Skip to content

Add mermaid PNG download and colour palettes to the V2 chat - #1388

Merged
Paul Lizer (paullizer) merged 2 commits into
paullizer-react-v2-uifrom
paullizer-mermaid-png-and-palettes
Sep 2, 2026
Merged

Add mermaid PNG download and colour palettes to the V2 chat#1388
Paul Lizer (paullizer) merged 2 commits into
paullizer-react-v2-uifrom
paullizer-mermaid-png-and-palettes

Conversation

@paullizer

Copy link
Copy Markdown
Collaborator

What this adds

The V2 chat renders mermaid diagrams and SimpleChart charts, but a diagram could not be saved as an image and neither could be recoloured.

Save a diagram as a picture. Diagrams now carry a PNG button, matching the one charts already had. The image is rasterized from the SVG already on screen rather than re-rendered, so whatever colours and background are showing come with it, painted onto an opaque background so it stays readable wherever it is pasted. It reuses the path static/js/chat/chat-visual-rasterizer.js already uses for exports.

Choose the colours. Both block kinds get a Colors control offering the five palettes the classic chart editor already used — Default, Calm, Vivid, Warm, Contrast. Charts additionally get per-series and per-slice pickers, and both get a background colour that defaults to Match theme so it keeps following light and dark mode unless something specific is chosen.

Colours resolve through three layers, each overriding the one before it:

Layer Where it lives
Built-in default PALETTE_PRESETS in visualPalettes.ts
Your default v2MermaidStyle / v2ChartStyle, set in Settings → Preferences
Block override metadata.visual_styles on the message

Recolouring one chart never affects another. Three charts in a reply keep three independent sets of colours, and a chart with no entry follows the user default.

The invariant this had to preserve

A block nobody has recoloured renders exactly as it did before. Mermaid keeps its stock default/dark theme rather than switching to base + theme variables, and buildChartConfig produces byte-identical output. There is a test asserting the Chart.js configuration is unchanged for the default style, because existing conversations must not shift under this change.

Three bugs found in review, fixed here

  1. Block numbering missed fences in containers. The first implementation scanned the markdown text for fences, which disagrees with the parser: CommonMark admits fences indented four or more spaces inside a nested list item, and fences behind a blockquote's > prefix. Those blocks came out unnumbered, fell back to index 0, and overwrote the real block zero's saved colours. Replaced with rehypeRichBlockIndex, which walks the parsed tree the renderer is about to render, so there is no gap between what the parser calls a code block and what gets numbered. A block that still cannot be numbered is treated as unaddressable rather than defaulted to zero.
  2. A cache-key collision. visualStyleSignature did not distinguish "untouched" from "explicitly set to the colour the theme happens to be showing". The two are drawn by different means, so an untouched diagram could be rendered with another block's theme variables depending on render order.
  3. The unmount flush posted to the wrong conversation. A pending write read activeConversationId lazily, but switching conversations is one of the ways a block unmounts — by which point the active conversation is already the new one. The conversation is now captured when the change is scheduled.

Security

Colours reach inline style attributes and mermaid's theme configuration, so the accepted form is deliberately narrow:

  • A colour is reduced to lowercase #rrggbb or it is not stored. red, rgb(1,2,3) and url(...) are all refused even though a browser would apply them.
  • The server refuses an unknown palette; the client falls back only when reading a stored value it does not recognise, because it still has to draw something.
  • Unknown keys in a submitted style are dropped rather than stored.
  • Bounds: block index 0–199, 24 colour overrides per block, 100 styled blocks per message.

The endpoint authorizes the conversation via _authorize_personal_conversation_access rather than the message, because a diagram lives in an assistant message that carries no author of its own; this also admits a participant acting inside a shared conversation. Existing rendering guarantees are untouched: mermaid still runs securityLevel: 'strict' with htmlLabels: false, output still passes through DOMPurify as an independent second boundary, and bindFunctions is still never called.

No new npm package, no vendored library, and nothing fetched from the internet. The Content-Security-Policy is unchanged — it already permits the data: image source rasterizing needs.

Testing

