From 96a0b8de9566acfa3a819545df0d94a1be6b68c7 Mon Sep 17 00:00:00 2001
From: Chris Messina
Date: Fri, 23 Oct 2026 12:07:00 -0700
Subject: [PATCH 1/3] feat: show hex colors as swatches
A hex color written like a hashtag, such as #ff8a4c or #fff, shows with a
small swatch of the color. It needs at least one letter, so #123 stays an
issue reference, and colors in code are left alone.
Co-Authored-By: Claude Opus 5.5
---
KNOWLEDGE_BASE.md | 4 +-
resources/js/components/linkified-text.tsx | 85 ++++++++++++++++++++--
resources/js/lib/code.ts | 13 ++++
resources/js/lib/colors.ts | 29 ++++++++
resources/js/pages/log/show.tsx | 1 +
tests/Unit/Colors.test.ts | 59 +++++++++++++++
tests/Unit/LinkifiedText.test.tsx | 47 ++++++++++++
7 files changed, 232 insertions(+), 6 deletions(-)
create mode 100644 resources/js/lib/colors.ts
create mode 100644 tests/Unit/Colors.test.ts
diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md
index b08a46604..769567dd8 100644
--- a/KNOWLEDGE_BASE.md
+++ b/KNOWLEDGE_BASE.md
@@ -205,7 +205,9 @@ Typing **@** in a composer opens developer suggestions. Use ↑/↓ to move, Ent
Typing **#123** in a composer looks up that exact issue or pull request in the /dev/null repository, `dev-null-sh/dev-null.sh`. The matching title appears beneath the number; click it or use Enter or Tab to insert its GitHub URL. A space or punctuation after the number automatically inserts the match, without treating a pause midway through a longer number as completion. Missing numbers and failed lookups leave the text unchanged. Code, URL fragments, and references qualified with another repository are not expanded.
-Sources: [body renderer](resources/js/components/commit-body.tsx), [code blocks](resources/js/components/code-block.tsx), [mention suggestions](app/Http/Controllers/MentionSuggestionController.php), [mention parsing](app/Services/Mentions.php).
+A hex color such as `#ff8a4c` or `#fff` shows with a small swatch of the color, outside code. Colors written only with digits, such as `#000000`, stay plain text so they aren't confused with issue numbers.
+
+Sources: [body renderer](resources/js/components/commit-body.tsx), [colors](resources/js/lib/colors.ts), [code blocks](resources/js/components/code-block.tsx), [mention suggestions](app/Http/Controllers/MentionSuggestionController.php), [mention parsing](app/Services/Mentions.php).
### Draft previews
diff --git a/resources/js/components/linkified-text.tsx b/resources/js/components/linkified-text.tsx
index 5e8b8e01f..669a9ae03 100644
--- a/resources/js/components/linkified-text.tsx
+++ b/resources/js/components/linkified-text.tsx
@@ -5,7 +5,9 @@ import AppLogoIcon from '@/components/app-logo-icon';
import AppWordmark from '@/components/app-wordmark';
import { GitHubIcon } from '@/components/github-icon';
import { UserPreview } from '@/components/user-preview';
-import { typedProse } from '@/lib/code';
+import { typedProse, withoutCode } from '@/lib/code';
+import type { Color } from '@/lib/colors';
+import { colorsIn } from '@/lib/colors';
import { mentionPattern } from '@/lib/mentions';
import {
hrefFor,
@@ -255,7 +257,11 @@ function withPasteis(text: string, pasteisMode: boolean): ReactNode {
);
}
-function withWordmarks(text: string, pasteisMode: boolean): ReactNode[] {
+function withWordmarks(
+ text: string,
+ offset: number,
+ pasteisMode: boolean,
+): ReactNode[] {
return text
.split(
/(?
@@ -275,10 +281,60 @@ function withWordmarks(text: string, pasteisMode: boolean): ReactNode[] {
);
}
+/**
+ * The hex colors found in the whole text that fall in this slice of it, as their value with a swatch of the color.
+ */
+function withColors(
+ text: string,
+ offset: number,
+ colors: Color[],
+ pasteisMode: boolean,
+): ReactNode[] {
+ const parts: ReactNode[] = [];
+ let position = 0;
+
+ for (const color of colors) {
+ const index = color.index - offset;
+
+ if (index < position || index + color.length > text.length) {
+ continue;
+ }
+
+ parts.push(
+ ...withWordmarks(
+ text.slice(position, index),
+ offset + position,
+ pasteisMode,
+ ),
+
+
+ {text.slice(index, index + color.length)}
+ ,
+ );
+
+ position = index + color.length;
+ }
+
+ parts.push(
+ ...withWordmarks(text.slice(position), offset + position, pasteisMode),
+ );
+
+ return parts;
+}
+
function withMentions(
text: string,
offset: number,
mentions: Commit['mentions'],
+ colors: Color[],
pasteisMode: boolean,
isStatic: boolean,
): ReactNode[] {
@@ -293,7 +349,12 @@ function withMentions(
: null;
parts.push(
- ...withWordmarks(text.slice(position, match.index), pasteisMode),
+ ...withColors(
+ text.slice(position, match.index),
+ offset + position,
+ colors,
+ pasteisMode,
+ ),
isStatic || !userId ? (
match[0]
) : (
@@ -316,7 +377,14 @@ function withMentions(
position = match.index + match[0].length;
}
- parts.push(...withWordmarks(text.slice(position), pasteisMode));
+ parts.push(
+ ...withColors(
+ text.slice(position),
+ offset + position,
+ colors,
+ pasteisMode,
+ ),
+ );
return parts;
}
@@ -326,13 +394,18 @@ export function LinkifiedText({
mentions,
pasteisMode = false,
isStatic = false,
+ startsText = false,
}: {
text: string;
mentions: Commit['mentions'];
pasteisMode?: boolean;
isStatic?: boolean;
+ // Whether the text is a whole body, so its start is the start of a line.
+ startsText?: boolean;
}) {
const parts: ReactNode[] = [];
+ // Colors are found in the prose only, for callers that pass a whole body, code included.
+ const colors = colorsIn(withoutCode(text), startsText);
let position = 0;
for (const match of text.matchAll(urlPattern)) {
@@ -351,6 +424,7 @@ export function LinkifiedText({
text.slice(position, match.index),
position,
mentions,
+ colors,
pasteisMode,
isStatic,
),
@@ -396,6 +470,7 @@ export function LinkifiedText({
text.slice(position),
position,
mentions,
+ colors,
pasteisMode,
isStatic,
),
diff --git a/resources/js/lib/code.ts b/resources/js/lib/code.ts
index 742a22090..36d86f320 100644
--- a/resources/js/lib/code.ts
+++ b/resources/js/lib/code.ts
@@ -45,6 +45,19 @@ export function mapProse(
.join('');
}
+/**
+ * The text with its committed code blanked out to spaces of the same length, so what is found in the prose keeps its
+ * place in the text.
+ */
+export function withoutCode(text: string): string {
+ return text
+ .split(codePattern)
+ .map((part, index) =>
+ index % 2 === 0 ? part : ' '.repeat(part.length),
+ )
+ .join('');
+}
+
/**
* Applies the callback to the prose being typed only, leaving the code as written.
*/
diff --git a/resources/js/lib/colors.ts b/resources/js/lib/colors.ts
new file mode 100644
index 000000000..4be1b316d
--- /dev/null
+++ b/resources/js/lib/colors.ts
@@ -0,0 +1,29 @@
+// A hex color like "#ff8a4c" or "#fff", which the web shows next to a swatch of it. It has 3, 4, 6 or 8 hex digits and
+// at least one letter, so "#123" stays an issue reference. It is a whole word like a hashtag: it starts at the start,
+// after whitespace or after the ">" that starts a quoted line, and only punctuation may follow it before the next
+// space, so neither "#fff-x" nor "#ff8a4c.com" is a color.
+const colorPattern =
+ // oxlint-disable-next-line no-misleading-character-class
+ /(?:(?))[##](?=[0-9]*[a-f])([0-9a-f]{8}|[0-9a-f]{6}|[0-9a-f]{3,4})(?=\p{P}*(?:[\s -⃢]|$))/giu;
+
+export type Color = {
+ value: string;
+ index: number;
+ length: number;
+};
+
+/**
+ * The hex colors in a run of prose, where they are and how long they are with the "#". When the prose starts a whole
+ * body, its start is the start of a line, so a ">" there starts a quoted line.
+ */
+export function colorsIn(text: string, startsText = false): Color[] {
+ const start = startsText ? 1 : 0;
+
+ return [...(startsText ? `\n${text}` : text).matchAll(colorPattern)].map(
+ (match) => ({
+ value: match[1],
+ index: match.index - start,
+ length: match[0].length,
+ }),
+ );
+}
diff --git a/resources/js/pages/log/show.tsx b/resources/js/pages/log/show.tsx
index 393a4d866..ad1256cc8 100644
--- a/resources/js/pages/log/show.tsx
+++ b/resources/js/pages/log/show.tsx
@@ -283,6 +283,7 @@ function Suggestions({ suggestions }: { suggestions: Commit[] }) {
diff --git a/tests/Unit/Colors.test.ts b/tests/Unit/Colors.test.ts
new file mode 100644
index 000000000..1728bcb51
--- /dev/null
+++ b/tests/Unit/Colors.test.ts
@@ -0,0 +1,59 @@
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+import { colorsIn } from '../../resources/js/lib/colors.ts';
+
+const values = (text: string) => colorsIn(text).map((color) => color.value);
+
+await test('finds hex colors and where they are', () => {
+ assert.deepEqual(
+ colorsIn(
+ 'Brand orange is #ff8a4c, white is #fff, and #ff8a4c80 or #fffa',
+ ),
+ [
+ { value: 'ff8a4c', index: 16, length: 7 },
+ { value: 'fff', index: 34, length: 4 },
+ { value: 'ff8a4c80', index: 44, length: 9 },
+ { value: 'fffa', index: 57, length: 5 },
+ ],
+ );
+});
+
+await test('finds a hex color as a whole word, with punctuation after it left out', () => {
+ const cases: [string, string[]][] = [
+ ['#FFF', ['FFF']],
+ ['Is it #fff?!', ['fff']],
+ ['(#ff8a4c)', []],
+ ['Line\n>#fff', ['fff']],
+ ['#ff8a4c', ['ff8a4c']],
+ ['Tab\t#abc', ['abc']],
+ ];
+
+ for (const [text, expected] of cases) {
+ assert.deepEqual(values(text), expected, text);
+ }
+});
+
+await test('ignores what only looks like a hex color', () => {
+ for (const text of [
+ 'Issue #123',
+ 'Black is #000000',
+ '#fffff and #fffffff',
+ '#ff8a4g',
+ '#fff-x',
+ '#ff8a4c.com',
+ 'a#fff',
+ 'C#fff',
+ '##fff',
+ '>>#fff',
+ ]) {
+ assert.deepEqual(values(text), [], text);
+ }
+});
+
+await test('finds a hex color after the ">" that starts a whole body', () => {
+ assert.deepEqual(colorsIn('>#fff', true), [
+ { value: 'fff', index: 1, length: 4 },
+ ]);
+ assert.deepEqual(colorsIn('>#fff'), []);
+ assert.deepEqual(colorsIn('>>#fff', true), []);
+});
diff --git a/tests/Unit/LinkifiedText.test.tsx b/tests/Unit/LinkifiedText.test.tsx
index dd3fa8079..d507c8f19 100644
--- a/tests/Unit/LinkifiedText.test.tsx
+++ b/tests/Unit/LinkifiedText.test.tsx
@@ -509,3 +509,50 @@ for (const link of [
assert.ok(markup.includes(`href="${link}"`));
});
}
+
+test('shows a hex color with a swatch of it', () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+
+ assert.ok(html.includes('data-test="color"'));
+ assert.ok(html.includes('background-color:#ff8a4c'));
+ assert.equal(html.replace(/<[^>]*>/g, ''), 'Brand orange is #ff8a4c.');
+});
+
+test('shows no swatch for a color in code when given a whole body', () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+
+ assert.equal(html.match(/data-test="color"/g)?.length, 1);
+ assert.ok(html.includes('background-color:#ff8a4c'));
+});
+
+test('shows swatches next to links, mentions and wordmarks', () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+
+ assert.equal(html.match(/data-test="color"/g)?.length, 2);
+ assert.ok(html.includes('data-test="mention"'));
+ assert.ok(html.includes('href="https://example.com/#abc"'));
+});
+
+test('shows a swatch for a color that starts a quoted line at the start of a whole body', () => {
+ const colors = (text: string) =>
+ renderToStaticMarkup(
+ ,
+ ).match(/data-test="color"/g)?.length ?? 0;
+
+ assert.equal(colors('>#fff'), 1);
+ assert.equal(colors('line\n>#fff'), 1);
+ assert.equal(colors('>>#fff'), 0);
+ assert.equal(colors('`x`>#fff'), 0);
+});
From 36c2cc749d0113f20db1d3826295e00e2524a0e6 Mon Sep 17 00:00:00 2001
From: Chris Messina
Date: Fri, 23 Oct 2026 13:22:05 -0700
Subject: [PATCH 2/3] fix: show swatches for digit-only colors that can't be
issue numbers
#000 and #000000 are black, and #333333 is gray, but they showed as plain
text because a color needed a letter. Issue numbers never start with a
zero and aren't 6 or 8 digits long, so those count as colors now, while
#123 and #333 still stay plain. The pattern also spells its zero-width
characters as escapes instead of the invisible characters themselves.
Co-Authored-By: Claude Opus 5.5
---
KNOWLEDGE_BASE.md | 2 +-
resources/js/lib/colors.ts | 7 ++++---
tests/Unit/Colors.test.ts | 15 ++++++++++++++-
3 files changed, 19 insertions(+), 5 deletions(-)
diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md
index 769567dd8..21a31ee28 100644
--- a/KNOWLEDGE_BASE.md
+++ b/KNOWLEDGE_BASE.md
@@ -205,7 +205,7 @@ Typing **@** in a composer opens developer suggestions. Use ↑/↓ to move, Ent
Typing **#123** in a composer looks up that exact issue or pull request in the /dev/null repository, `dev-null-sh/dev-null.sh`. The matching title appears beneath the number; click it or use Enter or Tab to insert its GitHub URL. A space or punctuation after the number automatically inserts the match, without treating a pause midway through a longer number as completion. Missing numbers and failed lookups leave the text unchanged. Code, URL fragments, and references qualified with another repository are not expanded.
-A hex color such as `#ff8a4c` or `#fff` shows with a small swatch of the color, outside code. Colors written only with digits, such as `#000000`, stay plain text so they aren't confused with issue numbers.
+A hex color such as `#ff8a4c` or `#fff` shows with a small swatch of the color, outside code. A color written only with digits counts when it has 6 or 8 digits or starts with a zero, which issue numbers here don't: `#000`, `#000000` and `#333333` are colors, while `#123` and `#333` stay plain text.
Sources: [body renderer](resources/js/components/commit-body.tsx), [colors](resources/js/lib/colors.ts), [code blocks](resources/js/components/code-block.tsx), [mention suggestions](app/Http/Controllers/MentionSuggestionController.php), [mention parsing](app/Services/Mentions.php).
diff --git a/resources/js/lib/colors.ts b/resources/js/lib/colors.ts
index 4be1b316d..1ca300729 100644
--- a/resources/js/lib/colors.ts
+++ b/resources/js/lib/colors.ts
@@ -1,10 +1,11 @@
-// A hex color like "#ff8a4c" or "#fff", which the web shows next to a swatch of it. It has 3, 4, 6 or 8 hex digits and
-// at least one letter, so "#123" stays an issue reference. It is a whole word like a hashtag: it starts at the start,
+// A hex color like "#ff8a4c" or "#fff", which the web shows next to a swatch of it. It has 3, 4, 6 or 8 hex digits. One
+// of 3 or 4 digits also needs a letter or a leading zero, so "#123" stays an issue reference while "#000" is black: issue
+// numbers never start with a zero, and none are 6 or 8 digits long. It is a whole word like a hashtag: it starts at the start,
// after whitespace or after the ">" that starts a quoted line, and only punctuation may follow it before the next
// space, so neither "#fff-x" nor "#ff8a4c.com" is a color.
const colorPattern =
// oxlint-disable-next-line no-misleading-character-class
- /(?:(?))[##](?=[0-9]*[a-f])([0-9a-f]{8}|[0-9a-f]{6}|[0-9a-f]{3,4})(?=\p{P}*(?:[\s -⃢]|$))/giu;
+ /(?:(?))[#\uFF03]([0-9a-f]{8}|[0-9a-f]{6}|(?=[0-9]*[a-f]|0)[0-9a-f]{3,4})(?=\p{P}*(?:[\s\u00AD\u2060\u200A-\u200D\u20E2]|$))/giu;
export type Color = {
value: string;
diff --git a/tests/Unit/Colors.test.ts b/tests/Unit/Colors.test.ts
index 1728bcb51..a0bde69dd 100644
--- a/tests/Unit/Colors.test.ts
+++ b/tests/Unit/Colors.test.ts
@@ -36,7 +36,7 @@ await test('finds a hex color as a whole word, with punctuation after it left ou
await test('ignores what only looks like a hex color', () => {
for (const text of [
'Issue #123',
- 'Black is #000000',
+ 'Gray #333 or #9999',
'#fffff and #fffffff',
'#ff8a4g',
'#fff-x',
@@ -57,3 +57,16 @@ await test('finds a hex color after the ">" that starts a whole body', () => {
assert.deepEqual(colorsIn('>#fff'), []);
assert.deepEqual(colorsIn('>>#fff', true), []);
});
+
+await test('finds digit-only hex colors that cannot be issue numbers', () => {
+ assert.deepEqual(
+ values('#000, #0000, #000000, #00000000, #333333 and #99999999'),
+ ['000', '0000', '000000', '00000000', '333333', '99999999'],
+ );
+ assert.deepEqual(values('#00000 and #0000000'), []);
+});
+
+await test('ends a hex color at a zero-width character, as a hashtag ends', () => {
+ assert.deepEqual(values('#fff\u200Bnext and #abc\u2060'), ['fff', 'abc']);
+ assert.deepEqual(values('#fff\u200Cx'), ['fff']);
+});
From 4f80e516264468a5b37048b7e8c0d51f9d61a56f Mon Sep 17 00:00:00 2001
From: Chris Messina
Date: Fri, 23 Oct 2026 19:36:57 -0700
Subject: [PATCH 3/3] fix: don't look up digit-only colors as issues
The composer offered "#333333" and "#99999999" as issue references and
looked them up, although they render as colors. Skip a reference that the
color rule recognizes, with code blanked out as when rendering.
Co-Authored-By: Claude Opus 5.5
---
resources/js/lib/issue-reference.ts | 14 ++++++++++++--
tests/Unit/IssueReference.test.tsx | 25 +++++++++++++++++++++++++
2 files changed, 37 insertions(+), 2 deletions(-)
diff --git a/resources/js/lib/issue-reference.ts b/resources/js/lib/issue-reference.ts
index a6106a6d8..2a3a604be 100644
--- a/resources/js/lib/issue-reference.ts
+++ b/resources/js/lib/issue-reference.ts
@@ -1,4 +1,5 @@
-import { typedProse } from '@/lib/code';
+import { typedProse, withoutCode } from '@/lib/code';
+import { colorsIn } from '@/lib/colors';
export function issueReferenceBeforeCaret(text: string, caret: number) {
const prose = typedProse(text.slice(0, caret)).at(-1) ?? '';
@@ -12,8 +13,17 @@ export function issueReferenceBeforeCaret(text: string, caret: number) {
return null;
}
+ const start = caret - match[1].length - 1;
+
+ // A color like "#333333" is all digits too, but it is never an issue.
+ if (
+ colorsIn(withoutCode(text), true).some((color) => color.index === start)
+ ) {
+ return null;
+ }
+
return {
- start: caret - match[1].length - 1,
+ start,
query: match[1],
};
}
diff --git a/tests/Unit/IssueReference.test.tsx b/tests/Unit/IssueReference.test.tsx
index 91acdcd38..f55821822 100644
--- a/tests/Unit/IssueReference.test.tsx
+++ b/tests/Unit/IssueReference.test.tsx
@@ -61,3 +61,28 @@ test('preserves offsets after code and non-ASCII text, and chooses the latest re
query: '456',
});
});
+
+test('does not look up digit-only colors as issues', () => {
+ for (const [text, caret] of [
+ ['#333333', 7],
+ ['See #99999999', 13],
+ ['Gray is #333333.', 15],
+ ['#333333`code`', 7],
+ ['#99999999`a b` done', 9],
+ ] as const) {
+ assert.equal(issueReferenceBeforeCaret(text, caret), null, text);
+ }
+
+ for (const [text, caret, query] of [
+ ['See #123', 8, '123'],
+ ['See (#333333', 12, '333333'],
+ ['#333333.com', 7, '333333'],
+ ['#333333`code', 7, '333333'],
+ ] as const) {
+ assert.equal(
+ issueReferenceBeforeCaret(text, caret)?.query,
+ query,
+ text,
+ );
+ }
+});