fix(markdown): make rendered code blocks follow the active theme (#11591)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Agents report their work in task chat, and that chat renders
markdown through `MarkdownBody`
> - Agents post shell commands, API payloads and diffs, so a fenced code
block is one of the most frequent things a person reads in the product
> - The rendered code block was pinned to two literal colors, so it
stayed dark in light mode and was the only dark surface on a light page
> - The `prose` and the `prose-invert` variables held the same two
values, so the theme could not change the block at all
> - This pull request binds the block to the theme tokens that already
exist and already carry a `.dark` override
> - The benefit is that a code block now matches the page in both modes,
and it follows any future change to the theme automatically

## Linked Issues or Issue Description

No public issue exists. The problem is described below.

**What happened?**
A fenced code block in rendered markdown is always dark. It uses the
background `#1e1e2e` and the text color `#cdd6f4` in light mode and in
dark mode. In light mode the block is the only dark surface on the page.
The rule is in `ui/src/index.css`. The variables `--tw-prose-pre-bg` and
`--tw-prose-invert-pre-bg` are set to the same literal value, so
`prose-invert` cannot change it.

**Expected behavior**
A code block uses a light surface with dark text in light mode. It uses
a dark surface with light text in dark mode. It follows the theme like
every other surface in the app.

**Steps to reproduce**
1. Start the app and open a task that contains a fenced code block in
its chat.
2. Set the theme to light.
3. Look at the code block. The block is dark. The page is light.
4. Set the theme to dark. The block does not change.

**Paperclip version or commit**
Reproduced on `master` at `4af55ba6b`.

**Agent adapter(s) involved**
Not adapter-specific (core bug). The defect is in the UI render path.

**Deployment mode**
Local development server.

## What Changed

- Bind `.paperclip-markdown pre` to `--muted`, `--foreground`,
`--border` and `--radius-lg` in `ui/src/index.css`. Remove the `#1e1e2e`
and `#cdd6f4` literals.
- Set the four `--tw-prose-*-pre-*` variables to the same tokens, so
`prose` and `prose-invert` both follow the theme.
- Apply the same four tokens to `.paperclip-mdxeditor-content pre`.
- Change the fill of the copy button and the wrap button to
`--background` in `ui/src/components/MarkdownBody.tsx`. The old fill was
`color-mix(in oklab, var(--muted) 92%, var(--background) 8%)`. That
value is almost equal to the block's new `--muted` surface, so the
buttons would nearly disappear.
- Update the stale comment above the rule. The comment said "Dark theme
code blocks".
- Add `ui/src/components/MarkdownCodeBlockStyles.test.ts`. It fails if a
literal color returns to any themed code surface.
- Export `codeBlockActionStyle` from `MarkdownBody.tsx` so the new test
can read it.

No new design token is added. Every token used here is already defined
in `ui/src/index.css`, and each one already has a `.dark` override.

## Verification

Automated:

```bash
cd ui && npx vitest run --config vitest.config.ts src/components/MarkdownCodeBlockStyles.test.ts src/components/MarkdownBody.test.tsx src/components/MarkdownBody.wrap.test.tsx src/components/MarkdownAccentStyles.test.ts
```

59 tests pass.

`MarkdownCodeBlockStyles.test.ts` is new. It guards the defect directly.
It reads `index.css` and asserts that each themed code surface rides a
token and holds no hex, `rgb()` or `hsl()` literal. I confirmed the test
fails on the original defect: restoring `#1e1e2e` and `#cdd6f4` fails 2
of the 5 tests.

Manual. Open a task that has a fenced code block in its chat. Read the
computed style of `.paperclip-markdown pre` in the browser. The measured
values are:

| Property | Light | Dark |
| --- | --- | --- |
| background | `oklch(0.97 0 0)` | `oklch(0.269 0 0)` |
| color | `oklch(0.145 0 0)` | `oklch(0.985 0 0)` |
| border | `oklch(0.922 0 0)` | `oklch(1 0 0 / 0.1)` |
| radius | `8px` | `8px` |

These values are `--muted`, `--foreground`, `--border` and `--radius-lg`
for each mode. Toggle the theme and confirm the block changes with the
page.

## Risks

Low risk. The change is CSS and one inline style value. There is no
migration and no schema change.

Two behavioral notes for the reviewer:

1. The corner radius changes from `calc(var(--radius) - 3px)` (5px) to
`var(--radius-lg)` (8px). This matches the approved design and removes
an arbitrary offset. It is a small visual change on every code block.
2. The CodeMirror theme inside the MDXEditor still uses the Catppuccin
literals (`ui/src/index.css`, the `.paperclip-mdxeditor .cm-editor`
rules). That surface is an editor, not a rendered snippet, and a change
there needs a full light CodeMirror theme. A code block therefore looks
light when it is rendered and dark while a person edits it. This is
intentional in this pull request. Tell me if you want it in scope.

Syntax highlighting, line numbers and diff rows are not in this pull
request. The repository has no highlighter dependency and no syntax or
diff tokens. Those are separate changes.

## Model Used

Claude Opus 5 (`claude-opus-5`), via Claude Code. Extended thinking was
on. Tool use was on, with file edit, shell, browser automation and the
Paper design tool. The design was produced first in Paper, then read
back through the design tool for exact token values rather than from
screenshots.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [ ] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

Notes on the unchecked boxes:

- Branch name. The branch is
`claude/paperclip-code-snippet-styling-202f7d`. It describes the change,
but the `202f7d` suffix comes from the local worktree name. It carries
no ticket id. I did not rename it, because the branch was already named
when this work was requested. Tell me if you want it renamed before
review.
- CI and Greptile. Not yet run at the time of opening.

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
scotttong 2026-08-17 21:36:56 -07:00 committed by GitHub
parent 1c366a9059
commit a1278e6ded
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 88 additions and 16 deletions

View File

@ -231,7 +231,7 @@ const codeBlockActionsStyle: React.CSSProperties = {
gap: "0.25rem",
};
const codeBlockActionStyle: React.CSSProperties = {
export const codeBlockActionStyle: React.CSSProperties = {
position: "static",
opacity: 1,
display: "inline-flex",
@ -242,7 +242,7 @@ const codeBlockActionStyle: React.CSSProperties = {
padding: "0.2rem 0.4rem",
borderRadius: "calc(var(--radius) - 4px)",
border: "1px solid color-mix(in oklab, var(--foreground) 14%, transparent)",
backgroundColor: "color-mix(in oklab, var(--muted) 92%, var(--background) 8%)",
backgroundColor: "var(--background)",
color: "var(--muted-foreground)",
fontSize: "var(--text-micro)",
lineHeight: 1,

View File

@ -0,0 +1,70 @@
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { codeBlockActionStyle } from "./MarkdownBody";
const stylesheet = readFileSync(fileURLToPath(new URL("../index.css", import.meta.url)), "utf8");
function cssBlock(selector: string): string {
const start = stylesheet.indexOf(`${selector} {`);
expect(start, `Missing CSS selector: ${selector}`).toBeGreaterThanOrEqual(0);
const bodyStart = stylesheet.indexOf("{", start);
const bodyEnd = stylesheet.indexOf("\n}", bodyStart);
expect(bodyStart, `Missing CSS block start: ${selector}`).toBeGreaterThanOrEqual(0);
expect(bodyEnd, `Missing CSS block end: ${selector}`).toBeGreaterThan(bodyStart);
return stylesheet.slice(bodyStart + 1, bodyEnd);
}
/* The rendered code block used to be pinned to the Catppuccin literals
#1e1e2e / #cdd6f4, in the normal AND the prose-invert variables. A code
block therefore stayed dark in light mode. These tests fail if any of
those surfaces is pinned to a literal again, rather than riding a token
that carries a `.dark` override. */
describe("markdown code block styles", () => {
it("rides theme tokens for the rendered code block surface", () => {
const block = cssBlock(".paperclip-markdown pre");
expect(block).toContain("background-color: var(--muted)");
expect(block).toContain("color: var(--foreground)");
expect(block).toContain("border: 1px solid var(--border)");
expect(block).toContain("border-radius: var(--radius-lg)");
});
it("rides theme tokens for the editor content code block surface", () => {
const block = cssBlock(".paperclip-mdxeditor-content pre");
expect(block).toContain("background: var(--muted)");
expect(block).toContain("color: var(--foreground)");
expect(block).toContain("border: 1px solid var(--border)");
expect(block).toContain("border-radius: var(--radius-lg)");
});
it("points the normal and inverted prose variables at the same tokens", () => {
const block = cssBlock(".paperclip-markdown");
expect(block).toContain("--tw-prose-pre-bg: var(--muted)");
expect(block).toContain("--tw-prose-pre-code: var(--foreground)");
expect(block).toContain("--tw-prose-invert-pre-bg: var(--muted)");
expect(block).toContain("--tw-prose-invert-pre-code: var(--foreground)");
});
it("keeps every themed code surface free of hardcoded colors", () => {
for (const selector of [".paperclip-markdown pre", ".paperclip-mdxeditor-content pre", ".paperclip-markdown"]) {
// Any hex literal, or an rgb()/hsl() literal, re-pins the surface to one mode.
expect(cssBlock(selector), `${selector} must not hardcode a color`).not.toMatch(
/#[0-9a-f]{3,8}\b|\brgba?\(|\bhsla?\(/i,
);
}
});
it("separates the copy and wrap controls from the code block surface", () => {
// The controls sit on top of the block. --muted is now the block's own
// fill, so the controls must not use it or they disappear against it.
expect(codeBlockActionStyle.backgroundColor).toBe("var(--background)");
expect(codeBlockActionStyle.backgroundColor).not.toContain("--muted");
});
});

View File

@ -1406,10 +1406,10 @@ a.paperclip-mention-chip[data-mention-kind="agent"]::before {
.paperclip-mdxeditor-content pre {
margin: 0.4rem 0;
padding: 0;
border: 1px solid color-mix(in oklab, var(--foreground) 12%, transparent);
border-radius: calc(var(--radius) - 3px);
background: #1e1e2e;
color: #cdd6f4;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: var(--muted);
color: var(--foreground);
overflow-x: auto;
}
@ -1482,20 +1482,22 @@ a.paperclip-mention-chip[data-mention-kind="agent"]::before {
}
/* Rendered markdown code blocks & inline code (prose/MarkdownBody context).
Dark theme code blocks with compact sizing.
Override prose CSS variables so prose-invert can't revert to defaults. */
Theme-aware: the block rides --muted/--foreground/--border so it follows
light and dark instead of pinning the old Catppuccin #1e1e2e/#cdd6f4 in
both modes. Override prose CSS variables so prose-invert can't revert to
defaults. */
.paperclip-markdown {
--tw-prose-pre-bg: #1e1e2e;
--tw-prose-pre-code: #cdd6f4;
--tw-prose-invert-pre-bg: #1e1e2e;
--tw-prose-invert-pre-code: #cdd6f4;
--tw-prose-pre-bg: var(--muted);
--tw-prose-pre-code: var(--foreground);
--tw-prose-invert-pre-bg: var(--muted);
--tw-prose-invert-pre-code: var(--foreground);
}
.paperclip-markdown pre {
border: 1px solid color-mix(in oklab, var(--foreground) 12%, transparent) !important;
border-radius: calc(var(--radius) - 3px) !important;
background-color: #1e1e2e !important;
color: #cdd6f4 !important;
border: 1px solid var(--border) !important;
border-radius: var(--radius-lg) !important;
background-color: var(--muted) !important;
color: var(--foreground) !important;
padding: 0.5rem 0.65rem !important;
margin: 0.4rem 0 !important;
font-size: 1em !important;