fix(workspace): tables, headings and lists in agent replies get a typographic treatment (#2616) - #2684
Conversation
`.prose-portal` styled exactly four things — paragraphs, unordered lists, links and code — and Tailwind's preflight reset everything else, so every other element an agent can write arrived with the browser reset and nothing else: a GFM table with no rules and no padding, headings at body weight, ordered lists with their numbers gone. The element set is now covered where the render, the style and the copy handler already live (PortalMarkdown.vue), so the bubble, the room and any future surface inherit one look rather than a second one being copied out: - Tables get the platform's own anatomy — mono-caps header on chrome, 6/12 cell padding, a rule per row — as an object one step off the bubble tint, the same shape a code block already has. The scroll-viewport pair is lifted from CanvasKit (#2583): `display: block` makes the table its own horizontal viewport so a ten-column table scrolls instead of widening the bubble, and `overflow-wrap: normal` in the cells stops an inherited "break anywhere" squeezing an auto-layout table to one character per line. `width: fit-content` keeps a two-column table from drawing its border around an acre of nothing. - GFM alignment survives sanitising as the `align` attribute, but a presentational hint loses to author CSS, so the right/center cases are restated and the table carries `tabular-nums`. - Headings get a bounded ladder: `# Title` names a section of the reply, not the page, so it tops out at the design system's section size (18/650) and lands on the meta overline by h3 — three visibly distinct steps, all inside the six-size scale. - Ordered lists, list items, nested lists, blockquotes (the `ck-callout` 3px left rule, so a quoted line reads the same in a message and on a canvas) and horizontal rules. Nothing here touches the sanitiser: the treatment is CSS on platform-owned selectors, no new admitted tags, attributes or classes. Code blocks keep the #2515 behaviour — labelled bar, always-visible Copy, wrap-at-the-edge `pre`. One #2515 assertion is narrowed rather than deleted: it read "the sheet has no `overflow-x` at all", which was a claim about the `pre` (a scroller inside a chat bubble hides the end of a line behind a gesture nobody makes) written as a claim about the whole stylesheet. A table must scroll in its own viewport, so the assertion now looks at the `pre` rule it was always about. Verified in light and dark at 1100px and at 430px. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GawbQRicLvNMaPknFT8BfR
Its own commit, with the growth named, per the design-system contract — a deliberate increase is never absorbed into a feature diff. - components/portal/PortalMarkdown.vue: raw_gray 13 -> 34 (+21). Giving the Workspace transcript a typographic treatment (#2616) costs a surface, border and ink pair per element in BOTH themes — table, header row, cells, headings, blockquote, rule — and those are gray by definition ("everything else is gray"). raw_nongray stays 0; hardcoded_colors stays 0. - components/portal/PortalJumpToLatest.vue: newly recorded at 1 gray. It arrived on dev after the last freeze and had no entry. #2605's `refrozen` record is carried forward by hand: the regenerator writes the file wholesale and would have dropped the history of why the ceiling sits where it does. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GawbQRicLvNMaPknFT8BfR
CodeQL read the helper's one-pass `replace(/<!--[\s\S]*?-->/g, '')` as js/incomplete-multi-character-sanitization (high): nested input (`<!--<!-- -->`) leaves a live `<!--` behind. Nothing untrusted reaches this helper — it reads a checked-in `.vue` file so the assertions are not confused by prose in comments — but the loop is the rule's prescribed fix and the stronger strip, and `hardeningGuide.spec.js` already carries the same fixpoint shape for the same reason, so this matches the established pattern rather than dismissing the alert. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lh6WrChj2636YEy5UZmZ1w
…2616) The first attempt ran the fixpoint loop over all three comment strips chained together, and CodeQL flagged the same rule again: the query only recognises the loop as a complete sanitiser when the flagged replace's result is assigned straight back to the loop variable, which a chain of three breaks. Split out `withoutHtmlComments` so the shape matches `hardeningGuide.spec.js` exactly — the one form already proven clean on dev — and chain the JavaScript comment strips after it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lh6WrChj2636YEy5UZmZ1w
Conflict was `raw-color-baseline.json` alone, and only because both sides re-froze it: dev's own frontend work moved `base/fieldClasses.js` 11 -> 15 gray and recorded `portal/PortalJumpToLatest.vue`, while this branch recorded `portal/PortalMarkdown.vue` 13 -> 34. The file is generated, so it was resolved by regenerating it against the merged tree rather than by hand-picking hunks — the ratchet (`tests/unit/rawColorRatchet.spec.js`) requires the baseline to be EXACT, and a hand-merged entry that is merely plausible re-permits regressions up to a stale ceiling. Both sides' `refrozen` prose is kept: dev's block plus this branch's `_2616_note`, minus its now-stale sentence about PortalJumpToLatest, which dev has since recorded itself. Result differs from dev by exactly one entry (PortalMarkdown.vue, the change this PR makes) and from this branch by exactly one (fieldClasses.js, the change dev makes). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lh6WrChj2636YEy5UZmZ1w
|
merge-train 2026-09-10 (evening run): riding. Your For the record, since the next person to hit this conflict will reach for it: Two nits, not blockers: |
Closes #2616
What was wrong
components/portal/PortalMarkdown.vuestyled exactly four things — paragraphs, unordered lists, links, and code (#2515). Tailwind's preflight resets everything else, so every other element an agent can write arrived with the browser reset and nothing else: a GFM table with no rules, no padding and no header separation; headings at body weight and size; ordered lists with their numbers gone. The transcript was the one markdown surface in the app with no typographic treatment, and the table was simply where it showed worst.What changed
All of it lands in
PortalMarkdown.vue, the single mount point that already owns render + style + copy as one unit — so the bubble, the room and any future surface inherit one look instead of a second sheet being copied out.CanvasKit.vue(abilityai/trinity-enterprise#537 / bug(canvas): every block layout an agent can produce renders unbroken in the workspace column — gallery test + fixes #2583):display: blockmakes the table its own horizontal viewport so a ten-column table scrolls instead of widening the bubble, andoverflow-wrap: normalin the cells stops an inherited "break anywhere" squeezing an auto-layout table to one character per line.width: fit-contentkeeps a two-column table from drawing its border around an acre of nothing.tabular-nums. GFM alignment survives sanitising as thealignattribute, but a presentational hint loses to author CSS, so the right/center cases are restated explicitly.# Titlenames a section of the reply, not the page, so it tops out at the design system's section size (18/650) and lands on the meta overline by h3 — three visibly distinct steps, all inside the six-size scale.ck-callout's 3px left rule, so a quoted line reads the same whether the agent wrote it into a message or onto a canvas.Kept the hand-rolled sheet rather than adopting
prose prose-sm dark:prose-invert: the plugin would have had to be un-styled back offpre,codeand the heading scale to preserve #2515 and keep an# h1out of page-title size, which is a larger and riskier diff than covering the missing element set directly. The issue names either as acceptable.Not touched
The sanitiser. This is CSS on platform-owned selectors — no new admitted tags, attributes, classes or inline styles. Code blocks keep their #2515 behaviour: labelled bar, always-visible Copy, wrap-at-the-edge
pre.One assertion narrowed, not deleted
portalAgentBubble.spec.jsasserted the stylesheet contained nooverflow-xat all. The claim #2515 actually makes is about thepre— a scroller inside a chat bubble hides the end of a line behind a gesture nobody makes — written as a claim about the whole sheet. A table must scroll in its own viewport (AC #2 here), so the assertion now looks at theprerule it was always about.Verification
tests/unit/portalMarkdownTypography.spec.js— 12 new cases: what the configuredmarkedemits (table withalignattributes, ordered + nested lists, heading run, blockquote,hr) and that the sheet covers that element set, keeps the heading ladder bounded, keepsoverflow-wrap: anywhereconfined to thepre, admits nothing new through the sanitiser, and is still the only copy.npm run test:unit— 2599 passed. The one failure,portalAgentsAtCentre.spec.js > the agent row time is tight, reproduces on a cleanorigin/devcheckout and is unrelated to this branch.npm run check:tokens— OK.The raw-colour baseline re-freeze is its own commit with the growth named (+21 gray in this file,
raw_nongraystill 0), per the contract's "never absorbed into a feature diff".🤖 Generated with Claude Code
https://claude.ai/code/session_01GawbQRicLvNMaPknFT8BfR