From 4f1fefab51b984dd74e917e15fef1110df8af373 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Mon, 19 Oct 2026 19:30:20 +0100 Subject: [PATCH] Draw /dev/null as the wordmark in commits and replies A standalone /dev/null in a commit or reply now draws as the brand wordmark, as the web has since nunomaduro/dev-null.sh@416a1ca3. CommitBody splits prose with the web's rule, so /dev/null inside a longer path, a word, a name or code stays plain text. EDGE text can't stroke a glyph, so the middle slash is solid lane-0 in the regular weight rather than the web's outline. Word joiners keep the wordmark on one line, and a zero-width space after a space lets iOS break before it the way a browser does. Co-Authored-By: Claude Opus 5.5 --- app/Devnull/Data/BodyRun.php | 5 +- app/Devnull/Data/CommitBody.php | 37 +++++++++++-- .../native/partials/commit-body.blade.php | 10 ++++ tests/Feature/Screens/LogTest.php | 18 +++++++ tests/Pest.php | 5 +- tests/Unit/Devnull/CommitBodyTest.php | 53 +++++++++++++++++++ 6 files changed, 120 insertions(+), 8 deletions(-) diff --git a/app/Devnull/Data/BodyRun.php b/app/Devnull/Data/BodyRun.php index be245be..5200661 100644 --- a/app/Devnull/Data/BodyRun.php +++ b/app/Devnull/Data/BodyRun.php @@ -3,11 +3,12 @@ namespace App\Devnull\Data; /** - * A run of a paragraph: plain prose, a mention, or inline code. + * A run of a paragraph: plain prose, a mention, inline code, or a standalone + * `/dev/null`, which draws as the wordmark. */ final readonly class BodyRun { - public function __construct(public string $text, public ?string $username = null, public bool $isCode = false) {} + public function __construct(public string $text, public ?string $username = null, public bool $isCode = false, public bool $isWordmark = false) {} public function isMention(): bool { diff --git a/app/Devnull/Data/CommitBody.php b/app/Devnull/Data/CommitBody.php index 3e8bb7a..d52e061 100644 --- a/app/Devnull/Data/CommitBody.php +++ b/app/Devnull/Data/CommitBody.php @@ -4,8 +4,9 @@ /** * Turns a commit's `body_segments` into blocks to draw, the way the web's - * CommitBody does: prose (with mentions linked) and inline code flow together - * in paragraphs, and fenced code blocks stand on their own. + * CommitBody does: prose (with mentions linked, 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 * exact slices of it: inline code loses its backticks, a block its fences, and @@ -25,6 +26,12 @@ final class CommitBody private const string FencedBlock = '/\G`{3,}[^`\r\n]*\r?\n(?:([\s\S]*?)\r?\n)?`{3,}[ \t]*(?![^\r\n])/u'; + /** + * 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. + */ + private const string Wordmark = '/(?>|null $segments The `body_segments`, or null when the API sent none. * @param list $mentions @@ -53,7 +60,7 @@ public static function blocks(string $body, ?array $segments, array $mentions = $start = $text === '' ? false : mb_strpos($body, $text, $cursor); if ($start === false) { - $runs[] = new BodyRun($text.($cut ? self::Ellipsis : '')); + array_push($runs, ...self::withWordmarks($text.($cut ? self::Ellipsis : ''))); break; } @@ -127,7 +134,7 @@ private static function withMentions(string $text, int $start, array $mentions): } if ($offset > $position) { - $runs[] = new BodyRun(mb_substr($text, $position, $offset - $position)); + array_push($runs, ...self::withWordmarks(mb_substr($text, $position, $offset - $position))); } $runs[] = new BodyRun($handle, username: $mention->username); @@ -135,7 +142,27 @@ private static function withMentions(string $text, int $start, array $mentions): } if ($position < $length) { - $runs[] = new BodyRun(mb_substr($text, $position)); + array_push($runs, ...self::withWordmarks(mb_substr($text, $position))); + } + + return $runs; + } + + /** + * Splits prose around each standalone `/dev/null`, which becomes a + * wordmark run. Like the web, the prose between two mentions is looked at + * on its own. + * + * @return list + */ + private static function withWordmarks(string $prose): array + { + $runs = []; + + foreach (preg_split(self::Wordmark, $prose, flags: PREG_SPLIT_DELIM_CAPTURE) ?: [$prose] as $index => $part) { + if ($part !== '') { + $runs[] = new BodyRun($part, isWordmark: $index % 2 === 1); + } } return $runs; diff --git a/resources/views/native/partials/commit-body.blade.php b/resources/views/native/partials/commit-body.blade.php index 229c603..144ef15 100644 --- a/resources/views/native/partials/commit-body.blade.php +++ b/resources/views/native/partials/commit-body.blade.php @@ -4,6 +4,13 @@ The runs inside a paragraph sit flush left with no blank lines between them on purpose: under whitespace-pre-wrap any whitespace between those tags would print. Each line ends in a tag or a directive, whose newline PHP drops. + A standalone /dev/null is the wordmark, in semibold with tighter tracking like the web's. The web outlines its + middle slash in lane-0; EDGE text runs can't stroke a glyph, so that slash is solid lane-0 instead, in the regular + weight, which is nearer the outline's thin lines than a solid semibold slash or a faded one. iOS breaks lines + after a slash, so a word joiner (U+2060) after each one keeps the wordmark on one line. It never breaks before a + 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 body too long to show whole in a list shows its start, as devnull cut it, and "Show more" in Commit Blue, which opens the rest where it is. The commit a page is about ($focused) is always whole. $muted sets it in muted ink, for the commits around the one a page is about. @@ -25,6 +32,9 @@ @elseif ($run->isCode) +@elseif ($run->isWordmark) +@php($afterSpace = $loop->index > 0 && ctype_space(substr($block->runs[$loop->index - 1]->text, -1))) +@if ($afterSpace)​@endif/⁠dev⁠null @else @endif diff --git a/tests/Feature/Screens/LogTest.php b/tests/Feature/Screens/LogTest.php index 51e78c4..3de9bb7 100644 --- a/tests/Feature/Screens/LogTest.php +++ b/tests/Feature/Screens/LogTest.php @@ -205,6 +205,24 @@ ]); }); +it('draws a standalone /dev/null as the wordmark: semibold, tight, its middle slash regular in lane-0, held on one line', function () { + Http::fake(['devnull.test/*' => Http::response(pageJson([commitJson(['body' => 'Piped to /dev/null, not /dev/nullable (/dev/null)'])]))]); + + $screen = Native::visit('/log'); + $wordmarks = findNodes($screen->tree(), fn (array $node): bool => ($node['props']['letter_spacing'] ?? null) === -0.025); + $slash = $wordmarks[0]['children'][1]['props']; + + expect(paragraphRuns($screen, 'Piped to'))->toBe([['Piped to '], ['/dev/null', 'wordmark'], [', not /dev/nullable ('], ['/dev/null', 'wordmark'], [')']]) + ->and(array_map(fn (array $wordmark): array => array_map(fn (array $run): string => $run['props']['text'], $wordmark['children']), $wordmarks))->toBe([ + ["\u{200B}/\u{2060}dev", '/', "\u{2060}null"], + ["/\u{2060}dev", '/', "\u{2060}null"], + ]) + ->and($wordmarks[0]['props'])->toMatchArray(['font_name' => 'semibold', 'font_weight' => 5]) + ->and($slash)->toMatchArray(['font_name' => 'default', 'font_weight' => 3]) + ->and(strtolower($slash['color']))->toBe(strtolower(config('native-ui.theme.light.lane-0'))) + ->and(strtolower($slash['dark_color']))->toBe(strtolower(config('native-ui.theme.dark.lane-0'))); +}); + it('shows a link preview as a compact row and opens it in the browser', function () { Http::fake(['devnull.test/*' => Http::response(pageJson([commitJson([ 'id' => 'c1', diff --git a/tests/Pest.php b/tests/Pest.php index c18b1d2..3593d10 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -618,7 +618,9 @@ function pageJson(array $items, ?string $nextCursor = null, string $path = 'http /** * The runs of the first paragraph whose text contains $text, as [text, style]: - * 'mention' for a pressable run, 'code' for one on a wash, or nothing for prose. + * 'mention' for a pressable run, 'code' for one on a wash, 'wordmark' for one + * drawn from runs of its own (read without its invisible line-break hints), or + * nothing for prose. * * @return list */ @@ -646,6 +648,7 @@ function paragraphRuns(TestableComponent $screen, string $text): array return array_map(fn (array $run): array => match (true) { isset($run['on_press']) => [$run['props']['text'], 'mention'], isset($run['style']['bg_color']) => [$run['props']['text'], 'code'], + isset($run['children']) => [str_replace(["\u{200B}", "\u{2060}"], '', implode('', array_map(fn (array $part): string => $part['props']['text'], $run['children']))), 'wordmark'], default => [$run['props']['text']], }, $paragraph['children']); } diff --git a/tests/Unit/Devnull/CommitBodyTest.php b/tests/Unit/Devnull/CommitBodyTest.php index d41dd78..fa56a80 100644 --- a/tests/Unit/Devnull/CommitBodyTest.php +++ b/tests/Unit/Devnull/CommitBodyTest.php @@ -15,6 +15,7 @@ function runsOf(array $blocks): array return array_map(fn (BodyRun $run): array => match (true) { $run->isMention() => [$run->text, '@'.$run->username], $run->isCode => [$run->text, 'code'], + $run->isWordmark => [$run->text, 'wordmark'], default => [$run->text], }, $blocks[0]->runs); } @@ -118,3 +119,55 @@ function runsOf(array $blocks): array expect($commit->mentions)->toHaveCount(2) ->and(runsOf($commit->content))->toBe([['😀 '], ['@ada', '@ada'], [' '], ['x', 'code'], [' '], ['@bob', '@bob']]); }); + +it('draws a standalone /dev/null as the wordmark, keeping its text', function () { + $body = 'piped to /dev/null, then /dev/null. (/dev/null)'; + + $blocks = CommitBody::blocks($body, null); + + expect(runsOf($blocks))->toBe([ + ['piped to '], + ['/dev/null', 'wordmark'], + [', then '], + ['/dev/null', 'wordmark'], + ['. ('], + ['/dev/null', 'wordmark'], + [')'], + ])->and(implode('', array_map(fn (BodyRun $run): string => $run->text, $blocks[0]->runs)))->toBe($body); +}); + +it('leaves /dev/null inside longer paths, words, URLs and usernames as text', function () { + $body = '/dev/nullable /dev/null/log a/dev/null /dev/null_1 /DEV/NULL https://example.com/dev/null @devnull'; + + expect(runsOf(CommitBody::blocks($body, null)))->toBe([[$body]]); +}); + +it('draws the wordmark beside mentions but not in inline code or code blocks', function () { + $body = "@ada sent it to /dev/null, not `/dev/null`:\n```sh\necho hi > /dev/null\n```"; + + $blocks = CommitBody::blocks($body, [ + ['type' => 'text', 'text' => '@ada sent it to /dev/null, not '], + ['type' => 'code', 'code' => '/dev/null'], + ['type' => 'text', 'text' => ':'], + ['type' => 'block', 'language' => 'sh', 'html' => 'echo hi > /dev/null'], + ], [new Mention('ada', 0, 4)]); + + expect(runsOf($blocks))->toBe([ + ['@ada', '@ada'], + [' sent it to '], + ['/dev/null', 'wordmark'], + [', not '], + ['/dev/null', 'code'], + [':'], + ])->and($blocks[1]->code)->toBe('echo hi > /dev/null'); +}); + +it('draws the wordmark at the end of a preview, before the ellipsis it was cut with', function () { + $body = 'Logs go to /dev/null and nowhere else, which is the whole point of it.'; + + expect(runsOf(CommitBody::blocks($body, [['type' => 'text', 'text' => 'Logs go to /dev/null…']])))->toBe([ + ['Logs go to '], + ['/dev/null', 'wordmark'], + ['…'], + ]); +});