mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 17:45:53 +01:00
The block follows the theme now, but every token in it is one color. Add syntax highlighting, using the palette from the approved design. Render: add rehype-highlight and pass it to react-markdown. The plugin array sits at module scope, because react-markdown remounts the rendered tree when the array identity changes. That is the same hazard the remarkPlugins memo already guards against. Detection: `detect` is on, because most agent-authored fences carry no language tag and would never highlight without it. `subset` restricts detection to the languages agents actually post. highlight.js guesses badly on short snippets, and a wrong guess colors the whole block wrongly. Palette: eight `--code-syntax-*` aliases carry the roles. Each one points at a token that already exists and already has a `.dark` override, so a custom property resolves per mode at its use site and one definition covers both themes. The mapping is BORROWED, and this is the part to review. Paperclip has no syntax palette. `--status-task-icon-*` and `--chip-match-title-fg` name task-status and search-chip concerns. They were used because they are the only hues that carry a real per-mode value; the fixed `--status-task-*` hues go dim on the dark code surface. The coupling is confined to the alias block. Repointing those eight lines moves the whole palette, and no rule changes. A test asserts that the syntax rules never reference the borrowed tokens directly. Verified in both themes on a real task. Light resolves keyword #7c3aed, identifier oklch(0.145 0 0), function oklch(0.4 0.13 265), comment oklch(0.556 0 0). Dark resolves #9474f0, oklch(0.985 0 0), oklch(0.78 0.1 265), oklch(0.708 0 0). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
113 lines
4.5 KiB
TypeScript
113 lines
4.5 KiB
TypeScript
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");
|
|
});
|
|
});
|
|
|
|
describe("code syntax palette", () => {
|
|
const aliases = [
|
|
"--code-syntax-keyword",
|
|
"--code-syntax-string",
|
|
"--code-syntax-number",
|
|
"--code-syntax-identifier",
|
|
"--code-syntax-punctuation",
|
|
"--code-syntax-comment",
|
|
"--code-syntax-added",
|
|
"--code-syntax-removed",
|
|
];
|
|
|
|
it("defines every syntax role as an alias of an existing token", () => {
|
|
for (const alias of aliases) {
|
|
const declaration = new RegExp(`${alias}:\\s*var\\(--[a-z0-9_-]+\\)`, "i");
|
|
expect(stylesheet, `${alias} must be defined as var(--existing-token)`).toMatch(declaration);
|
|
}
|
|
});
|
|
|
|
it("holds the borrowed-token coupling in the alias layer only", () => {
|
|
// The syntax rules must reference the aliases, never the borrowed tokens
|
|
// directly. That keeps repointing to a real palette a one-block edit.
|
|
const syntaxRules = stylesheet.slice(stylesheet.indexOf(".paperclip-markdown :is(pre, code)"));
|
|
|
|
expect(syntaxRules).not.toContain("--status-task-icon-");
|
|
expect(syntaxRules).not.toContain("--chip-match-title-fg");
|
|
});
|
|
|
|
it("maps the highlight.js token classes the renderer emits", () => {
|
|
for (const cls of [
|
|
".hljs-keyword",
|
|
".hljs-string",
|
|
".hljs-number",
|
|
".hljs-comment",
|
|
".hljs-addition",
|
|
".hljs-deletion",
|
|
]) {
|
|
expect(stylesheet, `${cls} must be mapped`).toContain(cls);
|
|
}
|
|
});
|
|
});
|