diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 13024a7516ff..2cb472915bea 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -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"; @@ -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]); diff --git a/apps/web/src/index.css b/apps/web/src/index.css index f69adb9cf08e..0a4d090e9a17 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -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); @@ -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); + 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 diff --git a/apps/web/src/lib/codeColorPreviews.test.ts b/apps/web/src/lib/codeColorPreviews.test.ts new file mode 100644 index 000000000000..d71980f778e6 --- /dev/null +++ b/apps/web/src/lib/codeColorPreviews.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { codeColorPreviewParts, 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("keeps issue references and private names bare when short forms are off", () => { + expect(codeColorPreviewParts("// fixes #1904 via this.#abc and #a1b2c3", false)).toEqual([ + { text: "// fixes #1904 via this.#abc and " }, + { text: "#a1b2c3", color: "#a1b2c3" }, + ]); + }); + + it("previews colours only for languages that carry them", () => { + expect(codeColorPreviewTransformers("css").map((transformer) => transformer.name)).toEqual([ + "t3-code-color-previews", + ]); + expect(codeColorPreviewTransformers("json").map((transformer) => transformer.name)).toEqual([ + "t3-code-color-previews-long-hex", + ]); + 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: codeColorPreviewTransformers("json"), + }); + + 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"); + }); +}); diff --git a/apps/web/src/lib/codeColorPreviews.ts b/apps/web/src/lib/codeColorPreviews.ts new file mode 100644 index 000000000000..936479fba081 --- /dev/null +++ b/apps/web/src/lib/codeColorPreviews.ts @@ -0,0 +1,124 @@ +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; +const CSS_LONG_HEX_COLOR_REGEX = + /(^|[^0-9A-Za-z_-])(#[0-9A-Fa-f]{8}|#[0-9A-Fa-f]{6})(?![0-9A-Za-z_-])/g; + +/** Style languages keep the #rgb/#rgba short forms. In data and script + languages a short hex run is usually an issue reference or a JS private + name, so only #rrggbb/#rrggbbaa get a swatch there. Elsewhere hashes are + directives or prose, so the swatch stays off entirely. */ +const STYLE_LANGUAGES = new Set([ + "css", + "scss", + "sass", + "less", + "stylus", + "postcss", + "html", + "vue", + "svelte", + "astro", +]); +const LONG_HEX_LANGUAGES = new Set([ + "json", + "jsonc", + "json5", + "yaml", + "yml", + "toml", + "javascript", + "js", + "mjs", + "cjs", + "jsx", + "typescript", + "ts", + "mts", + "cts", + "tsx", +]); + +interface CodeColorPreviewPart { + readonly text: string; + readonly color?: string; +} + +export function codeColorPreviewParts( + text: string, + allowShortForms = true, +): CodeColorPreviewPart[] { + const regex = allowShortForms ? CSS_HEX_COLOR_REGEX : CSS_LONG_HEX_COLOR_REGEX; + const parts: CodeColorPreviewPart[] = []; + let cursor = 0; + + for (const match of text.matchAll(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; +} + +function createCodeColorPreviewTransformer( + name: string, + allowShortForms: boolean, +): ShikiTransformer { + return { + name, + span(hast) { + const textNode = hast.children.length === 1 ? hast.children[0] : undefined; + if (textNode?.type !== "text") return; + + const parts = codeColorPreviewParts(textNode.value, allowShortForms); + 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: [], + }, + ], + }; + }); + }, + }; +} + +const styleTransformer = createCodeColorPreviewTransformer("t3-code-color-previews", true); +const longHexTransformer = createCodeColorPreviewTransformer( + "t3-code-color-previews-long-hex", + false, +); + +export function codeColorPreviewTransformers(language: string): ShikiTransformer[] { + if (STYLE_LANGUAGES.has(language)) return [styleTransformer]; + if (LONG_HEX_LANGUAGES.has(language)) return [longHexTransformer]; + return []; +}