diff --git a/app/Devnull/Lane.php b/app/Devnull/Lane.php
index d04f580..456f71b 100644
--- a/app/Devnull/Lane.php
+++ b/app/Devnull/Lane.php
@@ -10,7 +10,8 @@
* for a server that doesn't send one.
*
* The colours themselves are `lane-0` to `lane-5` in the native-ui theme, so
- * `bg-theme-lane-{n}` picks the light or dark lift automatically.
+ * `bg-theme-lane-{n}` picks the light or dark lift automatically. Each has an
+ * `on-lane-{n}` for a button's label.
*/
final class Lane
{
@@ -35,6 +36,16 @@ public static function from(mixed $value): ?int
return $lane >= 0 && $lane < self::Count ? $lane : null;
}
+ /**
+ * The theme token a lane is drawn in, which is also a button variant: your
+ * lane's is what the tab bar, the main buttons and the pager tab underlines
+ * wear. Commit Blue (primary) before devnull has said whose phone this is.
+ */
+ public static function token(?int $lane): string
+ {
+ return $lane === null ? 'primary' : 'lane-'.$lane;
+ }
+
public static function for(string $id): int
{
$hash = 0;
diff --git a/app/NativeComponents/CommitDetail.php b/app/NativeComponents/CommitDetail.php
index 495c628..3bb817a 100644
--- a/app/NativeComponents/CommitDetail.php
+++ b/app/NativeComponents/CommitDetail.php
@@ -338,6 +338,7 @@ public function render(): View
$earlier = $this->earlierShown ? [] : $this->ancestors;
return view('native.commit-detail', [
+ 'myColour' => $this->myColour(),
'viewer' => $this->me(),
'rows' => $this->commit === null ? [] : Timeline::thread(
[...($this->earlierShown ? $this->ancestors : []), $this->commit, ...$this->series],
diff --git a/app/NativeComponents/Concerns/TalksToDevnull.php b/app/NativeComponents/Concerns/TalksToDevnull.php
index b687f73..246acbd 100644
--- a/app/NativeComponents/Concerns/TalksToDevnull.php
+++ b/app/NativeComponents/Concerns/TalksToDevnull.php
@@ -8,6 +8,7 @@
use App\Devnull\Exceptions\ApiException;
use App\Devnull\Exceptions\KeychainUnavailable;
use App\Devnull\Exceptions\Unauthenticated;
+use App\Devnull\Lane;
use Closure;
/**
@@ -60,4 +61,13 @@ protected function me(): ?User
{
return app(Account::class)->user();
}
+
+ /**
+ * The theme token for your lane, which the main buttons and the pager tab
+ * underlines are drawn in.
+ */
+ protected function myColour(): string
+ {
+ return Lane::token($this->me()?->lane());
+ }
}
diff --git a/app/NativeComponents/Layouts/TabsLayout.php b/app/NativeComponents/Layouts/TabsLayout.php
index 8548bae..11b9435 100644
--- a/app/NativeComponents/Layouts/TabsLayout.php
+++ b/app/NativeComponents/Layouts/TabsLayout.php
@@ -3,6 +3,7 @@
namespace App\NativeComponents\Layouts;
use App\Devnull\Account;
+use App\Devnull\Lane;
use App\Icons\Lucide;
use App\NativeComponents\Log;
use Native\Mobile\Edge\Elements\Image;
@@ -90,8 +91,7 @@ public function tabBar(NativeComponent $screen): ?TabBar
public static function accent(): string
{
$primary = (string) theme('primary', '#3b6fe0');
- $me = app(Account::class)->user();
- return $me === null ? $primary : (string) theme('lane-'.$me->lane(), $primary);
+ return (string) theme(Lane::token(app(Account::class)->user()?->lane()), $primary);
}
}
diff --git a/app/NativeComponents/Log.php b/app/NativeComponents/Log.php
index 7af6cdb..864e558 100644
--- a/app/NativeComponents/Log.php
+++ b/app/NativeComponents/Log.php
@@ -698,6 +698,7 @@ public function render(): View
$showing = $pages[array_search($this->log, self::Logs, true)];
return view('native.log', [
+ 'myColour' => $this->myColour(),
'viewer' => $viewer,
'composerOpen' => $this->composing || $this->body !== '',
'remaining' => $this->remaining(),
diff --git a/app/NativeComponents/Notifications.php b/app/NativeComponents/Notifications.php
index 046198f..dc499f0 100644
--- a/app/NativeComponents/Notifications.php
+++ b/app/NativeComponents/Notifications.php
@@ -462,6 +462,7 @@ public function render(): View
}
return view('native.notifications', [
+ 'myColour' => $this->myColour(),
'types' => self::Types,
'readAt' => $readAt,
'pages' => $pages,
diff --git a/app/NativeComponents/Profile.php b/app/NativeComponents/Profile.php
index b45731d..0a7dd6c 100644
--- a/app/NativeComponents/Profile.php
+++ b/app/NativeComponents/Profile.php
@@ -385,6 +385,7 @@ public function render(): View
$pinnedCommit = $this->pinnedCommit();
return view('native.profile', [
+ 'myColour' => $this->myColour(),
'isBlocked' => $this->unavailable === ErrorCode::UserBlocked->value,
'isMe' => $this->isMe(),
'newCommitsLabel' => $this->newCommitsLabel(),
diff --git a/app/NativeComponents/ThisDevice.php b/app/NativeComponents/ThisDevice.php
index 7809b26..aab8440 100644
--- a/app/NativeComponents/ThisDevice.php
+++ b/app/NativeComponents/ThisDevice.php
@@ -312,6 +312,7 @@ public function render(): View
$current = $this->current;
return view('native.this-device', [
+ 'myColour' => $this->myColour(),
'user' => $current?->user,
'fields' => $current === null ? [] : self::fields($current),
'syncedAt' => $current?->githubSyncedAt === null ? 'Last synced when you signed in' : 'Last synced '.Display::ago($current->githubSyncedAt),
diff --git a/composer.lock b/composer.lock
index b3c76d6..d4af933 100644
--- a/composer.lock
+++ b/composer.lock
@@ -3009,12 +3009,12 @@
"source": {
"type": "git",
"url": "https://github.com/NativePHP/mobile-ui.git",
- "reference": "b391517bbcd75e297ada2ef85f6c8ef9b4ae8b9f"
+ "reference": "ca9b7ef7cdc93f490e196862e33516855d3975f8"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/NativePHP/mobile-ui/zipball/b391517bbcd75e297ada2ef85f6c8ef9b4ae8b9f",
- "reference": "b391517bbcd75e297ada2ef85f6c8ef9b4ae8b9f",
+ "url": "https://api.github.com/repos/NativePHP/mobile-ui/zipball/ca9b7ef7cdc93f490e196862e33516855d3975f8",
+ "reference": "ca9b7ef7cdc93f490e196862e33516855d3975f8",
"shasum": ""
},
"require": {
@@ -3073,7 +3073,7 @@
"url": "https://nativephp.com/sponsor"
}
],
- "time": "2026-10-17T16:18:24+00:00"
+ "time": "2026-10-17T16:26:36+00:00"
},
{
"name": "nesbot/carbon",
diff --git a/config/native-ui.php b/config/native-ui.php
index de629ca..1953780 100644
--- a/config/native-ui.php
+++ b/config/native-ui.php
@@ -40,9 +40,11 @@
'theme' => [
// devnull's tokens from DESIGN.md: a cool, nearly colourless shell, one
- // working blue, and six author lanes that only ever mark the graph.
+ // working blue, and six author lanes that mark the graph. Your own lane
+ // also colours your buttons.
'light' => [
- // Commit Blue: the primary button, links, the active tab underline.
+ // Commit Blue: links, the new commits pill, and the main buttons and
+ // tab underlines before devnull has said whose phone this is.
'primary' => '#3b6fe0',
'on-primary' => '#ffffff',
@@ -80,13 +82,24 @@
'pending' => '#b45309',
'pending-soft' => '#fef3c7',
- // Author lanes. Identity only: graph nodes, rail segments, avatar rings.
+ // Author lanes: graph nodes, rail segments, avatar rings. Yours also
+ // colours the tab bar, the main buttons and the pager tab underlines.
'lane-0' => '#d9480f',
'lane-1' => '#2f9e44',
'lane-2' => '#9c36b5',
'lane-3' => '#0c8599',
'lane-4' => '#c2255c',
'lane-5' => '#b7791f',
+
+ // A button's label on each lane: white, except ink on green and
+ // ochre, where white falls well short of 4.5:1. White on orange and
+ // teal is 4.3:1, a hair short, and ink is no better there.
+ 'on-lane-0' => '#ffffff',
+ 'on-lane-1' => '#121417',
+ 'on-lane-2' => '#ffffff',
+ 'on-lane-3' => '#ffffff',
+ 'on-lane-4' => '#ffffff',
+ 'on-lane-5' => '#121417',
],
// A faithful mirror on graphite, with the lanes and primary lifted.
@@ -129,6 +142,15 @@
'lane-3' => '#3bc9db',
'lane-4' => '#f06595',
'lane-5' => '#fcc419',
+
+ // The lifted lanes are all light enough that white won't read on
+ // them (3:1 at best), so labels are graphite, as on primary.
+ 'on-lane-0' => '#111112',
+ 'on-lane-1' => '#111112',
+ 'on-lane-2' => '#111112',
+ 'on-lane-3' => '#111112',
+ 'on-lane-4' => '#111112',
+ 'on-lane-5' => '#111112',
],
// Corner radii (points / dp): 6px controls, 8px groups, circles for people and commits.
diff --git a/resources/views/native/commit-detail.blade.php b/resources/views/native/commit-detail.blade.php
index b853d7d..b8e1ce2 100644
--- a/resources/views/native/commit-detail.blade.php
+++ b/resources/views/native/commit-detail.blade.php
@@ -122,7 +122,7 @@ class="w-full py-2 text-theme-ink"
/>
@include('native.partials.body-counter', ['body' => $reply, 'ref' => 'reply-budget'])
- Reply
+ Reply
diff --git a/resources/views/native/log.blade.php b/resources/views/native/log.blade.php
index 484ef5c..539809c 100644
--- a/resources/views/native/log.blade.php
+++ b/resources/views/native/log.blade.php
@@ -5,8 +5,9 @@
{{--
The logs as pages you swipe between, under DESIGN.md's page tabs, which the pager draws itself: a full-width
- split, the active label in ink over a 3px blue underline. On the phone the underline follows your finger, the
- label you're nearest lights up past halfway, and a tab tap scrolls the pager there, so none of it waits for PHP.
+ split, the active label in ink over a 3px underline in your colour. On the phone the underline follows your
+ finger, the label you're nearest lights up past halfway, and a tab tap scrolls the pager there, so none of it
+ waits for PHP.
The pager says which log it settled on once the swipe has come to rest, not as the log changes under your
finger: switching logs sends a frame big enough to make the swipe stutter if it lands mid-swipe. A tap on the
@@ -27,6 +28,7 @@ class="w-full flex-1 bg-theme-surface"
:tabs="$tabs"
on-tab-reselect="backToTop"
tab-active-color="theme-ink"
+ :tab-indicator-color="'theme-'.$myColour"
tab-color="theme-ink-muted"
tab-divider-color="theme-hairline"
tab-font="semibold"
diff --git a/resources/views/native/notifications.blade.php b/resources/views/native/notifications.blade.php
index e2c536a..cf5f5af 100644
--- a/resources/views/native/notifications.blade.php
+++ b/resources/views/native/notifications.blade.php
@@ -25,6 +25,7 @@ class="w-full flex-1 bg-theme-surface"
tabs-scrollable
on-tab-reselect="backToTop"
tab-active-color="theme-ink"
+ :tab-indicator-color="'theme-'.$myColour"
tab-color="theme-ink-muted"
tab-divider-color="theme-hairline"
tab-font="semibold"
diff --git a/resources/views/native/partials/commit-sheet.blade.php b/resources/views/native/partials/commit-sheet.blade.php
index ce59e26..599ea69 100644
--- a/resources/views/native/partials/commit-sheet.blade.php
+++ b/resources/views/native/partials/commit-sheet.blade.php
@@ -44,7 +44,7 @@ class="w-full text-theme-ink"
@include('native.partials.body-counter', ['body' => $sheetBody, 'ref' => 'sheet-budget'])
- Commit
+ Commit
@@ -84,7 +84,7 @@ class="w-full"
{{ $sheetError }}
@endif
- Send report
+ Send report
@elseif ($sheetView === 'reported')
@@ -93,7 +93,7 @@ class="w-full"
Report received. Moderators will review it privately.
- Done
+ Done
@endif
@endif
diff --git a/resources/views/native/partials/composer.blade.php b/resources/views/native/partials/composer.blade.php
index 4429243..f6d782d 100644
--- a/resources/views/native/partials/composer.blade.php
+++ b/resources/views/native/partials/composer.blade.php
@@ -30,7 +30,7 @@ class="w-full text-theme-ink"
@if ($composerOpen)
@include('native.partials.body-counter', ['body' => $body, 'ref' => 'budget'])
- Commit
+ Commit
@endif
diff --git a/resources/views/native/partials/follow-back.blade.php b/resources/views/native/partials/follow-back.blade.php
index c1791a6..fc1471f 100644
--- a/resources/views/native/partials/follow-back.blade.php
+++ b/resources/views/native/partials/follow-back.blade.php
@@ -1,2 +1,2 @@
-{{-- The web's Follow button on a follow: Follow in Commit Blue, or Following once you do. A tap flips it. --}}
-username }}')" a11y-hint="{{ $isFollowing ? 'Unfollows' : 'Follows' }} {{ $actor->handle() }}">{{ $isFollowing ? 'Following' : 'Follow' }}
+{{-- The web's Follow button on a follow: Follow in your colour, or Following once you do. A tap flips it. --}}
+username }}')" a11y-hint="{{ $isFollowing ? 'Unfollows' : 'Follows' }} {{ $actor->handle() }}">{{ $isFollowing ? 'Following' : 'Follow' }}
diff --git a/resources/views/native/partials/following-empty.blade.php b/resources/views/native/partials/following-empty.blade.php
index 33424bc..286beff 100644
--- a/resources/views/native/partials/following-empty.blade.php
+++ b/resources/views/native/partials/following-empty.blade.php
@@ -27,7 +27,7 @@
{{ $suggestion->plainText() }}
- username }}')" a11y-label="Follow {{ $author->handle() }}">Follow
+ username }}')" a11y-label="Follow {{ $author->handle() }}">Follow
@unless ($loop->last)
diff --git a/resources/views/native/partials/page-tabs.blade.php b/resources/views/native/partials/page-tabs.blade.php
index 8f0918e..a2b664f 100644
--- a/resources/views/native/partials/page-tabs.blade.php
+++ b/resources/views/native/partials/page-tabs.blade.php
@@ -1,11 +1,11 @@
-{{-- DESIGN.md's page tabs: a full-width split, the active label in ink over a 3px blue underline. --}}
+{{-- DESIGN.md's page tabs: a full-width split, the active label in ink over a 3px underline in your colour. --}}
@foreach ($tabs as $tab)
{{ $tab['label'] }}
-
+
@endforeach
diff --git a/resources/views/native/profile.blade.php b/resources/views/native/profile.blade.php
index 34dc52e..4efd9ed 100644
--- a/resources/views/native/profile.blade.php
+++ b/resources/views/native/profile.blade.php
@@ -34,7 +34,7 @@
@elseif ($profile->isFollowing)
Unfollow
@else
- Follow
+ Follow
@endif
diff --git a/resources/views/native/this-device.blade.php b/resources/views/native/this-device.blade.php
index 3a72196..00962cb 100644
--- a/resources/views/native/this-device.blade.php
+++ b/resources/views/native/this-device.blade.php
@@ -1,8 +1,12 @@
+@use('App\Devnull\Lane')
@use('App\Icons\Ios')
@use('App\Icons\Android')
@use('App\Icons\Lucide')
-{{-- Laid out like the web's profile settings: a section each, with its heading, on the surface. --}}
+{{--
+ Laid out like the web's profile settings: a section each, with its heading, on the surface. The full-width buttons
+ are large, as Sign in with GitHub is, and the main ones wear your colour.
+--}}
@include('native.partials.error')
@@ -41,9 +45,9 @@
- Edit on GitHub
+ Edit on GitHub
{{-- The label stays put while it spins, so the button keeps its size. --}}
- Sync from GitHub
+ Sync from GitHub
{{ $syncedAt }}
@if ($syncError)
{{ $syncError }}
@@ -94,8 +98,9 @@
@endforeach
+ {{-- In the colour picked, so you see it on a button before saving it. --}}
- Save color
+ Save color
@if ($laneError)
{{ $laneError }}
@endif
@@ -121,11 +126,11 @@
@if ($appleLinked === false)
{{-- Apple's black button (white in dark mode, the theme's accent) with its logo, as Apple's guidelines ask. --}}
- Sign in with Apple
+ Sign in with Apple
@elseif ($appleLinked === true)
- Unlink Apple ID
+ Unlink Apple ID
@endif
@@ -153,12 +158,12 @@
- Your devices on dev-null.sh
+ Your devices on dev-null.sh
- Sign out
+ Sign out
Deletes the key from this phone and signs you out.
@@ -169,7 +174,7 @@
])
- Delete account
+ Delete account
diff --git a/tests/Feature/Screens/LaneButtonsTest.php b/tests/Feature/Screens/LaneButtonsTest.php
new file mode 100644
index 0000000..46630a8
--- /dev/null
+++ b/tests/Feature/Screens/LaneButtonsTest.php
@@ -0,0 +1,186 @@
+ 'http://devnull.test']);
+});
+
+afterEach(fn () => Platform::set(null));
+
+/**
+ * The colours a button in a lane is sent with: the lane's light and dark
+ * shades, and its label's.
+ *
+ * @return array
+ */
+function laneButtonColours(int $lane): array
+{
+ return [
+ 'variant' => "lane-{$lane}",
+ 'tint_color' => config("native-ui.theme.light.lane-{$lane}"),
+ 'dark_tint_color' => config("native-ui.theme.dark.lane-{$lane}"),
+ 'label_color' => config("native-ui.theme.light.on-lane-{$lane}"),
+ 'dark_label_color' => config("native-ui.theme.dark.on-lane-{$lane}"),
+ ];
+}
+
+/**
+ * The props of the button with this ref, lower-cased for comparing colours.
+ *
+ * @return array
+ */
+function buttonProps(TestableComponent $screen, string $ref): array
+{
+ return array_map(fn (mixed $value): mixed => is_string($value) ? strtolower($value) : $value, nodeByRef($screen, $ref)['props']);
+}
+
+/**
+ * A log with one commit of Ada's, which you can quote and report.
+ */
+function laneLog(): TestableComponent
+{
+ serveApi(fn (Request $request) => Http::response(pageJson([
+ commitJson(['id' => 'c1', 'body' => 'Required reading.', 'author' => userJson(['id' => 'u2', 'username' => 'ada', 'name' => 'Ada'])]),
+ ])));
+
+ return Native::visit('/log', platform: 'ios');
+}
+
+dataset('main buttons', [
+ 'Commit, in the composer' => [fn (): TestableComponent => laneLog()->pressUp('composer-field'), 'commit'],
+ 'Commit, in the compose sheet' => [fn (): TestableComponent => laneLog()->press('composeInSheet'), 'sheet-commit'],
+ 'Commit, quoting a commit' => [fn (): TestableComponent => laneLog()->tap('quote-c1'), 'sheet-commit'],
+ 'Send report' => [fn (): TestableComponent => laneLog()->tap('report-c1'), 'send-report'],
+ 'Follow, on a suggestion in an empty log' => [function (): TestableComponent {
+ serveApi(fn (Request $request) => Http::response([...pageJson([]), 'suggestions' => [commitJson(['id' => 's1', 'author' => userJson(['id' => 'u2', 'username' => 'ada', 'name' => 'Ada'])])]]));
+
+ return Native::visit('/log');
+ }, 'follow-ada'],
+ 'Follow, back from a follow' => [function (): TestableComponent {
+ $follow = notificationJson('followed', ['actor' => userJson(['id' => 'u-linus', 'username' => 'linus', 'name' => 'Linus', 'is_followed' => false])]);
+ serveApi(['GET /api/v1/notifications' => [[
+ ...pageJson([$follow], path: 'http://devnull.test/api/v1/notifications'),
+ 'filters' => ['type' => 'all'],
+ 'read_at' => now()->toIso8601ZuluString(),
+ ]]]);
+
+ return Native::visit('/notifications');
+ }, 'follow-back-linus'],
+ 'Follow, on a profile' => [function (): TestableComponent {
+ serveApi([
+ 'GET /api/v1/users/ada' => [['data' => userWithCountsJson(['id' => 'u2', 'username' => 'ada', 'name' => 'Ada']), 'is_following' => false]],
+ 'GET /api/v1/users/ada/commits' => [pageJson([])],
+ ]);
+
+ return Native::visit('/log/users/ada');
+ }, 'follow'],
+ 'Reply' => [function (): TestableComponent {
+ serveApi([
+ 'GET /api/v1/commits/c2' => [commitPageJson(commitJson(['id' => 'c2']))],
+ 'GET /api/v1/commits/c2/replies' => [pageJson([])],
+ ]);
+
+ return Native::visit('/log/commits/c2');
+ }, 'send-reply'],
+ 'Edit on GitHub, on the Account screen' => [function (): TestableComponent {
+ serveApi(['GET /api/v1/me' => [currentUserJson(['lane' => app(Account::class)->user()?->lane()])]]);
+
+ return Native::visit('/profile/device');
+ }, 'edit-on-github'],
+]);
+
+it('draws the main buttons in your lane, with a label that reads on it', function (Closure $screen, string $ref, int $lane) {
+ signIn(['lane' => $lane]);
+
+ expect(buttonProps($screen(), $ref))->toMatchArray(array_map(strtolower(...), laneButtonColours($lane)));
+})->with('main buttons')->with([
+ 'green, labelled in ink' => 1,
+ 'rose, labelled in white' => 4,
+]);
+
+it('draws the tab underline in your lane, in its light and dark shades', function (string $path, int $lane) {
+ signIn(['lane' => $lane]);
+ Http::fake(['devnull.test/*' => Http::response(pageJson([]))]);
+
+ $pager = findNode(Native::visit($path)->tree(), fn (array $node): bool => ($node['type'] ?? null) === 'pager');
+
+ expect(strtolower($pager['props']['tab_indicator_color']))->toBe(strtolower(config("native-ui.theme.light.lane-{$lane}")))
+ ->and(strtolower($pager['props']['dark_tab_indicator_color']))->toBe(strtolower(config("native-ui.theme.dark.lane-{$lane}")))
+ // Only the underline: the labels stay ink.
+ ->and($pager['props']['tab_active_color'])->toBe(config('native-ui.theme.light.ink'));
+})->with(['/log', '/notifications'])->with([2, 5]);
+
+it('draws the underline under your profile\'s tabs in your lane too', function () {
+ signIn(['lane' => 5]);
+ serveApi([
+ 'GET /api/v1/users/local_dev' => [['data' => userWithCountsJson(['lane' => 5]), 'is_following' => false]],
+ 'GET /api/v1/users/local_dev/commits' => [pageJson([])],
+ ]);
+
+ $underline = findNode(nodeByRef(Native::visit('/profile'), 'tab-commits'), fn (array $node): bool => ($node['layout']['height'] ?? null) === 3.0);
+
+ expect(strtolower($underline['style']['bg_color']))->toBe(strtolower(config('native-ui.theme.light.lane-5')));
+});
+
+it('draws Save color in the colour you picked, before you save it', function () {
+ signIn(['lane' => 3]);
+ serveApi(['GET /api/v1/me' => [currentUserJson(['lane' => 3, 'preferred_lane' => 3, 'automatic_lane' => 3])]]);
+
+ $screen = Native::visit('/profile/device');
+
+ expect(buttonProps($screen, 'save-lane')['variant'])->toBe('lane-3');
+
+ $screen->tap('lane-1');
+
+ expect(buttonProps($screen, 'save-lane'))->toMatchArray(array_map(strtolower(...), laneButtonColours(1)))
+ // Everything else waits for the save.
+ ->and(buttonProps($screen, 'edit-on-github')['variant'])->toBe('lane-3');
+});
+
+it('leaves the other buttons in their own colours', function () {
+ signIn(['lane' => 4])->respondTo('NativephpSocialAuth.AppleSignIn', ['pending' => true]);
+ serveApi(['GET /api/v1/me' => [currentUserJson(['lane' => 4], ['apple_linked' => false])]]);
+
+ $screen = Native::visit('/profile/device', platform: 'ios');
+
+ foreach (['sync-github' => 'secondary', 'devices' => 'secondary', 'sign-out' => 'secondary', 'delete-account' => 'destructive', 'link-apple' => 'accent'] as $ref => $variant) {
+ expect(buttonProps($screen, $ref))
+ ->toMatchArray(['variant' => $variant])
+ ->not->toHaveKeys(['tint_color', 'label_color']);
+ }
+});
+
+it('makes the full-width buttons large, and leaves the ones in a row their size', function () {
+ signIn(['lane' => 4])->respondTo('NativephpSocialAuth.AppleSignIn', ['pending' => true]);
+ serveApi(['GET /api/v1/me' => [currentUserJson(['lane' => 4], ['apple_linked' => false])]]);
+
+ $account = Native::visit('/profile/device', platform: 'ios');
+
+ foreach (['edit-on-github', 'sync-github', 'save-lane', 'link-apple', 'devices', 'sign-out', 'delete-account'] as $ref) {
+ expect(buttonProps($account, $ref)['size'] ?? null)->toBe('lg', $ref);
+ }
+
+ $report = laneLog()->tap('report-c1');
+
+ expect(buttonProps($report, 'send-report')['size'] ?? null)->toBe('lg');
+
+ $report->selectRadio('report-reason', 'spam')->tap('send-report');
+
+ expect(buttonProps($report, 'report-done')['size'] ?? null)->toBe('lg');
+
+ expect(buttonProps(laneLog()->pressUp('composer-field'), 'commit'))->not->toHaveKey('size')
+ ->and(buttonProps(laneLog()->tap('quote-c1'), 'sheet-commit'))->not->toHaveKey('size');
+});
+
+it('makes Unlink Apple ID large too', function () {
+ signIn()->respondTo('NativephpSocialAuth.AppleSignIn', ['pending' => true]);
+ serveApi(['GET /api/v1/me' => [currentUserJson(extra: ['apple_linked' => true])]]);
+
+ expect(buttonProps(Native::visit('/profile/device', platform: 'ios'), 'unlink-apple')['size'] ?? null)->toBe('lg');
+});
diff --git a/tests/Unit/Devnull/LaneTest.php b/tests/Unit/Devnull/LaneTest.php
index 30583de..b65f622 100644
--- a/tests/Unit/Devnull/LaneTest.php
+++ b/tests/Unit/Devnull/LaneTest.php
@@ -27,3 +27,53 @@
['blue', null],
[2.5, null],
]);
+
+it('names the theme token your colour is drawn in, Commit Blue before devnull says who you are', function (?int $lane, string $token) {
+ expect(Lane::token($lane))->toBe($token);
+})->with([
+ [0, 'lane-0'],
+ [5, 'lane-5'],
+ [null, 'primary'],
+]);
+
+/**
+ * WCAG 2's contrast ratio between two #rrggbb colours.
+ */
+function contrastRatio(string $first, string $second): float
+{
+ $luminance = function (string $hex): float {
+ $channels = array_map(function (string $pair): float {
+ $channel = hexdec($pair) / 255;
+
+ return $channel <= 0.03928 ? $channel / 12.92 : (($channel + 0.055) / 1.055) ** 2.4;
+ }, str_split(ltrim($hex, '#'), 2));
+
+ return 0.2126 * $channels[0] + 0.7152 * $channels[1] + 0.0722 * $channels[2];
+ };
+
+ [$lighter, $darker] = [max($luminance($first), $luminance($second)), min($luminance($first), $luminance($second))];
+
+ return ($lighter + 0.05) / ($darker + 0.05);
+}
+
+it('labels a button on each lane in whichever of white and ink reads better', function (string $mode, string $ink) {
+ $theme = (require dirname(__DIR__, 3).'/config/native-ui.php')['theme'][$mode];
+
+ foreach (range(0, Lane::Count - 1) as $lane) {
+ $fill = $theme["lane-{$lane}"];
+ $better = contrastRatio('#ffffff', $fill) >= contrastRatio($theme[$ink], $fill) ? '#ffffff' : $theme[$ink];
+
+ expect($theme["on-lane-{$lane}"])->toBe($better, "lane-{$lane} in {$mode}");
+ }
+})->with([
+ 'light, against ink' => ['light', 'ink'],
+ 'dark, against graphite' => ['dark', 'on-primary'],
+]);
+
+it('reads at 4.5:1 or better on every lifted lane in dark mode', function () {
+ $theme = (require dirname(__DIR__, 3).'/config/native-ui.php')['theme']['dark'];
+
+ foreach (range(0, Lane::Count - 1) as $lane) {
+ expect(contrastRatio($theme["on-lane-{$lane}"], $theme["lane-{$lane}"]))->toBeGreaterThanOrEqual(4.5);
+ }
+});