# 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 `` with a `` header row and `` body rows, XSS-safe (escape-first, as the rest of the renderer). ## Acceptance criteria 1. **Pipe tables render:** header row + `|---|` separator row + body rows → `
` with `
` 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 `` 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 `` with a `` (header cells as `
`) 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 ``. 5. `test_fence_not_a_table` (regression) — a fenced code block full of `|` pipes renders as `
`, no `
`. 6. `test_plain_pipe_stays_text` (regression) — a prose answer with a single `|` renders as text, no `
`.