From 4ce7fafc79f79c9ccb9c151b60ce1eb1e7389102 Mon Sep 17 00:00:00 2001 From: msegec Date: Sun, 23 Aug 2026 15:58:31 +0800 Subject: [PATCH 1/3] feat(web): preview colors in code blocks Render swatches through Shiki token output so source and copied code stay unchanged. --- apps/web/src/components/ChatMarkdown.tsx | 13 ++++- apps/web/src/index.css | 22 ++++++++ apps/web/src/lib/codeColorPreviews.test.ts | 46 +++++++++++++++ apps/web/src/lib/codeColorPreviews.ts | 66 ++++++++++++++++++++++ 4 files changed, 145 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/lib/codeColorPreviews.test.ts create mode 100644 apps/web/src/lib/codeColorPreviews.ts diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 13024a7516ff..837a7a9bf7dd 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 { codeColorPreviewTransformer } from "../lib/codeColorPreviews"; import { RenderErrorBoundary } from "./RenderErrorBoundary"; import { useTheme } from "../hooks/useTheme"; import { getClientSettings } from "../hooks/useSettings"; @@ -819,7 +820,11 @@ 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( @@ -827,7 +832,11 @@ function UncachedShikiCodeBlock({ 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]); diff --git a/apps/web/src/index.css b/apps/web/src/index.css index f69adb9cf08e..b0e72d9c41f9 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -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); + 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..8b51a09ecdaa --- /dev/null +++ b/apps/web/src/lib/codeColorPreviews.test.ts @@ -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"); + }); +}); diff --git a/apps/web/src/lib/codeColorPreviews.ts b/apps/web/src/lib/codeColorPreviews.ts new file mode 100644 index 000000000000..a90de78f11db --- /dev/null +++ b/apps/web/src/lib/codeColorPreviews.ts @@ -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; + +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: [], + }, + ], + }; + }); + }, +}; From 6478495333d863eed2af79e2ea04a0bb89f8a6e1 Mon Sep 17 00:00:00 2001 From: msegec Date: Sun, 23 Aug 2026 16:19:16 +0800 Subject: [PATCH 2/3] fix(web): keep colour swatches off non-colour hashes and themed borders readable The hex regex only rejected trailing hex digits, so identifiers such as #define, #fffxyz, and #fff-theme grew a swatch mid-word. The trailing lookahead now mirrors the leading boundary. The transformer ran on every language including the plain-text fallback, where a whole line is one token, so prose and issue references in unknown-language fences were scanned. Previews are now enabled only for languages that plausibly carry CSS colours, and the fallback renders without them. The swatch border mixed --contrast-foreground while themed palettes repaint the code surface, washing it out on custom themes. A themed counterpart now follows --code-foreground like the rest of the code-block chrome. --- apps/web/src/components/ChatMarkdown.tsx | 13 +++----- apps/web/src/index.css | 6 ++++ apps/web/src/lib/codeColorPreviews.test.ts | 19 ++++++++++- apps/web/src/lib/codeColorPreviews.ts | 37 +++++++++++++++++++++- 4 files changed, 65 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 837a7a9bf7dd..2cb472915bea 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -65,7 +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 { codeColorPreviewTransformers } from "../lib/codeColorPreviews"; import { RenderErrorBoundary } from "./RenderErrorBoundary"; import { useTheme } from "../hooks/useTheme"; import { getClientSettings } from "../hooks/useSettings"; @@ -823,7 +823,7 @@ function UncachedShikiCodeBlock({ return highlighter.codeToHtml(code, { lang: language, theme: themeName, - transformers: [codeColorPreviewTransformer], + transformers: codeColorPreviewTransformers(language), }); } catch (error) { // Log highlighting failures for debugging while falling back to plain text @@ -831,12 +831,9 @@ function UncachedShikiCodeBlock({ `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, - transformers: [codeColorPreviewTransformer], - }); + // 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 b0e72d9c41f9..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); diff --git a/apps/web/src/lib/codeColorPreviews.test.ts b/apps/web/src/lib/codeColorPreviews.test.ts index 8b51a09ecdaa..cbc5dd51593b 100644 --- a/apps/web/src/lib/codeColorPreviews.test.ts +++ b/apps/web/src/lib/codeColorPreviews.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vite-plus/test"; -import { codeColorPreviewParts, codeColorPreviewTransformer } from "./codeColorPreviews"; +import { + codeColorPreviewParts, + codeColorPreviewTransformer, + codeColorPreviewTransformers, +} from "./codeColorPreviews"; import { resolveDiffThemeName } from "./diffRendering"; import { getSyntaxHighlighterPromise } from "./syntaxHighlighting"; @@ -30,6 +34,19 @@ describe("codeColorPreviewParts", () => { 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"}', { diff --git a/apps/web/src/lib/codeColorPreviews.ts b/apps/web/src/lib/codeColorPreviews.ts index a90de78f11db..1ff4e4af1b88 100644 --- a/apps/web/src/lib/codeColorPreviews.ts +++ b/apps/web/src/lib/codeColorPreviews.ts @@ -1,7 +1,38 @@ 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; + /(^|[^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", +]); interface CodeColorPreviewPart { readonly text: string; @@ -64,3 +95,7 @@ export const codeColorPreviewTransformer: ShikiTransformer = { }); }, }; + +export function codeColorPreviewTransformers(language: string): ShikiTransformer[] { + return CODE_COLOR_PREVIEW_LANGUAGES.has(language) ? [codeColorPreviewTransformer] : []; +} From c550a2800f29d761255a7890288b7ddb47af4296 Mon Sep 17 00:00:00 2001 From: msegec Date: Sun, 23 Aug 2026 16:24:38 +0800 Subject: [PATCH 3/3] fix(web): limit short hex swatches to style languages In script and data languages a 3- or 4-digit hex run is usually an issue reference in a comment or a JS private name, so those languages now only preview #rrggbb/#rrggbbaa. Style languages keep the short forms, where they are real colours. --- apps/web/src/lib/codeColorPreviews.test.ts | 23 +++-- apps/web/src/lib/codeColorPreviews.ts | 97 +++++++++++++--------- 2 files changed, 75 insertions(+), 45 deletions(-) diff --git a/apps/web/src/lib/codeColorPreviews.test.ts b/apps/web/src/lib/codeColorPreviews.test.ts index cbc5dd51593b..d71980f778e6 100644 --- a/apps/web/src/lib/codeColorPreviews.test.ts +++ b/apps/web/src/lib/codeColorPreviews.test.ts @@ -1,10 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { - codeColorPreviewParts, - codeColorPreviewTransformer, - codeColorPreviewTransformers, -} from "./codeColorPreviews"; +import { codeColorPreviewParts, codeColorPreviewTransformers } from "./codeColorPreviews"; import { resolveDiffThemeName } from "./diffRendering"; import { getSyntaxHighlighterPromise } from "./syntaxHighlighting"; @@ -39,9 +35,20 @@ describe("codeColorPreviewParts", () => { 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")).toEqual([codeColorPreviewTransformer]); - expect(codeColorPreviewTransformers("json")).toEqual([codeColorPreviewTransformer]); + 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([]); @@ -52,7 +59,7 @@ describe("codeColorPreviewParts", () => { const html = highlighter.codeToHtml('{"idle":"#701525"}', { lang: "json", theme: resolveDiffThemeName("dark"), - transformers: [codeColorPreviewTransformer], + transformers: codeColorPreviewTransformers("json"), }); expect(html).toContain('class="chat-markdown-color-literal"'); diff --git a/apps/web/src/lib/codeColorPreviews.ts b/apps/web/src/lib/codeColorPreviews.ts index 1ff4e4af1b88..936479fba081 100644 --- a/apps/web/src/lib/codeColorPreviews.ts +++ b/apps/web/src/lib/codeColorPreviews.ts @@ -2,10 +2,14 @@ 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; -/** 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([ +/** 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", @@ -16,6 +20,8 @@ const CODE_COLOR_PREVIEW_LANGUAGES = new Set([ "vue", "svelte", "astro", +]); +const LONG_HEX_LANGUAGES = new Set([ "json", "jsonc", "json5", @@ -39,11 +45,15 @@ interface CodeColorPreviewPart { readonly color?: string; } -export function codeColorPreviewParts(text: string): CodeColorPreviewPart[] { +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(CSS_HEX_COLOR_REGEX)) { + for (const match of text.matchAll(regex)) { const color = match[2]; if (!color || match.index == null) continue; @@ -61,41 +71,54 @@ export function codeColorPreviewParts(text: string): CodeColorPreviewPart[] { 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; +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); - if (!parts.some((part) => part.color != null)) 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}`, + 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: [], }, - 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[] { - return CODE_COLOR_PREVIEW_LANGUAGES.has(language) ? [codeColorPreviewTransformer] : []; + if (STYLE_LANGUAGES.has(language)) return [styleTransformer]; + if (LONG_HEX_LANGUAGES.has(language)) return [longHexTransformer]; + return []; }