Files
brain-of-reese/.agent/user_stories/markdown-tables.md
T
ducoterra 02c76ad328 chore(agent): phase roadmap from TODO.md — 8 phases (40–47), 24 tasks
Converts the 9 TODO items into an executable phase roadmap (Protocol B,
appended after phase 39):

- 40 tuning toggle anonymous flash (TODO L3)
- 41 sync fail-fast + modal when a model is down (TODO L4)
- 42 no reply autoscroll (TODO L5)
- 43 thinking scroll back — user scroll + gated autoscroll (TODO L7)
- 44 markdown tables (TODO L6)
- 45 agent unlimited tool calls behind BOR_AGENT_MAX_ROUNDS (TODO L8)
- 46 mobile hamburger nav (TODO L9)
- 47 quadlet + jinja import formats, A9 revision (TODO L10–L11)

Each phase carries a user story, a dedicated Playwright E2E suite plan,
and owner-locked decisions (R1 A9 format extension, R2 phase-37 budget
revision, A1–A5 scope decisions) confirmed 2026-08-27.

Also records the completed phases 30–39 todo/ -> complete/ moves that
were pending in the working tree. TODO.md is cleared (items now live in
.agent/phases/todo/).
2026-08-27 18:25:53 -04:00

5.1 KiB

Story: Markdown tables in chat (and everywhere the renderer runs)

Phase: 44_markdown_tables · Source: TODO.md L6 · E2E: tests/e2e/test_markdown_tables.py

Bug report (verbatim, TODO.md L6)

"Certain markdown formatting isn't working - tables for example don't get rendered as tables in the chat response."

Narrative

As a user, when Brain answers with a markdown table (services and ports, versions, schedules — the concrete specifics the persona is built around), I expect a real table: aligned columns, borders, readable. Today the shared renderer (frontend/assets/markdown.js, ~60 lines, no-CDN by A11) has no table support — a pipe table renders as one paragraph of raw | text.

  • Given Brain's answer (or a document / thinking block) contains a GFM pipe table
  • When the renderer runs
  • Then it renders a semantic <table> with a <thead> header row and <tbody> body rows, XSS-safe (escape-first, as the rest of the renderer).

Acceptance criteria

  1. Pipe tables render: header row + |---| separator row + body rows → <table class="md-table"> with <th scope="col"> header cells; leading/trailing pipes and in-cell whitespace are handled; cells keep their inline markdown (bold/em/code).
  2. XSS-safe: cell content is escaped before any transform — a cell containing <img onerror=…> renders inert (the escape-first guarantee, same as all other content).
  3. Code fences win: a |-heavy block inside a ``` fence is never parsed as a table (fence protection runs first, as today).
  4. Non-tables stay put: a single | in prose, a lone separator without a header, or a 1-line "table" is left as text.
  5. Wide tables: the table sits in an overflow-x: auto wrapper so a wide table scrolls horizontally instead of breaking the 46rem chat column (PLAN §7.1).
  6. Shared everywhere: the same renderer serves the chat answer, the document viewer, and the thinking block — all three render tables.
  7. Style: .md-table uses the existing dark-tech palette (PLAN §7.2 tokens, contrast ≥4.5:1); alignment colons in the separator are parsed but all cells render left-aligned (owner decision).

Owner-confirmed (2026-08-27, roadmap A3)

  1. Scope = GFM pipe tables (header + separator + body rows). Links, blockquotes, and hr are not in scope for this story.
  2. Alignment colons parsed, rendered left.
  3. Wide tables get a horizontal scroll wrapper inside the bubble.

UI Visualization & Structure

  • Renderer (frontend/assets/markdown.js): a table-protection pass between the existing fence pass and the escape pass — consecutive lines forming a table (every line contains |; line 2 matches the separator ^\s*\|?(\s*:?-{3,}:?\s*\|)+\s*:?-{3,}:?\s*\|?\s*$-style rule with ≥1 cell) are pulled out, each cell is escaped + inline- transformed, and the block is reinserted as protected HTML (same \u0000CODE…\u0000 placeholder mechanism as code fences, or a sibling placeholder — the existing restore step is the only place placeholders are re-expanded). Output shape: `
    …` / `
    `.
  • CSS (frontend/assets/styles.css): .md-table-wrap { overflow-x: auto; } (the wrapper is the scroller — the table keeps natural width); .md-table border-collapse, th/td borders from --line, padding ≈0.4rem 0.6rem, thead tinted from the surface palette; fits the 46rem column without a new container.
  • Mock (tests/e2e/mock_llm.py): new TABLE_TRIGGER (a substring like "show me a table") → compose_answer returns a fixed deterministic GFM table answer (checked before the default tail-echo branch), including one deliberately wide table for the overflow assertion.
  • Non-goals: no new library (A11), no CDN, no change to the escape- first architecture, no table editing in the steering/tuning UI.

Playwright Mapping Rule

Test Scenario → tests/e2e/test_markdown_tables.py (mock LLM; DB up):

  1. test_chat_table_renders — ask a question containing TABLE_TRIGGER; the brain bubble contains <table class="md-table"> with a <thead> (header cells as <th scope="col">) and the expected body-cell texts; no raw |---| separator text in the bubble.
  2. test_wide_table_scrolls — the mock's wide table: the bubble's .md-table-wrap has scrollWidth > clientWidth and horizontal wheel/scroll moves it; the 46rem column itself does not overflow the page.
  3. test_table_xss_safe — a table whose cell contains an HTML tag (mock variant or document content) renders the tag as text (no injected element).
  4. test_viewer_table_renders (shared renderer) — an indexed fixture document containing a pipe table opens in the document modal and renders the same <table class="md-table">.
  5. test_fence_not_a_table (regression) — a fenced code block full of | pipes renders as <pre><code>, no <table>.
  6. test_plain_pipe_stays_text (regression) — a prose answer with a single | renders as text, no <table>.