Skip to content

Commit c737cc1

Browse files
frenchie4111claude
andauthored
feat(chat): color swatches next to hex literals (#165)
<img width="869" height="336" alt="image" src="https://github.com/user-attachments/assets/b3532c0f-eb58-4604-b0be-2b56b2388b4a" /> Just makes it a bit easier / more fun when dealing with colors ## Summary - Tiny rehype plugin appends an inline color-swatch chip after `#RGB` / `#RGBA` / `#RRGGBB` / `#RRGGBBAA` literals in chat markdown (both assistant text and thinking blocks). - Skips text inside `<pre>` so `rehype-highlight` syntax tokens stay intact, but inline backtick code like `` `#9400D3` `` — which is how Claude almost always formats colors — does get swatches. - Negative lookbehind keeps URL fragments (`/#section`), mid-word matches (`abc#fff`), and `##fff` from matching. - Swatch styling is em-based (tracks surrounding text size) with a thin `border-strong` outline so light colors stay visible against the panel. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e836785 commit c737cc1

2 files changed

Lines changed: 96 additions & 1 deletion

File tree

src/renderer/components/JsonModeChat.tsx

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,87 @@ import 'highlight.js/styles/github-dark.css'
4242
import type { JsonClaudeChatEntry } from '../../shared/state/json-claude'
4343

4444
const REMARK_PLUGINS = [remarkGfm]
45-
const REHYPE_PLUGINS = [rehypeHighlight]
45+
const REHYPE_PLUGINS = [rehypeHighlight, rehypeColorHex]
46+
47+
// Matches color literals we want to swatch:
48+
// - #RRGGBB and #RRGGBBAA hex. 3-hex (#fff) and 4-hex (#ffff) are
49+
// omitted on purpose — they collide with GitHub PR / issue refs
50+
// like #158, #1234 which dominate dev-chat false-positive volume.
51+
// - rgb() / rgba() CSS functions, modern and legacy syntax
52+
// (commas, spaces, slash-alpha, percentages). The character class
53+
// is loose-but-bounded — the browser's CSS parser does the real
54+
// validation when we set the inline background-color, and the
55+
// class excludes injection vectors (no quotes, semicolons, etc.).
56+
// Negative lookbehind on the hex branch avoids URL fragments
57+
// (`/#abc`) and double-hash; `\b` before `rgb` avoids `srgb(`.
58+
const COLOR_RE =
59+
/(?<![\w#/])#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6})\b|\brgba?\(\s*[\d.,%\s/]+\s*\)/gi
60+
61+
type HastNode = {
62+
type: string
63+
tagName?: string
64+
value?: string
65+
properties?: Record<string, unknown>
66+
children?: HastNode[]
67+
}
68+
69+
function rehypeColorHex() {
70+
return (tree: HastNode) => {
71+
walk(tree, false)
72+
}
73+
// Skip the <pre> subtree so rehype-highlight's syntax-highlighted
74+
// tokens stay intact. Inline <code> (outside of <pre>) is NOT skipped
75+
// — Claude almost always backticks color literals like `#FF00AA`.
76+
function walk(node: HastNode, inPre: boolean): void {
77+
if (!node.children) return
78+
const out: HastNode[] = []
79+
for (const child of node.children) {
80+
const childInPre = inPre || child.tagName === 'pre'
81+
if (child.type === 'text' && !inPre && typeof child.value === 'string') {
82+
const split = splitColorText(child.value)
83+
if (split) {
84+
out.push(...split)
85+
continue
86+
}
87+
} else if (child.children) {
88+
walk(child, childInPre)
89+
}
90+
out.push(child)
91+
}
92+
node.children = out
93+
}
94+
function splitColorText(text: string): HastNode[] | null {
95+
COLOR_RE.lastIndex = 0
96+
if (!COLOR_RE.test(text)) return null
97+
COLOR_RE.lastIndex = 0
98+
const parts: HastNode[] = []
99+
let last = 0
100+
let m: RegExpExecArray | null
101+
while ((m = COLOR_RE.exec(text))) {
102+
const color = m[0]
103+
if (m.index > last) {
104+
parts.push({ type: 'text', value: text.slice(last, m.index) })
105+
}
106+
parts.push({ type: 'text', value: color })
107+
parts.push({
108+
type: 'element',
109+
tagName: 'span',
110+
properties: {
111+
className: ['hex-color-swatch'],
112+
style: `background-color: ${color}`,
113+
title: color,
114+
ariaHidden: 'true'
115+
},
116+
children: []
117+
})
118+
last = m.index + color.length
119+
}
120+
if (last < text.length) {
121+
parts.push({ type: 'text', value: text.slice(last) })
122+
}
123+
return parts
124+
}
125+
}
46126

47127
// Worktree file list cache. Same TTL/shape as CommandPalette uses — the
48128
// list rarely changes during a typing session, and listAllFiles shells

src/renderer/styles.css

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -442,6 +442,21 @@
442442
margin-left: -1.4em;
443443
}
444444

445+
/* Tiny inline swatch the rehypeColorHex plugin inserts after a hex
446+
color literal (#fff, #FF00AA, etc.) in chat markdown. em-based so it
447+
tracks the surrounding text size; border keeps near-background
448+
colors visible against the panel. */
449+
.hex-color-swatch {
450+
display: inline-block;
451+
width: 0.85em;
452+
height: 0.85em;
453+
margin-left: 0.2em;
454+
vertical-align: -0.1em;
455+
border-radius: 0.2em;
456+
border: 1px solid var(--color-border-strong);
457+
box-sizing: border-box;
458+
}
459+
445460
/* Streaming-cursor for partial assistant entries — appears after the
446461
markdown body while a json-claude turn is still arriving via
447462
--include-partial-messages. Cleared by the consolidated assistant

0 commit comments

Comments
 (0)