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, + ), + + , + ); + + 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, + ); + } +});