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:
parent
1c366a9059
commit
a1278e6ded
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue