Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
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
10 changes: 8 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 { codeColorPreviewTransformers } from "../lib/codeColorPreviews";
import { RenderErrorBoundary } from "./RenderErrorBoundary";
import { useTheme } from "../hooks/useTheme";
import { getClientSettings } from "../hooks/useSettings";
Expand Down Expand Up @@ -819,14 +820,19 @@ 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: codeColorPreviewTransformers(language),
});
} 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
// If highlighting fails for this language, render as plain text without
// colour previews: text tokens span whole lines, so prose would match
return highlighter.codeToHtml(code, { lang: "text", theme: themeName });
}
}, [code, highlighter, language, themeName]);
Expand Down
28 changes: 28 additions & 0 deletions apps/web/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -1833,6 +1833,12 @@ html[data-theme-id] .chat-markdown .chat-markdown-chrome-action[aria-pressed="tr
color: var(--code-foreground);
}

/* Themed palettes repaint the code surface with --code-foreground, so the
swatch border follows it instead of the app-chrome contrast colour. */
html[data-theme-id] .chat-markdown .chat-markdown-color-swatch {
border-color: color-mix(in srgb, var(--code-foreground) 35%, transparent);
}

html[data-theme-id] [data-app-sidebar] {
--background: var(--app-theme-canvas);
--foreground: var(--app-theme-text);
Expand Down Expand Up @@ -2338,6 +2344,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
63 changes: 63 additions & 0 deletions apps/web/src/lib/codeColorPreviews.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { describe, expect, it } from "vite-plus/test";

import {
codeColorPreviewParts,
codeColorPreviewTransformer,
codeColorPreviewTransformers,
} 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("ignores identifiers that merely start with a hex run", () => {
const code = "#define X #fffxyz #fff_value #fff-theme";
expect(codeColorPreviewParts(code)).toEqual([{ text: code }]);
});

it("previews colours only for languages that carry them", () => {
expect(codeColorPreviewTransformers("css")).toEqual([codeColorPreviewTransformer]);
expect(codeColorPreviewTransformers("json")).toEqual([codeColorPreviewTransformer]);
expect(codeColorPreviewTransformers("text")).toEqual([]);
expect(codeColorPreviewTransformers("c")).toEqual([]);
expect(codeColorPreviewTransformers("markdown")).toEqual([]);
});

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");
});
});
101 changes: 101 additions & 0 deletions apps/web/src/lib/codeColorPreviews.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
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-Za-z_-])/g;

/** Languages whose hex tokens are plausibly CSS colours. Elsewhere, hashes are
issue references, directives, or prose, so the swatch stays off. */
const CODE_COLOR_PREVIEW_LANGUAGES = new Set([
"css",
"scss",
"sass",
"less",
"stylus",
"postcss",
"html",
"vue",
"svelte",
"astro",
"json",
"jsonc",
"json5",
"yaml",
"yml",
"toml",
"javascript",
"js",
"mjs",
"cjs",
"jsx",
"typescript",
"ts",
"mts",
"cts",
"tsx",
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
]);

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: [],
},
],
};
});
},
};

export function codeColorPreviewTransformers(language: string): ShikiTransformer[] {
return CODE_COLOR_PREVIEW_LANGUAGES.has(language) ? [codeColorPreviewTransformer] : [];
}
Loading