From be303daf2b9f3d0f62571258766edddb19ba00ea Mon Sep 17 00:00:00 2001 From: sonali-dudhia Date: Mon, 26 Oct 2026 12:21:20 +0530 Subject: [PATCH 1/2] Show hex colors with a swatch, as the web does A hex color in a commit or a remote, like #ff8a4c or #fff, now draws after a square of that color, as on the web since dev-null-sh/dev-null.sh#1064. It uses the web's rule: 3, 4, 6 or 8 hex digits, where 3 or 4 digits need a letter or a leading zero so #123 stays an issue number, as a whole word, and never inside code. Co-Authored-By: Claude Opus 5.5 --- app/Devnull/Data/BodyRun.php | 15 +++- app/Devnull/Data/CommitBody.php | 87 ++++++++++++++++--- .../native/partials/commit-body.blade.php | 5 ++ .../native/partials/message-body.blade.php | 4 +- tests/Feature/Screens/CommitDetailTest.php | 18 ++++ tests/Unit/Devnull/CommitBodyTest.php | 74 ++++++++++++++++ 6 files changed, 189 insertions(+), 14 deletions(-) diff --git a/app/Devnull/Data/BodyRun.php b/app/Devnull/Data/BodyRun.php index ed713af..598c4bf 100644 --- a/app/Devnull/Data/BodyRun.php +++ b/app/Devnull/Data/BodyRun.php @@ -4,15 +4,24 @@ /** * A run of a paragraph: plain prose, a mention, inline code, a link, or a - * standalone `/dev/null`, which draws as the wordmark. A link's text is the - * URL as written. + * standalone `/dev/null`, which draws as the wordmark, or a hex color, which + * draws with a swatch of it. A link's text is the URL as written, and a + * color's is the color as written, `#` included. */ final readonly class BodyRun { - public function __construct(public string $text, public ?string $username = null, public bool $isCode = false, public bool $isWordmark = false, public ?BodyLink $link = null) {} + public function __construct(public string $text, public ?string $username = null, public bool $isCode = false, public bool $isWordmark = false, public ?BodyLink $link = null, public ?string $color = null) {} public function isMention(): bool { return $this->username !== null; } + + /** + * Whether this run is a hex color, whose digits are in $color. + */ + public function isColor(): bool + { + return $this->color !== null; + } } diff --git a/app/Devnull/Data/CommitBody.php b/app/Devnull/Data/CommitBody.php index 0559d6a..9d4b5a9 100644 --- a/app/Devnull/Data/CommitBody.php +++ b/app/Devnull/Data/CommitBody.php @@ -4,11 +4,11 @@ /** * Turns a commit's `body_segments` into blocks to draw, the way the web's - * CommitBody does: prose (with URLs and mentions linked, and `/dev/null` as - * the wordmark) and inline code flow together in paragraphs, and fenced code + * CommitBody does: prose (with URLs and mentions linked, hex colors beside a + * swatch, and `/dev/null` as the wordmark) and inline code flow together in paragraphs, and fenced code * blocks stand on their own. * - * Mention offsets count code points into the whole body, but segments aren't + * Mention and color offsets count code points into the whole body, but segments aren't * exact slices of it: inline code loses its backticks, a block its fences, and * prose the line break next to a block. So each segment is found in the body, * moving forward, and mentions are placed by where their segment starts. @@ -31,6 +31,15 @@ final class CommitBody private const string FencedBlock = '/\G`{3,}[^`\r\n]*\r?\n(?:([\s\S]*?)\r?\n)?`{3,}[ \t]*(?![^\r\n])/u'; + /** + * A hex color: 3, 4, 6 or 8 hex digits after a `#`. One of 3 or 4 digits + * also needs a letter or a leading zero, so `#123` stays an issue number + * while `#000` is black. It's a whole word: it starts a line, follows + * whitespace or a quoted line's `>`, and only punctuation may follow it + * before the next space. The web's rule, from colors.ts. + */ + private const string Color = '/(?:(?))[#\x{FF03}]([0-9a-f]{8}|[0-9a-f]{6}|(?=[0-9]*[a-f]|0)[0-9a-f]{3,4})(?=\p{P}*(?:[\s\x{00AD}\x{2060}\x{200A}-\x{200D}\x{20E2}]|\z))/iu'; + /** * A `/dev/null` that stands on its own, not part of a longer path, a word * or a name. The web's rule, from withWordmarks() in linkified-text.tsx. @@ -105,6 +114,7 @@ public static function blocks(string $body, ?array $segments, array $mentions = $blocks = []; $runs = []; $cursor = 0; + $colors = self::colors($body); foreach ($segments as $segment) { switch ($segment['type'] ?? null) { @@ -121,12 +131,12 @@ public static function blocks(string $body, ?array $segments, array $mentions = $start = $text === '' ? false : mb_strpos($body, $text, $cursor); if ($start === false) { - array_push($runs, ...self::withLinks($text.($cut ? self::Ellipsis : ''), null, [])); + array_push($runs, ...self::withLinks($text.($cut ? self::Ellipsis : ''), null, [], [])); break; } - array_push($runs, ...self::withLinks($text, $start, $mentions)); + array_push($runs, ...self::withLinks($text, $start, $mentions, $colors)); $cursor = $start + mb_strlen($text); if ($cut) { @@ -173,15 +183,16 @@ public static function blocks(string $body, ?array $segments, array $mentions = * Prose that wasn't found in the body has no start, so no mentions. * * @param list $mentions + * @param list $colors * @return list */ - private static function withLinks(string $text, ?int $start, array $mentions): array + private static function withLinks(string $text, ?int $start, array $mentions, array $colors): array { $runs = []; $position = 0; $prose = fn (string $part, int $at): array => $start === null ? self::withWordmarks($part) - : self::withMentions($part, $start + $at, $mentions); + : self::withMentions($part, $start + $at, $mentions, $colors); foreach (BodyLink::in($text) as [$offset, $length, $link]) { if ($offset > $position) { @@ -203,9 +214,10 @@ private static function withLinks(string $text, ?int $start, array $mentions): a * Splits prose that starts at $start in the body around the mentions inside it. * * @param list $mentions + * @param list $colors * @return list */ - private static function withMentions(string $text, int $start, array $mentions): array + private static function withMentions(string $text, int $start, array $mentions, array $colors): array { $runs = []; $position = 0; @@ -227,7 +239,7 @@ private static function withMentions(string $text, int $start, array $mentions): } if ($offset > $position) { - array_push($runs, ...self::withWordmarks(mb_substr($text, $position, $offset - $position))); + array_push($runs, ...self::withColors(mb_substr($text, $position, $offset - $position), $start + $position, $colors)); } $runs[] = new BodyRun($handle, username: $mention->username); @@ -235,12 +247,67 @@ private static function withMentions(string $text, int $start, array $mentions): } if ($position < $length) { - array_push($runs, ...self::withWordmarks(mb_substr($text, $position))); + array_push($runs, ...self::withColors(mb_substr($text, $position), $start + $position, $colors)); } return $runs; } + /** + * Splits prose that starts at $start in the body around the colors found + * in the whole body that fall inside it, as the web's withColors() does. + * + * @param list $colors + * @return list + */ + private static function withColors(string $text, int $start, array $colors): array + { + $runs = []; + $position = 0; + $length = mb_strlen($text); + + foreach ($colors as [$offset, $colorLength, $value]) { + $offset -= $start; + + if ($offset < $position || $offset + $colorLength > $length) { + continue; + } + + array_push($runs, ...self::withWordmarks(mb_substr($text, $position, $offset - $position))); + $runs[] = new BodyRun(mb_substr($text, $offset, $colorLength), color: $value); + $position = $offset + $colorLength; + } + + array_push($runs, ...self::withWordmarks(mb_substr($text, $position))); + + return $runs; + } + + /** + * The hex colors in the body's prose, in order, as where each starts and + * how long it is in code points, `#` included, and its digits. Code is + * blanked out first, so nothing in it counts, and the body's start is a + * line's start. + * + * @return list + */ + private static function colors(string $body): array + { + $prose = ''; + + foreach (self::parts($body) as $index => $part) { + $prose .= $index % 2 === 0 ? $part : str_repeat(' ', mb_strlen($part)); + } + + preg_match_all(self::Color, "\n".$prose, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE); + + return array_map(fn (array $match): array => [ + mb_strlen(substr("\n".$prose, 0, $match[0][1])) - 1, + mb_strlen($match[0][0]), + $match[1][0], + ], $matches); + } + /** * Splits prose around each standalone `/dev/null`, which becomes a * wordmark run. Like the web, the prose between two mentions is looked at diff --git a/resources/views/native/partials/commit-body.blade.php b/resources/views/native/partials/commit-body.blade.php index 7b628cf..6fa9f21 100644 --- a/resources/views/native/partials/commit-body.blade.php +++ b/resources/views/native/partials/commit-body.blade.php @@ -10,6 +10,9 @@ slash, even after a space, so a zero-width space (U+200B) after a space lets the line break there as a browser would; without it, a few wordmarks in a row can't wrap at all. VoiceOver skips both and still reads "/dev/null". + A hex color has a square of that color before it, as the web's swatch, held to it by a narrow no-break space so + they wrap together. + A link is Commit Blue and opens on a tap, numbered by its place among the body's links. Like the web, a URL shows shortened, and a dev/null commit or a GitHub repository, issue or pull request shows as a mono name with the owner muted, held on one line by a word joiner after its slash. The web's dev/null and GitHub marks before those are icons, @@ -41,6 +44,8 @@ ⁠ @elseif ($run->link) +@elseif ($run->isColor()) +  @elseif ($run->isWordmark) @php($afterSpace = $loop->index > 0 && ctype_space(substr($block->runs[$loop->index - 1]->text, -1))) @if ($afterSpace)​@endif/⁠dev⁠null diff --git a/resources/views/native/partials/message-body.blade.php b/resources/views/native/partials/message-body.blade.php index 293ae71..6657233 100644 --- a/resources/views/native/partials/message-body.blade.php +++ b/resources/views/native/partials/message-body.blade.php @@ -1,6 +1,6 @@ {{-- A message's body, drawn from its segments as a commit's is (see partials/commit-body): prose with tappable - mentions and inline code, and fenced code as mono text in a box. $ink is the bubble's text colour, so a body + mentions, inline code and hex colors beside their swatch, and fenced code as mono text in a box. $ink is the bubble's text colour, so a body on your lane reads in that lane's label colour. --}} @foreach ($message->content as $block) @@ -14,6 +14,8 @@ @elseif ($run->isCode) +@elseif ($run->isColor()) +  @else @endif diff --git a/tests/Feature/Screens/CommitDetailTest.php b/tests/Feature/Screens/CommitDetailTest.php index 2f0ee42..428072e 100644 --- a/tests/Feature/Screens/CommitDetailTest.php +++ b/tests/Feature/Screens/CommitDetailTest.php @@ -448,6 +448,24 @@ function fakeThread(array $commit = [], array $replies = [], array $extra = []): $screen->press("openProfile('ada')")->assertNavigatedTo('/log/users/ada'); }); +it('draws a hex color after a square of that color, in light and dark mode alike', function () { + $body = 'Brand orange is #ff8a4c, not #123'; + + fakeThread([ + 'body' => $body, + 'body_segments' => [['type' => 'text', 'text' => $body]], + ]); + + $screen = Native::visit('/log/commits/c2')->assertSee('#ff8a4c'); + + $swatches = findNodes($screen->tree(), fn (array $node): bool => ($node['props']['text'] ?? null) === '■'); + + expect($swatches)->toHaveCount(1) + ->and($swatches[0]['props']['color'] ?? null)->toBe('#FF8A4C') + // The paragraph's dark ink would otherwise draw the square in dark mode. + ->and($swatches[0]['props']['dark_color'] ?? null)->toBe('#FF8A4C'); +}); + it('shows the focused commit with its media and stars', function () { fakeThread([ 'author' => userJson(['id' => 'u3', 'username' => 'grace', 'name' => 'Grace']), diff --git a/tests/Unit/Devnull/CommitBodyTest.php b/tests/Unit/Devnull/CommitBodyTest.php index 135c372..edae384 100644 --- a/tests/Unit/Devnull/CommitBodyTest.php +++ b/tests/Unit/Devnull/CommitBodyTest.php @@ -299,3 +299,77 @@ function linkIn(array $blocks): array 'https://gist.github.com/laravel/framework', 'https://notgithub.com/laravel/framework', ]); + +/** + * The colors in a body that is all prose, as written and as their digits. + * + * @return list + */ +function colorsOf(string $body): array +{ + $colors = []; + + foreach (CommitBody::blocks($body, null) as $block) { + foreach ($block->runs as $run) { + if ($run->isColor()) { + $colors[] = [$run->text, (string) $run->color]; + } + } + } + + return $colors; +} + +it('shows hex colors with their swatch, as the web does', function () { + expect(colorsOf('Brand orange is #ff8a4c, white is #fff, and #ff8a4c80 or #fffa'))->toBe([ + ['#ff8a4c', 'ff8a4c'], + ['#fff', 'fff'], + ['#ff8a4c80', 'ff8a4c80'], + ['#fffa', 'fffa'], + ])->and(runsOf(CommitBody::blocks('Brand orange is #ff8a4c, ok', null)))->toBe([ + ['Brand orange is '], + ['#ff8a4c'], + [', ok'], + ]); +}); + +it('leaves digit-only issue numbers alone but counts digits with a leading zero or six of them', function () { + expect(colorsOf('Fixes #123 and #333, in #000 and #000000 and #333333'))->toBe([ + ['#000', '000'], + ['#000000', '000000'], + ['#333333', '333333'], + ]); +}); + +it('only counts a color that is a whole word', function () { + expect(colorsOf('#fff. and #fff-x and #ff8a4c.com and (#fff) and a#fff and #ff and #fffff'))->toBe([ + ['#fff', 'fff'], + ]); +}); + +it('counts a color at the start of a quoted line', function () { + expect(colorsOf(">#fff\n> #000"))->toBe([['#fff', 'fff'], ['#000', '000']]); +}); + +it('leaves colors in code as written, and places colors after code and mentions by where they are in the body', function () { + $body = 'Use `#fff` with @ada on #ff8a4c'; + + $blocks = CommitBody::blocks($body, [ + ['type' => 'text', 'text' => 'Use '], + ['type' => 'code', 'code' => '#fff'], + ['type' => 'text', 'text' => ' with @ada on #ff8a4c'], + ], [new Mention('ada', 16, 4)]); + + expect(array_map(fn (BodyRun $run): array => [$run->text, $run->isCode, $run->username, $run->color], $blocks[0]->runs))->toBe([ + ['Use ', false, null, null], + ['#fff', true, null, null], + [' with ', false, null, null], + ['@ada', false, 'ada', null], + [' on ', false, null, null], + ['#ff8a4c', false, null, 'ff8a4c'], + ]); +}); + +it('does not take a URL fragment for a color', function () { + expect(colorsOf('See https://example.com/#fff for #abc'))->toBe([['#abc', 'abc']]); +}); From 8d37974f74410d0a695bb08a970745cdd63fab40 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Mon, 26 Oct 2026 12:14:38 +0000 Subject: [PATCH 2/2] Keep hex colors when splitting bodies into quotes The merge from main brought in the quote splitter from #88, which rebuilds every prose run as plain text. It didn't know about color runs, so every swatch was lost. Color runs now pass through untouched, the same as mentions, links and wordmarks. Also puts back the docblock lines the conflict resolution clipped in CommitBody and the message body partial. Co-Authored-By: Claude Opus 5.5 --- app/Devnull/Data/CommitBody.php | 7 +++++-- resources/views/native/partials/message-body.blade.php | 6 ++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/app/Devnull/Data/CommitBody.php b/app/Devnull/Data/CommitBody.php index 1b8d527..26e8f62 100644 --- a/app/Devnull/Data/CommitBody.php +++ b/app/Devnull/Data/CommitBody.php @@ -4,7 +4,10 @@ /** * Turns a commit's `body_segments` into blocks to draw, the way the web's - * CommitBody does. + * CommitBody does: prose (with URLs and mentions linked, hex colors beside a + * swatch, and `/dev/null` as the wordmark) and inline code flow together in + * paragraphs, lines that start with `>` group into quotes, and fenced code + * blocks stand on their own. * * Mention and color offsets count code points into the whole body, but segments aren't * exact slices of it: inline code loses its backticks, a block its fences, and @@ -190,7 +193,7 @@ private static function paragraphs(array $runs): array $quoted = false; foreach ($runs as $run) { - $prose = ! $run->isMention() && ! $run->isCode && ! $run->isWordmark && $run->link === null; + $prose = ! $run->isMention() && ! $run->isCode && ! $run->isWordmark && $run->link === null && ! $run->isColor(); $lines = $prose ? array_map(fn (string $text): BodyRun => new BodyRun($text), preg_split('/(?<=\n)/', $run->text, flags: PREG_SPLIT_NO_EMPTY) ?: []) diff --git a/resources/views/native/partials/message-body.blade.php b/resources/views/native/partials/message-body.blade.php index 28e7b9c..35fbc49 100644 --- a/resources/views/native/partials/message-body.blade.php +++ b/resources/views/native/partials/message-body.blade.php @@ -1,7 +1,9 @@ {{-- A message's body, drawn from its segments as a commit's is (see partials/commit-body): prose with tappable - mentions, inline code, quotes, and hex colors beside their swatch, and fenced code as mono text in a box. $ink is the bubble's text colour, so a body - on your lane reads in that lane's label colour. + mentions and inline code, hex colors beside their swatch, quotes behind a rule on the left, and fenced code as + mono text in a box. $ink is the bubble's text colour, so a body on your lane reads in that lane's label colour, + and $quoteRule the quote's rule: amber, or the label colour on your lane, as the web's pale rule in your own + bubbles. --}} @foreach ($message->content as $block) @if ($block->isCode())