Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering";
import { fnv1a32 } from "../lib/diffRendering";
import { LRUCache } from "../lib/lruCache";
import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting";
import { codeColorPreviewTransformer } from "../lib/codeColorPreviews";
import { RenderErrorBoundary } from "./RenderErrorBoundary";
import { useTheme } from "../hooks/useTheme";
import { getClientSettings } from "../hooks/useSettings";
Expand Down Expand Up @@ -819,15 +820,23 @@ function UncachedShikiCodeBlock({
const highlighter = use(getSyntaxHighlighterPromise(language));
const highlightedHtml = useMemo(() => {
try {
return highlighter.codeToHtml(code, { lang: language, theme: themeName });
return highlighter.codeToHtml(code, {
lang: language,
theme: themeName,
transformers: [codeColorPreviewTransformer],
});
} catch (error) {
// Log highlighting failures for debugging while falling back to plain text
console.warn(
`Code highlighting failed for language "${language}", falling back to plain text.`,
error instanceof Error ? error.message : error,
);
// If highlighting fails for this language, render as plain text
return highlighter.codeToHtml(code, { lang: "text", theme: themeName });
return highlighter.codeToHtml(code, {
lang: "text",
theme: themeName,
transformers: [codeColorPreviewTransformer],
});
}
}, [code, highlighter, language, themeName]);

Expand Down
22 changes: 22 additions & 0 deletions apps/web/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -2338,6 +2338,28 @@ code {
background: transparent !important;
}

.chat-markdown .chat-markdown-color-literal {
white-space: nowrap;
}

.chat-markdown .chat-markdown-color-swatch {
display: inline-block;
width: 0.72em;
height: 0.72em;
margin-inline-start: 0.32em;
border: 1px solid color-mix(in srgb, var(--contrast-foreground) 35%, transparent);
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
border-radius: 2px;
background: var(--chat-markdown-color);
vertical-align: -0.04em;
transform-origin: center;
}

.chat-markdown .chat-markdown-color-literal:hover .chat-markdown-color-swatch {
position: relative;
z-index: 1;
transform: scale(1.6);
}

/* Diagnostics-style tables: row separators only, uppercase headers, and a
scroll-fade container for horizontal overflow. The root chat-markdown
wrapping rules (overflow-wrap: anywhere) would let columns shrink to single
Expand Down
46 changes: 46 additions & 0 deletions apps/web/src/lib/codeColorPreviews.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vite-plus/test";

import { codeColorPreviewParts, codeColorPreviewTransformer } from "./codeColorPreviews";
import { resolveDiffThemeName } from "./diffRendering";
import { getSyntaxHighlighterPromise } from "./syntaxHighlighting";

describe("codeColorPreviewParts", () => {
it("finds CSS hex colours without changing the surrounding code", () => {
expect(codeColorPreviewParts('"idle": "#701525",')).toEqual([
{ text: '"idle": "' },
{ text: "#701525", color: "#701525" },
{ text: '",' },
]);
});

it("supports every CSS hex colour length", () => {
expect(codeColorPreviewParts("#abc #abcd #a1b2c3 #A1B2C3D4")).toEqual([
{ text: "#abc", color: "#abc" },
{ text: " " },
{ text: "#abcd", color: "#abcd" },
{ text: " " },
{ text: "#a1b2c3", color: "#a1b2c3" },
{ text: " " },
{ text: "#A1B2C3D4", color: "#A1B2C3D4" },
]);
});

it("ignores invalid lengths and hashes embedded in identifiers", () => {
const code = "#12 #12345 #123456789 token#abcdef hash-tag#123";
expect(codeColorPreviewParts(code)).toEqual([{ text: code }]);
});

it("adds a decorative swatch to highlighted code", async () => {
const highlighter = await getSyntaxHighlighterPromise("json");
const html = highlighter.codeToHtml('{"idle":"#701525"}', {
lang: "json",
theme: resolveDiffThemeName("dark"),
transformers: [codeColorPreviewTransformer],
});

expect(html).toContain('class="chat-markdown-color-literal"');
expect(html).toContain('class="chat-markdown-color-swatch"');
expect(html).toContain('aria-hidden="true"');
expect(html).toContain("--chat-markdown-color: #701525");
});
});
66 changes: 66 additions & 0 deletions apps/web/src/lib/codeColorPreviews.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import type { ShikiTransformer } from "@pierre/diffs";

const CSS_HEX_COLOR_REGEX =
/(^|[^0-9A-Za-z_-])(#[0-9A-Fa-f]{8}|#[0-9A-Fa-f]{6}|#[0-9A-Fa-f]{4}|#[0-9A-Fa-f]{3})(?![0-9A-Fa-f])/g;
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated

interface CodeColorPreviewPart {
readonly text: string;
readonly color?: string;
}

export function codeColorPreviewParts(text: string): CodeColorPreviewPart[] {
const parts: CodeColorPreviewPart[] = [];
let cursor = 0;

for (const match of text.matchAll(CSS_HEX_COLOR_REGEX)) {
const color = match[2];
if (!color || match.index == null) continue;

const colorStart = match.index + (match[1]?.length ?? 0);
if (colorStart > cursor) {
parts.push({ text: text.slice(cursor, colorStart) });
}
parts.push({ text: color, color });
cursor = colorStart + color.length;
}

if (cursor < text.length || parts.length === 0) {
parts.push({ text: text.slice(cursor) });
}
return parts;
}

export const codeColorPreviewTransformer: ShikiTransformer = {
name: "t3-code-color-previews",
span(hast) {
const textNode = hast.children.length === 1 ? hast.children[0] : undefined;
if (textNode?.type !== "text") return;

const parts = codeColorPreviewParts(textNode.value);
if (!parts.some((part) => part.color != null)) return;

hast.children = parts.map((part) => {
if (!part.color) {
return { type: "text", value: part.text };
}
return {
type: "element",
tagName: "span",
properties: { className: ["chat-markdown-color-literal"] },
children: [
{ type: "text", value: part.text },
{
type: "element",
tagName: "span",
properties: {
className: ["chat-markdown-color-swatch"],
ariaHidden: "true",
style: `--chat-markdown-color: ${part.color}`,
},
children: [],
},
],
};
});
},
};
Loading