From 9463b8a5ad5a03619c0ee383915d8afc769f9b4d Mon Sep 17 00:00:00 2001 From: sulimanbenhalim Date: Tue, 27 Oct 2026 01:06:42 +0200 Subject: [PATCH 1/3] feat: show CSS color functions as swatches --- KNOWLEDGE_BASE.md | 2 +- resources/js/components/linkified-text.tsx | 6 +-- resources/js/lib/colors.ts | 39 ++++++++++++++++--- tests/Unit/Colors.test.ts | 45 +++++++++++++++++++++- 4 files changed, 81 insertions(+), 11 deletions(-) diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index 2bfc962a5..b3be35690 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -210,7 +210,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, just like choosing an emoji suggestion. A space or punctuation leaves the number as typed and dismisses the suggestion. 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. 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. +A hex color such as `#ff8a4c` or `#fff`, or a CSS color function such as `rgb(255 138 76)`, `hsl(20 100% 65%)` or `oklch(0.75 0.15 50)`, shows with a small swatch of the color, outside code. A color function needs three numbers, separated by spaces with an optional `/ alpha` or by commas with an optional fourth. 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/components/linkified-text.tsx b/resources/js/components/linkified-text.tsx index 483164a1a..f3c874c97 100644 --- a/resources/js/components/linkified-text.tsx +++ b/resources/js/components/linkified-text.tsx @@ -11,7 +11,7 @@ import { GitLabIcon } from '@/components/gitlab-icon'; import { UserPreview } from '@/components/user-preview'; import { typedProse, withoutCode } from '@/lib/code'; import type { Color } from '@/lib/colors'; -import { colorsIn } from '@/lib/colors'; +import { colorsIn, cssColor } from '@/lib/colors'; import { mentionPattern } from '@/lib/mentions'; import { hrefFor, @@ -622,7 +622,7 @@ function withWordmarks( } /** - * The hex colors found in the whole text that fall in this slice of it, as their value with a swatch of the color. + * The 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, @@ -654,7 +654,7 @@ function withColors( , diff --git a/resources/js/lib/colors.ts b/resources/js/lib/colors.ts index 1ca300729..2c818c576 100644 --- a/resources/js/lib/colors.ts +++ b/resources/js/lib/colors.ts @@ -7,6 +7,20 @@ const colorPattern = // oxlint-disable-next-line no-misleading-character-class /(?:(?))[#\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; +// A channel of a color function, like "255", "0.75", "65%" or "50deg". +const channel = String.raw`[+-]?(?:\d+(?:\.\d+)?|\.\d+)(?:%|deg)?`; + +// A CSS color function like "rgb(255 138 76)", "hsl(20 100% 65%)" or "oklch(0.75 0.15 50 / 50%)", a whole word like a hex +// color. Its three channels are on one line, separated by spaces with an optional "/ alpha" or by commas with an optional +// fourth, so "rgb(1, 2 3)" and "hsl(1 2 3 4)" stay text. +const colorFunctionPattern = new RegExp( + String.raw`(?:(?))(?:rgba?|hsla?|(?:ok)?lab|(?:ok)?lch)\([ \t]*(?:${channel}[ \t]+${channel}[ \t]+${channel}(?:[ \t]*/[ \t]*${channel})?|${channel}[ \t]*,[ \t]*${channel}[ \t]*,[ \t]*${channel}(?:[ \t]*,[ \t]*${channel})?)[ \t]*\)(?=\p{P}*(?:[\s\u00AD\u2060\u200A-\u200D\u20E2]|$))`, + 'giu', +); + +/** + * A hex color's digits, without the "#", or a whole color function, as written. + */ export type Color = { value: string; index: number; @@ -14,17 +28,30 @@ export type Color = { }; /** - * 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. + * The hex colors and color functions in a run of prose, in order, where they are and how long they are as written. 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; + const prose = startsText ? `\n${text}` : text; - return [...(startsText ? `\n${text}` : text).matchAll(colorPattern)].map( - (match) => ({ + return [ + ...[...prose.matchAll(colorPattern)].map((match) => ({ value: match[1], index: match.index - start, length: match[0].length, - }), - ); + })), + ...[...prose.matchAll(colorFunctionPattern)].map((match) => ({ + value: match[0], + index: match.index - start, + length: match[0].length, + })), + ].sort((a, b) => a.index - b.index); +} + +/** + * What a color's swatch is painted with: a hex color's digits with their "#", or a color function as written. + */ +export function cssColor(color: Color): string { + return color.value.endsWith(')') ? color.value : `#${color.value}`; } diff --git a/tests/Unit/Colors.test.ts b/tests/Unit/Colors.test.ts index a0bde69dd..676be5bad 100644 --- a/tests/Unit/Colors.test.ts +++ b/tests/Unit/Colors.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { colorsIn } from '../../resources/js/lib/colors.ts'; +import { colorsIn, cssColor } from '../../resources/js/lib/colors.ts'; const values = (text: string) => colorsIn(text).map((color) => color.value); @@ -70,3 +70,46 @@ 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']); }); + +await test('finds CSS color functions as whole words, in order with hex colors', () => { + assert.deepEqual( + values( + 'Use rgb(255 138 76), #fff, rgba(255, 138, 76, 0.5) or HSL(20 100% 65% / 50%)!\n>oklch(0.75 0.15 50deg) oklab(.7 -.1 .1) lab(52% 40 59) lch(52% 72 56)', + ), + [ + 'rgb(255 138 76)', + 'fff', + 'rgba(255, 138, 76, 0.5)', + 'HSL(20 100% 65% / 50%)', + 'oklch(0.75 0.15 50deg)', + 'oklab(.7 -.1 .1)', + 'lab(52% 40 59)', + 'lch(52% 72 56)', + ], + ); +}); + +await test('ignores what only looks like a CSS color function', () => { + for (const text of [ + 'rgb()', + 'rgb(1 2)', + 'rgb(a b c)', + 'rgb(1, 2 3)', + 'hsl(1 2 3 4)', + 'rgb(1 2 3)px', + 'xrgb(1 2 3)', + '(rgb(1 2 3))', + 'color(srgb 1 0 0)', + 'rgb(1\n2 3)', + 'rgba(\n0, 0, 0, 0.5\n)', + ]) { + assert.deepEqual(values(text), [], text); + } +}); + +await test('paints a swatch with the color as written', () => { + assert.deepEqual( + colorsIn('#ff8a4c and oklch(0.75 0.15 50)').map(cssColor), + ['#ff8a4c', 'oklch(0.75 0.15 50)'], + ); +}); From 1cc3edd6228698e11deb8e9abdee8b87ff6ccb53 Mon Sep 17 00:00:00 2001 From: sulimanbenhalim Date: Tue, 27 Oct 2026 02:25:37 +0200 Subject: [PATCH 2/3] fix: only accept commas in rgb() and hsl() color swatches like CSS does --- KNOWLEDGE_BASE.md | 2 +- resources/js/lib/colors.ts | 6 +++--- tests/Unit/Colors.test.ts | 2 ++ 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index b3be35690..ff56e9f17 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -210,7 +210,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, just like choosing an emoji suggestion. A space or punctuation leaves the number as typed and dismisses the suggestion. 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`, or a CSS color function such as `rgb(255 138 76)`, `hsl(20 100% 65%)` or `oklch(0.75 0.15 50)`, shows with a small swatch of the color, outside code. A color function needs three numbers, separated by spaces with an optional `/ alpha` or by commas with an optional fourth. 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. +A hex color such as `#ff8a4c` or `#fff`, or a CSS color function such as `rgb(255 138 76)`, `hsl(20 100% 65%)` or `oklch(0.75 0.15 50)`, shows with a small swatch of the color, outside code. A color function needs three numbers, separated by spaces with an optional `/ alpha`, or in `rgb()` and `hsl()` also by commas with an optional fourth. 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 2c818c576..96c7ad3dd 100644 --- a/resources/js/lib/colors.ts +++ b/resources/js/lib/colors.ts @@ -11,10 +11,10 @@ const colorPattern = const channel = String.raw`[+-]?(?:\d+(?:\.\d+)?|\.\d+)(?:%|deg)?`; // A CSS color function like "rgb(255 138 76)", "hsl(20 100% 65%)" or "oklch(0.75 0.15 50 / 50%)", a whole word like a hex -// color. Its three channels are on one line, separated by spaces with an optional "/ alpha" or by commas with an optional -// fourth, so "rgb(1, 2 3)" and "hsl(1 2 3 4)" stay text. +// color. Its three channels are on one line, separated by spaces with an optional "/ alpha" or, in rgb() and hsl() only, by +// commas with an optional fourth, so "rgb(1, 2 3)", "lab(1, 2, 3)" and "hsl(1 2 3 4)" stay text. const colorFunctionPattern = new RegExp( - String.raw`(?:(?))(?:rgba?|hsla?|(?:ok)?lab|(?:ok)?lch)\([ \t]*(?:${channel}[ \t]+${channel}[ \t]+${channel}(?:[ \t]*/[ \t]*${channel})?|${channel}[ \t]*,[ \t]*${channel}[ \t]*,[ \t]*${channel}(?:[ \t]*,[ \t]*${channel})?)[ \t]*\)(?=\p{P}*(?:[\s\u00AD\u2060\u200A-\u200D\u20E2]|$))`, + String.raw`(?:(?))(?:(?:rgba?|hsla?|(?:ok)?lab|(?:ok)?lch)\([ \t]*${channel}[ \t]+${channel}[ \t]+${channel}(?:[ \t]*/[ \t]*${channel})?|(?:rgba?|hsla?)\([ \t]*${channel}[ \t]*,[ \t]*${channel}[ \t]*,[ \t]*${channel}(?:[ \t]*,[ \t]*${channel})?)[ \t]*\)(?=\p{P}*(?:[\s\u00AD\u2060\u200A-\u200D\u20E2]|$))`, 'giu', ); diff --git a/tests/Unit/Colors.test.ts b/tests/Unit/Colors.test.ts index 676be5bad..d4af2854b 100644 --- a/tests/Unit/Colors.test.ts +++ b/tests/Unit/Colors.test.ts @@ -95,6 +95,8 @@ await test('ignores what only looks like a CSS color function', () => { 'rgb(1 2)', 'rgb(a b c)', 'rgb(1, 2 3)', + 'lab(52%, 40, 59)', + 'oklch(0.75, 0.15, 50)', 'hsl(1 2 3 4)', 'rgb(1 2 3)px', 'xrgb(1 2 3)', From 88c357ee0c1cbb155ad09cb5a2f1e90c8459254f Mon Sep 17 00:00:00 2001 From: sulimanbenhalim Date: Tue, 27 Oct 2026 02:32:07 +0200 Subject: [PATCH 3/3] test: pin the comma form of hsl() color swatches --- tests/Unit/Colors.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/Unit/Colors.test.ts b/tests/Unit/Colors.test.ts index d4af2854b..49aec5e35 100644 --- a/tests/Unit/Colors.test.ts +++ b/tests/Unit/Colors.test.ts @@ -74,12 +74,13 @@ await test('ends a hex color at a zero-width character, as a hashtag ends', () = await test('finds CSS color functions as whole words, in order with hex colors', () => { assert.deepEqual( values( - 'Use rgb(255 138 76), #fff, rgba(255, 138, 76, 0.5) or HSL(20 100% 65% / 50%)!\n>oklch(0.75 0.15 50deg) oklab(.7 -.1 .1) lab(52% 40 59) lch(52% 72 56)', + 'Use rgb(255 138 76), #fff, rgba(255, 138, 76, 0.5), hsl(20, 100%, 65%) or HSL(20 100% 65% / 50%)!\n>oklch(0.75 0.15 50deg) oklab(.7 -.1 .1) lab(52% 40 59) lch(52% 72 56)', ), [ 'rgb(255 138 76)', 'fff', 'rgba(255, 138, 76, 0.5)', + 'hsl(20, 100%, 65%)', 'HSL(20 100% 65% / 50%)', 'oklch(0.75 0.15 50deg)', 'oklab(.7 -.1 .1)',