Skip to content

Commit f4a3c54

Browse files
committed
feat(editor): render color swatches in inline code
Add a TipTap extension that auto-detects CSS color values inside inline code and renders a small colored square swatch to the left — similar to Chrome DevTools. Supports hex (#rgb, #rrggbb, #rrggbbaa), rgb(), rgba(), hsl(), hsla(), oklch(), oklab(), lch(), lab(), hwb(), color(), and all 148 CSS named colors. Uses a ProseMirror decoration plugin so the underlying markdown is unchanged — no special authoring syntax needed. Swatch has a checkerboard background pattern to reveal transparency in alpha colors, with dark mode border adaptation. https://claude.ai/code/session_01G7EiAdwAzdi7qzeYditnic
1 parent 3b820cf commit f4a3c54

7 files changed

Lines changed: 302 additions & 0 deletions

File tree

packages/ui/src/components/editor/CeptEditor.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { MathBlock, InlineMath } from './extensions/math.js';
2727
import { Mermaid } from './extensions/mermaid.js';
2828
import { SlashCommand, defaultSlashCommands, filterSlashCommands } from './extensions/slash-command.js';
2929
import type { SlashCommandItem } from './extensions/slash-command.js';
30+
import { CodeColorSwatch } from './extensions/code-color-swatch.js';
3031
import { SlashCommandMenu, type SlashCommandMenuRef } from './SlashCommandMenu.js';
3132
import { InlineToolbar } from './InlineToolbar.js';
3233
import tippy, { type Instance as TippyInstance } from 'tippy.js';
@@ -149,6 +150,7 @@ export function CeptEditor({
149150
},
150151
}),
151152
Mermaid,
153+
CodeColorSwatch,
152154
GlobalDragHandle.configure({
153155
dragHandleWidth: 24,
154156
scrollTreshold: 100,

packages/ui/src/components/editor/editor-styles.css

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,25 @@
162162
font-family: 'SFMono-Regular', 'Consolas', 'Liberation Mono', 'Menlo', monospace;
163163
}
164164

165+
/* Color swatch for inline code containing CSS color values */
166+
.cept-color-swatch {
167+
display: inline-block;
168+
width: 0.8em;
169+
height: 0.8em;
170+
border-radius: 2px;
171+
margin-right: 0.25em;
172+
vertical-align: -0.05em;
173+
border: 1px solid rgba(0, 0, 0, 0.15);
174+
/* Checkerboard pattern to reveal transparency in alpha colors */
175+
background-image:
176+
linear-gradient(45deg, #ccc 25%, transparent 25%),
177+
linear-gradient(-45deg, #ccc 25%, transparent 25%),
178+
linear-gradient(45deg, transparent 75%, #ccc 75%),
179+
linear-gradient(-45deg, transparent 75%, #ccc 75%);
180+
background-size: 6px 6px;
181+
background-position: 0 0, 0 3px, 3px -3px, -3px 0;
182+
}
183+
165184
/* Mentions */
166185
.cept-editor .tiptap .cept-mention {
167186
background-color: rgba(35, 131, 226, 0.1);
@@ -934,6 +953,7 @@
934953
@media (prefers-color-scheme: dark) {
935954
.cept-editor .tiptap p.is-editor-empty:first-child::before { color: #52525b; }
936955
.cept-editor .tiptap code { background-color: rgba(255, 255, 255, 0.1); }
956+
.cept-color-swatch { border-color: rgba(255, 255, 255, 0.2); }
937957
.cept-editor .tiptap hr, .cept-editor .tiptap hr.cept-divider { border-top-color: #404040; }
938958
.cept-editor .tiptap blockquote.cept-blockquote { border-left-color: #404040; color: #a1a1aa; }
939959
.cept-editor .tiptap pre.cept-code-block { background-color: #1e1e1e; }
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { CodeColorSwatch, colorSwatchPluginKey } from './code-color-swatch.js';
3+
4+
describe('CodeColorSwatch extension', () => {
5+
it('has correct name', () => {
6+
expect(CodeColorSwatch.name).toBe('codeColorSwatch');
7+
});
8+
9+
it('exports a plugin key', () => {
10+
expect(colorSwatchPluginKey).toBeDefined();
11+
});
12+
13+
it('defines ProseMirror plugins', () => {
14+
expect(CodeColorSwatch.config.addProseMirrorPlugins).toBeDefined();
15+
});
16+
17+
it('is an Extension (not a Node or Mark)', () => {
18+
expect(CodeColorSwatch.type).toBe('extension');
19+
});
20+
});
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/**
2+
* CodeColorSwatch — TipTap extension that renders a small color swatch
3+
* next to inline code elements whose text content is a valid CSS color value.
4+
*
5+
* Works like Chrome DevTools: if inline code text looks like a color
6+
* (hex, rgb, hsl, named colors, etc.), a tiny colored square appears
7+
* to the left of the code text.
8+
*
9+
* No special authoring syntax required — standard `#ff0000` inline code
10+
* gets a swatch automatically.
11+
*/
12+
13+
import { Extension } from '@tiptap/core';
14+
import { Plugin, PluginKey } from '@tiptap/pm/state';
15+
import { Decoration, DecorationSet } from '@tiptap/pm/view';
16+
import { detectCSSColor } from './color-utils.js';
17+
18+
export const colorSwatchPluginKey = new PluginKey('colorSwatch');
19+
20+
export const CodeColorSwatch = Extension.create({
21+
name: 'codeColorSwatch',
22+
23+
addProseMirrorPlugins() {
24+
return [
25+
new Plugin({
26+
key: colorSwatchPluginKey,
27+
state: {
28+
init(_, state) {
29+
return buildDecorations(state.doc);
30+
},
31+
apply(tr, oldDecorations) {
32+
if (!tr.docChanged) return oldDecorations;
33+
return buildDecorations(tr.doc);
34+
},
35+
},
36+
props: {
37+
decorations(state) {
38+
return this.getState(state) ?? DecorationSet.empty;
39+
},
40+
},
41+
}),
42+
];
43+
},
44+
});
45+
46+
function buildDecorations(doc: import('@tiptap/pm/model').Node): DecorationSet {
47+
const decorations: Decoration[] = [];
48+
49+
doc.descendants((node, pos) => {
50+
// We only care about text nodes that carry the "code" mark
51+
if (!node.isText) return;
52+
53+
const codeMark = node.marks.find((m) => m.type.name === 'code');
54+
if (!codeMark) return;
55+
56+
const text = node.text ?? '';
57+
const color = detectCSSColor(text);
58+
if (!color) return;
59+
60+
// Sanitise the color value for use in a style attribute.
61+
// Only allow characters valid in CSS color values.
62+
const safeColor = color.replace(/[^a-zA-Z0-9#(),.\s/%°\-+]/g, '');
63+
64+
// Create a widget decoration placed at the start of the code text.
65+
const widget = Decoration.widget(pos, () => {
66+
const swatch = document.createElement('span');
67+
swatch.className = 'cept-color-swatch';
68+
swatch.style.backgroundColor = safeColor;
69+
swatch.setAttribute('aria-hidden', 'true');
70+
swatch.contentEditable = 'false';
71+
return swatch;
72+
}, { side: -1, key: `swatch-${pos}-${safeColor}` });
73+
74+
decorations.push(widget);
75+
});
76+
77+
return DecorationSet.create(doc, decorations);
78+
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { detectCSSColor } from './color-utils.js';
3+
4+
describe('detectCSSColor', () => {
5+
describe('hex colors', () => {
6+
it('detects 3-digit hex', () => {
7+
expect(detectCSSColor('#fff')).toBe('#fff');
8+
expect(detectCSSColor('#ABC')).toBe('#ABC');
9+
});
10+
11+
it('detects 4-digit hex (with alpha)', () => {
12+
expect(detectCSSColor('#fffa')).toBe('#fffa');
13+
});
14+
15+
it('detects 6-digit hex', () => {
16+
expect(detectCSSColor('#ff0000')).toBe('#ff0000');
17+
expect(detectCSSColor('#EEAAFF')).toBe('#EEAAFF');
18+
});
19+
20+
it('detects 8-digit hex (with alpha)', () => {
21+
expect(detectCSSColor('#ff000080')).toBe('#ff000080');
22+
});
23+
24+
it('rejects invalid hex', () => {
25+
expect(detectCSSColor('#ff')).toBeNull();
26+
expect(detectCSSColor('#gggggg')).toBeNull();
27+
expect(detectCSSColor('#12345')).toBeNull();
28+
expect(detectCSSColor('#1234567890')).toBeNull();
29+
});
30+
});
31+
32+
describe('rgb/rgba', () => {
33+
it('detects rgb()', () => {
34+
expect(detectCSSColor('rgb(255, 0, 0)')).toBe('rgb(255, 0, 0)');
35+
});
36+
37+
it('detects rgba()', () => {
38+
expect(detectCSSColor('rgba(255, 0, 0, 0.5)')).toBe('rgba(255, 0, 0, 0.5)');
39+
});
40+
41+
it('detects modern rgb with slash alpha', () => {
42+
expect(detectCSSColor('rgb(255 0 0 / 50%)')).toBe('rgb(255 0 0 / 50%)');
43+
});
44+
});
45+
46+
describe('hsl/hsla', () => {
47+
it('detects hsl()', () => {
48+
expect(detectCSSColor('hsl(0, 100%, 50%)')).toBe('hsl(0, 100%, 50%)');
49+
});
50+
51+
it('detects hsla()', () => {
52+
expect(detectCSSColor('hsla(0, 100%, 50%, 0.5)')).toBe('hsla(0, 100%, 50%, 0.5)');
53+
});
54+
});
55+
56+
describe('modern color functions', () => {
57+
it('detects oklch()', () => {
58+
expect(detectCSSColor('oklch(0.7 0.15 180)')).toBe('oklch(0.7 0.15 180)');
59+
});
60+
61+
it('detects oklab()', () => {
62+
expect(detectCSSColor('oklab(0.7 -0.1 0.1)')).toBe('oklab(0.7 -0.1 0.1)');
63+
});
64+
});
65+
66+
describe('named colors', () => {
67+
it('detects common named colors', () => {
68+
expect(detectCSSColor('red')).toBe('red');
69+
expect(detectCSSColor('blue')).toBe('blue');
70+
expect(detectCSSColor('cornflowerblue')).toBe('cornflowerblue');
71+
expect(detectCSSColor('transparent')).toBe('transparent');
72+
});
73+
74+
it('is case-insensitive for named colors', () => {
75+
expect(detectCSSColor('Red')).toBe('Red');
76+
expect(detectCSSColor('BLUE')).toBe('BLUE');
77+
});
78+
79+
it('rejects non-color words', () => {
80+
expect(detectCSSColor('hello')).toBeNull();
81+
expect(detectCSSColor('function')).toBeNull();
82+
expect(detectCSSColor('const')).toBeNull();
83+
});
84+
});
85+
86+
describe('whitespace handling', () => {
87+
it('trims whitespace', () => {
88+
expect(detectCSSColor(' #fff ')).toBe('#fff');
89+
});
90+
91+
it('returns null for empty string', () => {
92+
expect(detectCSSColor('')).toBeNull();
93+
expect(detectCSSColor(' ')).toBeNull();
94+
});
95+
});
96+
97+
describe('non-color values', () => {
98+
it('rejects plain numbers', () => {
99+
expect(detectCSSColor('123')).toBeNull();
100+
});
101+
102+
it('rejects code snippets', () => {
103+
expect(detectCSSColor('console.log()')).toBeNull();
104+
expect(detectCSSColor('var x = 1')).toBeNull();
105+
});
106+
107+
it('rejects partial color-like strings', () => {
108+
expect(detectCSSColor('#')).toBeNull();
109+
expect(detectCSSColor('rgb')).toBeNull();
110+
expect(detectCSSColor('hsl(')).toBeNull();
111+
});
112+
});
113+
});
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/**
2+
* Color detection utilities for inline code swatch rendering.
3+
*
4+
* Detects CSS color values in text and normalises them to a format
5+
* suitable for use as a CSS `background-color` value.
6+
*/
7+
8+
// CSS named colors (Level 4) — lowercase for comparison
9+
const CSS_NAMED_COLORS = new Set([
10+
'aliceblue', 'antiquewhite', 'aqua', 'aquamarine', 'azure',
11+
'beige', 'bisque', 'black', 'blanchedalmond', 'blue',
12+
'blueviolet', 'brown', 'burlywood', 'cadetblue', 'chartreuse',
13+
'chocolate', 'coral', 'cornflowerblue', 'cornsilk', 'crimson',
14+
'cyan', 'darkblue', 'darkcyan', 'darkgoldenrod', 'darkgray',
15+
'darkgreen', 'darkgrey', 'darkkhaki', 'darkmagenta', 'darkolivegreen',
16+
'darkorange', 'darkorchid', 'darkred', 'darksalmon', 'darkseagreen',
17+
'darkslateblue', 'darkslategray', 'darkslategrey', 'darkturquoise',
18+
'darkviolet', 'deeppink', 'deepskyblue', 'dimgray', 'dimgrey',
19+
'dodgerblue', 'firebrick', 'floralwhite', 'forestgreen', 'fuchsia',
20+
'gainsboro', 'ghostwhite', 'gold', 'goldenrod', 'gray', 'green',
21+
'greenyellow', 'grey', 'honeydew', 'hotpink', 'indianred', 'indigo',
22+
'ivory', 'khaki', 'lavender', 'lavenderblush', 'lawngreen',
23+
'lemonchiffon', 'lightblue', 'lightcoral', 'lightcyan',
24+
'lightgoldenrodyellow', 'lightgray', 'lightgreen', 'lightgrey',
25+
'lightpink', 'lightsalmon', 'lightseagreen', 'lightskyblue',
26+
'lightslategray', 'lightslategrey', 'lightsteelblue', 'lightyellow',
27+
'lime', 'limegreen', 'linen', 'magenta', 'maroon', 'mediumaquamarine',
28+
'mediumblue', 'mediumorchid', 'mediumpurple', 'mediumseagreen',
29+
'mediumslateblue', 'mediumspringgreen', 'mediumturquoise',
30+
'mediumvioletred', 'midnightblue', 'mintcream', 'mistyrose',
31+
'moccasin', 'navajowhite', 'navy', 'oldlace', 'olive', 'olivedrab',
32+
'orange', 'orangered', 'orchid', 'palegoldenrod', 'palegreen',
33+
'paleturquoise', 'palevioletred', 'papayawhip', 'peachpuff', 'peru',
34+
'pink', 'plum', 'powderblue', 'purple', 'rebeccapurple', 'red',
35+
'rosybrown', 'royalblue', 'saddlebrown', 'salmon', 'sandybrown',
36+
'seagreen', 'seashell', 'sienna', 'silver', 'skyblue', 'slateblue',
37+
'slategray', 'slategrey', 'snow', 'springgreen', 'steelblue', 'tan',
38+
'teal', 'thistle', 'tomato', 'turquoise', 'violet', 'wheat', 'white',
39+
'whitesmoke', 'yellow', 'yellowgreen', 'transparent',
40+
]);
41+
42+
// Hex: #rgb, #rgba, #rrggbb, #rrggbbaa
43+
const HEX_RE = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
44+
45+
// Functional: rgb(), rgba(), hsl(), hsla(), oklch(), oklab(), lch(), lab(), color()
46+
// Deliberately permissive on inner content — we only need to know it *is* a color function.
47+
const FUNC_RE = /^(?:rgba?|hsla?|oklch|oklab|lch|lab|color|hwb)\(\s*[\d.,%/\s\-+a-z°]+\s*\)$/i;
48+
49+
/**
50+
* Detect whether `text` looks like a CSS color value.
51+
* Returns the normalised color string suitable for `background-color`, or `null`.
52+
*/
53+
export function detectCSSColor(text: string): string | null {
54+
const trimmed = text.trim();
55+
if (!trimmed) return null;
56+
57+
// Hex colors
58+
if (HEX_RE.test(trimmed)) return trimmed;
59+
60+
// Functional notation
61+
if (FUNC_RE.test(trimmed)) return trimmed;
62+
63+
// Named colors (case-insensitive)
64+
if (CSS_NAMED_COLORS.has(trimmed.toLowerCase())) return trimmed;
65+
66+
return null;
67+
}

packages/ui/src/components/editor/extensions/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,5 @@ export { Mermaid, defaultMermaidContent, mermaidExamples } from './mermaid.js';
1818
export type { MermaidOptions } from './mermaid.js';
1919
export { SlashCommand, defaultSlashCommands, filterSlashCommands, slashCommandPluginKey } from './slash-command.js';
2020
export type { SlashCommandItem, SlashCommandOptions } from './slash-command.js';
21+
export { CodeColorSwatch, colorSwatchPluginKey } from './code-color-swatch.js';
22+
export { detectCSSColor } from './color-utils.js';

0 commit comments

Comments
 (0)