Suite Result
test_v2_visual_style_controls.py (new) 16/16
test_v2_visual_style_logic.ts (new, 58 checks) pass
test_v2_rich_rendering.py 13/13
test_v2_settings_and_workspace_tags.py 9/9
test_v2_settings_tabs.py 6/6
test_docs_app_surface_coverage.py 7/7
test_docs_site_quality.py 6/6
route_tests/ (3 suites) 12/12

npm run build and tsc -b are clean.

The TypeScript checks run the real remark/rehype pipeline rather than a stand-in, which is the point: the bug they replaced was a hand-written scanner disagreeing with the parser, and only the parser's answer matters. They cover the blockquote and deep-indent cases directly.

Two existing assertions in test_v2_rich_rendering.py were updated rather than removed — one pinned isRichFence by symbol name, and one pins the exact rehype plugin list so adding a plugin stays a reviewable change.

Version and docs

0.261.0270.261.028. New docs/explanation/features/V2_DIAGRAM_AND_CHART_STYLING.md, plus updates to REACT_V2_UI.md, the features index and the release notes.

Note for the reviewer

docs/explanation/release-notes/*.md are generated from release_notes.md and were already stale before this branch — there is no v0.261.md page despite 15 releases in that series. I verified this by regenerating against an unmodified release_notes.md and getting the same drift, so I left them out rather than bundle an unrelated 7-file regeneration into this PR. Worth running python scripts/build_release_notes_pages.py as its own commit.

The V2 chat could render mermaid diagrams and SimpleChart charts, but a
diagram could not be saved as an image and neither could be recoloured.

Diagrams now carry a PNG button, rasterized from the SVG already on screen
so the file matches what is displayed. Both block kinds carry a Colors
control offering the five palettes the classic chart editor already used,
per-series and per-slice colours for charts, and a background colour.

Colours resolve through three layers: the built-in default, a per-user
default in Settings -> Preferences, and an override saved against one block
of one message. Recolouring one chart never affects another, and a block
nobody has touched renders exactly as it did before -- mermaid keeps its
stock theme and the Chart.js configuration is byte-identical.

A block is addressed by its number among blocks of the same kind, stamped
onto the parsed tree by a rehype plugin rather than by scanning the markdown
text: CommonMark admits fences nested in list items and behind a blockquote
prefix, which a text scan misses, and an unnumbered block would take block
zero's slot. A source fingerprint is stored alongside, so an override is
ignored rather than applied to different content after an edit.

Colours are reduced to #rrggbb on both sides before reaching a style
attribute or mermaid's theme configuration, and the stored map is bounded so
a message document cannot be grown by repeated requests. No new npm package
and no asset fetched from the internet; the Content-Security-Policy already
permits the data: image source rasterizing needs.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The base branch gained inline image proposals, chat notices, conversation
deep links and server-side mermaid rendering while this branch was open.
Five files conflicted.

AssistantMarkdown needed real integration rather than a pick. The base added
a third rich fence, ```image_proposal, to the RICH_FENCE_LANGUAGES set and
the `pre` renderer this branch had already replaced with tree-based
numbering. The fence list now lives in rehypeRichBlockIndex.ts alongside the
numbering, so one function answers both "does this fence lose its <pre>
wrapper" and "which block is it"; image proposals are numbered too, since
numbering is per kind and costs nothing.

The proposal card now reads its payload from the hast node with
`hast-util-to-text` rather than through the removed `fenceText`, which
walked rendered React children and could be handed highlighted spans instead
of the JSON.

MessageList keeps the base's ImageProposalScope wrapper with this branch's
messageId passed through to AssistantMarkdown.

Two assertions in test_v2_inline_image_proposals.py pinned the old
implementation by exact string -- the `fenceText(children)` call site and
the location of the rich-fence set. Both were updated to the current
mechanism, preserving their intent.

Version resolved to 0.261.033 above the base's 0.261.032, and the release
note section, feature doc and test headers renumbered to match.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@paullizer
Paul Lizer (paullizer) merged commit 6bf1aeb into paullizer-react-v2-ui Sep 2, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant