From bf677482401a35895573c5ea464e0e85552b91b3 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Mon, 19 Oct 2026 09:54:19 +0100 Subject: [PATCH 1/2] Draw custom lane colours, and mix one on Account The web lets people pick any colour for their lane, and devnull sends it as a #rrggbb lane. The app read that as no lane and fell back to the hashed one, so those people showed in the wrong colour. A custom colour now becomes a theme token of its own, lane-rrggbb, the first time it's drawn, with a label colour picked the way the palette's on-lane tokens are. Every view draws lanes through Lane::token(), so rails, avatars, profiles, notifications, the tab bar and the main buttons all take it. Like the web, it's the same colour in light and dark mode. Account gets the web's Custom option after the six lanes. It opens a sheet with hue, saturation and lightness sliders, standing in for the system colour picker the web opens, and Save color sends {"lane": null, "color"} as the web's form does. Co-Authored-By: Claude Opus 5.5 --- app/Devnull/Data/CurrentUser.php | 8 +- app/Devnull/Data/User.php | 11 +- app/Devnull/Hsl.php | 70 ++++ app/Devnull/Lane.php | 105 +++++- app/NativeComponents/Layouts/TabsLayout.php | 4 +- app/NativeComponents/ThisDevice.php | 117 +++++- .../partials/achievements-sheet.blade.php | 18 +- .../native/partials/achievements.blade.php | 2 +- .../views/native/partials/activity.blade.php | 4 +- .../native/partials/colour-sheet.blade.php | 44 +++ .../native/partials/connection.blade.php | 2 +- .../native/partials/earlier-commits.blade.php | 2 +- .../partials/notification-group.blade.php | 2 +- .../native/partials/notification.blade.php | 2 +- .../native/partials/profile-header.blade.php | 8 +- .../native/partials/rail-avatar.blade.php | 4 +- .../views/native/partials/rail-node.blade.php | 2 +- .../views/native/partials/rail.blade.php | 2 +- .../views/native/partials/streaks.blade.php | 2 +- .../native/partials/thread-elbow.blade.php | 2 +- .../native/partials/thread-reply.blade.php | 2 +- resources/views/native/this-device.blade.php | 343 +++++++++--------- tests/Feature/Screens/AccountColorTest.php | 73 +++- tests/Feature/Screens/CustomColoursTest.php | 84 +++++ tests/Unit/Devnull/HslTest.php | 36 ++ tests/Unit/Devnull/LaneTest.php | 21 +- 26 files changed, 742 insertions(+), 228 deletions(-) create mode 100644 app/Devnull/Hsl.php create mode 100644 resources/views/native/partials/colour-sheet.blade.php create mode 100644 tests/Feature/Screens/CustomColoursTest.php create mode 100644 tests/Unit/Devnull/HslTest.php diff --git a/app/Devnull/Data/CurrentUser.php b/app/Devnull/Data/CurrentUser.php index 62eb9ca..f802fae 100644 --- a/app/Devnull/Data/CurrentUser.php +++ b/app/Devnull/Data/CurrentUser.php @@ -9,8 +9,8 @@ * A `GET /me` answer: who this device is signed in as, how many of their * notifications are still unread, and whether an Apple ID is linked to them. * - * It also has what only you see in the web's profile settings: the lane you - * chose (null for Automatic) and the one Automatic gives you, your GitHub + * It also has what only you see in the web's profile settings: the lane or + * custom colour you chose (null for Automatic) and the one Automatic gives you, your GitHub * Sponsors page, and when your profile was last synced from GitHub (null when * it only was when you signed in). It has whether pastéis mode is on, how * many pastéis you can still give today, and how many others have given you. @@ -21,7 +21,7 @@ public function __construct( public User $user, public int $unreadNotificationsCount = 0, public bool $appleLinked = false, - public ?int $preferredLane = null, + public int|string|null $preferredLane = null, public int $automaticLane = 0, public ?string $sponsorsUrl = null, public ?CarbonImmutable $githubSyncedAt = null, @@ -38,7 +38,7 @@ public static function fromArray(array $json): self $data = is_array($json['data'] ?? null) ? $json['data'] : []; $unread = $json['unread_notifications_count'] ?? $data['unread_notifications_count'] ?? 0; $user = User::fromArray($data); - $automaticLane = Lane::from($data['automatic_lane'] ?? null) ?? Lane::for($user->id); + $automaticLane = Lane::palette($data['automatic_lane'] ?? null) ?? Lane::for($user->id); $sponsorsUrl = $data['sponsors_url'] ?? null; return new self( diff --git a/app/Devnull/Data/User.php b/app/Devnull/Data/User.php index 7bf7389..c8f7db8 100644 --- a/app/Devnull/Data/User.php +++ b/app/Devnull/Data/User.php @@ -17,7 +17,7 @@ public function __construct( public string $name, public ?string $avatarUrl = null, public ?string $bio = null, - public ?int $lane = null, + public int|string|null $lane = null, public ?string $company = null, public ?string $location = null, public ?string $website = null, @@ -56,7 +56,7 @@ public static function fromArray(array $data): self /** * The user in the API's shape, so it can be kept and read back. * - * @return array{id: string, username: string, name: string, avatar_url: ?string, bio: ?string, lane: ?int, company: ?string, location: ?string, website: ?string, github_url: ?string, html_url: ?string, feed_url: ?string, created_at: ?string} + * @return array{id: string, username: string, name: string, avatar_url: ?string, bio: ?string, lane: int|string|null, company: ?string, location: ?string, website: ?string, github_url: ?string, html_url: ?string, feed_url: ?string, created_at: ?string} */ public function toArray(): array { @@ -78,10 +78,11 @@ public function toArray(): array } /** - * The lane the user picked on the web, or the one devnull gave them. - * Hashing the id is only for a server too old to send it. + * The lane the user picked on the web, a palette lane or a custom + * `#rrggbb` colour, or the one devnull gave them. Hashing the id is only + * for a server too old to send it. */ - public function lane(): int + public function lane(): int|string { return $this->lane ?? Lane::for($this->id); } diff --git a/app/Devnull/Hsl.php b/app/Devnull/Hsl.php new file mode 100644 index 0000000..420f482 --- /dev/null +++ b/app/Devnull/Hsl.php @@ -0,0 +1,70 @@ + hexdec($pair) / 255, str_split(substr($hex, 1, 6), 2)); + + $max = max($red, $green, $blue); + $min = min($red, $green, $blue); + $lightness = ($max + $min) / 2; + $delta = $max - $min; + + if ($delta == 0.0) { + return new self(0.0, 0.0, round($lightness * 100)); + } + + $saturation = $delta / (1 - abs(2 * $lightness - 1)); + + $hue = match ($max) { + $red => fmod(($green - $blue) / $delta + 6, 6), + $green => ($blue - $red) / $delta + 2, + default => ($red - $green) / $delta + 4, + }; + + return new self(fmod(round($hue * 60), 360), round($saturation * 100), round($lightness * 100)); + } + + /** + * The colour as lowercase `#rrggbb`, with each value held to its range. + */ + public function hex(): string + { + $hue = fmod(fmod($this->hue, 360) + 360, 360); + $saturation = max(0, min(100, $this->saturation)) / 100; + $lightness = max(0, min(100, $this->lightness)) / 100; + + $chroma = (1 - abs(2 * $lightness - 1)) * $saturation; + $second = $chroma * (1 - abs(fmod($hue / 60, 2) - 1)); + $match = $lightness - $chroma / 2; + + [$red, $green, $blue] = match (intdiv((int) $hue, 60)) { + 0 => [$chroma, $second, 0], + 1 => [$second, $chroma, 0], + 2 => [0, $chroma, $second], + 3 => [0, $second, $chroma], + 4 => [$second, 0, $chroma], + default => [$chroma, 0, $second], + }; + + return '#'.implode('', array_map(fn (float|int $channel): string => sprintf('%02x', (int) round(($channel + $match) * 255)), [$red, $green, $blue])); + } +} diff --git a/app/Devnull/Lane.php b/app/Devnull/Lane.php index 456f71b..f6e3b2b 100644 --- a/app/Devnull/Lane.php +++ b/app/Devnull/Lane.php @@ -2,16 +2,20 @@ namespace App\Devnull; +use Native\Mobile\UI\Theme; + /** * An author's lane on the commit graph. * - * Users carry their lane (0 to 5) in the API, picked by them on the web or - * assigned by devnull. The hash below is the web's old `laneFor()`, kept only - * for a server that doesn't send one. + * Users carry their lane in the API, picked by them on the web or assigned by + * devnull: one of the six in the palette (0 to 5), or a custom colour picked + * with the web's colour picker, as lowercase `#rrggbb`. The hash below is the + * web's old `laneFor()`, kept only for a server that doesn't send one. * - * The colours themselves are `lane-0` to `lane-5` in the native-ui theme, so + * The palette's colours are `lane-0` to `lane-5` in the native-ui theme, so * `bg-theme-lane-{n}` picks the light or dark lift automatically. Each has an - * `on-lane-{n}` for a button's label. + * `on-lane-{n}` for a button's label. A custom colour gets a token of its own + * the first time it's drawn, so views draw any lane through token(). */ final class Lane { @@ -23,9 +27,23 @@ final class Lane public const string Rail = 'rail'; /** - * Reads a lane from the API, or null when it's missing or out of range. + * Reads a lane from the API: a palette lane, a custom colour as lowercase + * `#rrggbb`, or null when it's missing or neither. + */ + public static function from(mixed $value): int|string|null + { + if (is_string($value) && self::isCustom($value)) { + return strtolower($value); + } + + return self::palette($value); + } + + /** + * Reads a palette lane, 0 to 5, or null when it's missing or out of range. + * Automatic only ever gives one of these. */ - public static function from(mixed $value): ?int + public static function palette(mixed $value): ?int { if (! is_int($value) && ! (is_string($value) && ctype_digit($value))) { return null; @@ -36,14 +54,33 @@ public static function from(mixed $value): ?int return $lane >= 0 && $lane < self::Count ? $lane : null; } + /** + * Whether a lane is a custom colour rather than one of the palette's. + */ + public static function isCustom(int|string|null $lane): bool + { + return is_string($lane) && preg_match('/^#[0-9a-f]{6}$/i', $lane) === 1; + } + /** * 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. + * wear. Commit Blue (primary) before devnull has said whose phone this is, + * and Rail Gray for a deleted commit's. + * + * A custom colour is added to the theme as `lane-{rrggbb}` the first time + * it's asked for, with an `on-` token for a button's label, so it works + * wherever a palette lane's token does. Like the web, it draws the same in + * light and dark mode. */ - public static function token(?int $lane): string + public static function token(int|string|null $lane): string { - return $lane === null ? 'primary' : 'lane-'.$lane; + return match (true) { + $lane === null => 'primary', + $lane === self::Rail => 'rail', + is_string($lane) => self::isCustom($lane) ? self::customToken(strtolower($lane)) : 'primary', + default => 'lane-'.$lane, + }; } public static function for(string $id): int @@ -57,6 +94,54 @@ public static function for(string $id): int return $hash % self::Count; } + private static function customToken(string $colour): string + { + $token = 'lane-'.substr($colour, 1); + + if (! is_string(Theme::get("light.{$token}"))) { + Theme::merge([ + 'light' => [$token => $colour, "on-{$token}" => self::label($colour, (string) Theme::get('light.ink'))], + 'dark' => [$token => $colour, "on-{$token}" => self::label($colour, (string) Theme::get('dark.on-primary'))], + ]); + } + + return $token; + } + + /** + * White or ink on a colour, whichever reads better, as the palette's + * `on-lane-{n}` tokens are picked. + */ + private static function label(string $colour, string $ink): string + { + if (preg_match('/^#[0-9a-f]{6}$/i', $ink) !== 1) { + return '#ffffff'; + } + + return self::contrast('#ffffff', $colour) >= self::contrast($ink, $colour) ? '#ffffff' : $ink; + } + + /** + * WCAG 2's contrast ratio between two `#rrggbb` colours. + */ + private static function contrast(string $first, string $second): float + { + [$a, $b] = [self::luminance($first), self::luminance($second)]; + + return (max($a, $b) + 0.05) / (min($a, $b) + 0.05); + } + + private static function luminance(string $colour): 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(substr($colour, 1, 6), 2)); + + return 0.2126 * $channels[0] + 0.7152 * $channels[1] + 0.0722 * $channels[2]; + } + /** * JavaScript's `charCodeAt(0)` on one code point: the high surrogate above the BMP. */ diff --git a/app/NativeComponents/Layouts/TabsLayout.php b/app/NativeComponents/Layouts/TabsLayout.php index 1b2d782..525518b 100644 --- a/app/NativeComponents/Layouts/TabsLayout.php +++ b/app/NativeComponents/Layouts/TabsLayout.php @@ -87,8 +87,8 @@ public function tabBar(NativeComponent $screen): ?TabBar } /** - * Your lane's colour, in its light or dark lift, or Commit Blue before - * devnull has said who you are. + * Your lane's colour, in its light or dark lift, or your custom colour, + * or Commit Blue before devnull has said who you are. */ public static function accent(): string { diff --git a/app/NativeComponents/ThisDevice.php b/app/NativeComponents/ThisDevice.php index b56cc49..e47e324 100644 --- a/app/NativeComponents/ThisDevice.php +++ b/app/NativeComponents/ThisDevice.php @@ -9,6 +9,7 @@ use App\Devnull\DeviceKey; use App\Devnull\Display; use App\Devnull\Exceptions\KeychainUnavailable; +use App\Devnull\Hsl; use App\Devnull\Lane; use App\Devnull\ResponseCache; use App\NativeComponents\Concerns\RefreshesInBackground; @@ -69,9 +70,27 @@ class ThisDevice extends NativeComponent public ?CurrentUser $current = null; /** - * The colour picked on the screen, which the preview shows until it's saved: a lane, or null for Automatic. + * The colour picked on the screen, which the preview shows until it's saved: a lane, a custom `#rrggbb` colour, + * or null for Automatic. */ - public ?int $chosenLane = null; + public int|string|null $chosenLane = null; + + /** + * Whether the sheet for mixing a custom colour is open, as the web's Custom opens the system's colour picker. + */ + public bool $mixingColour = false; + + /** + * The custom colour being mixed, as `#rrggbb`, and the hue, saturation and lightness its sliders show. It starts + * as the colour it was opened on, exactly, until a slider moves. + */ + public string $mixedColour = '#000000'; + + public float $hue = 0; + + public float $saturation = 0; + + public float $lightness = 0; public bool $syncing = false; @@ -167,10 +186,17 @@ public function syncProfile(): void /** * Previews a colour, 'auto' or a lane. Nothing changes on devnull until it's saved. + * 'custom' opens the sheet that mixes a colour of your own. */ public function chooseLane(string $lane): void { - $chosen = $lane === 'auto' ? null : Lane::from($lane); + if ($lane === 'custom') { + $this->mixColour(); + + return; + } + + $chosen = $lane === 'auto' ? null : Lane::palette($lane); if ($lane !== 'auto' && $chosen === null) { return; @@ -180,9 +206,56 @@ public function chooseLane(string $lane): void $this->laneError = null; } + /** + * Opens the custom colour sheet on the custom colour you have, or on the + * one you're previewing, as the web opens its colour picker. + */ + public function mixColour(): void + { + $colour = $this->customColour() ?? (string) theme('lane-'.(Lane::palette($this->chosenLane) ?? $this->current?->automaticLane ?? 0)); + + $this->mixedColour = strtolower($colour); + $hsl = Hsl::fromHex($this->mixedColour); + $this->hue = $hsl->hue; + $this->saturation = $hsl->saturation; + $this->lightness = $hsl->lightness; + $this->mixingColour = true; + } + + public function updatedHue(): void + { + $this->mixedColour = (new Hsl($this->hue, $this->saturation, $this->lightness))->hex(); + } + + public function updatedSaturation(): void + { + $this->updatedHue(); + } + + public function updatedLightness(): void + { + $this->updatedHue(); + } + + /** + * Previews the colour mixed, which Save color then saves like any other. + */ + public function useMixedColour(): void + { + $this->chosenLane = $this->mixedColour; + $this->laneError = null; + $this->mixingColour = false; + } + + public function colourSheetClosed(): void + { + $this->mixingColour = false; + } + /** * Saves the colour picked. Once devnull has it, this phone remembers you - * with it, so the tab bar and your profile take it at once. + * with it, so the tab bar and your profile take it at once. A custom + * colour goes as the web's colour picker sends it, with no lane. */ public function saveLane(): void { @@ -193,7 +266,9 @@ public function saveLane(): void $this->savingLane = true; $this->laneError = null; - $this->writeInBackground('PUT', Client::LanePath, ['lane' => $this->chosenLane], function (array $json): void { + $body = Lane::isCustom($this->chosenLane) ? ['lane' => null, 'color' => $this->chosenLane] : ['lane' => $this->chosenLane]; + + $this->writeInBackground('PUT', Client::LanePath, $body, function (array $json): void { $this->savingLane = false; $this->takeMe($json); }, function (string $message): void { @@ -417,10 +492,11 @@ private static function fields(CurrentUser $current): array } /** - * Automatic, then the six lanes, as the web's picker offers them. Automatic - * shows the lane it gives you, and says which for VoiceOver. + * Automatic, then the six lanes, then Custom, as the web's picker offers + * them. Automatic shows the lane it gives you, and says which for + * VoiceOver. Custom shows your custom colour once you have one. * - * @return list + * @return list */ private function laneOptions(CurrentUser $current): array { @@ -437,9 +513,34 @@ private function laneOptions(CurrentUser $current): array $options[] = ['key' => (string) $lane, 'label' => $name, 'a11y' => $name, 'lane' => $lane, 'auto' => false, 'chosen' => $this->chosenLane === $lane]; } + $custom = $this->customColour(); + + $options[] = [ + 'key' => 'custom', + 'label' => 'Custom', + 'a11y' => $custom === null ? 'Custom color' : "Custom color ({$custom})", + 'lane' => $custom, + 'auto' => false, + 'chosen' => Lane::isCustom($this->chosenLane), + ]; + return $options; } + /** + * The custom colour you're previewing, or else the one you have. + */ + private function customColour(): ?string + { + foreach ([$this->chosenLane, $this->current?->preferredLane] as $lane) { + if (is_string($lane) && Lane::isCustom($lane)) { + return $lane; + } + } + + return null; + } + /** * Shows you as devnull sent you. A colour picked and not saved yet stays * picked; otherwise the picker follows the one devnull has. While the diff --git a/resources/views/native/partials/achievements-sheet.blade.php b/resources/views/native/partials/achievements-sheet.blade.php index 98ed917..3f899e9 100644 --- a/resources/views/native/partials/achievements-sheet.blade.php +++ b/resources/views/native/partials/achievements-sheet.blade.php @@ -23,8 +23,8 @@ Developer Achievements - - {{ $achievements->label() }} + + {{ $achievements->label() }} {{ $profile->user->handle() }} · {{ $achievements->xp }} XP · {{ $achievements->unlockedCount() }}/{{ count($achievements->badges) }} badges unlocked @@ -36,7 +36,7 @@ {{ $achievements->nextRankLabel() }} - @include('native.partials.progress-bar', ['ref' => 'sheet-level-progress', 'percent' => $achievements->levelProgress, 'fill' => "bg-theme-lane-{$lane}", 'label' => "{$achievements->levelProgress}% of the way to the next rank"]) + @include('native.partials.progress-bar', ['ref' => 'sheet-level-progress', 'percent' => $achievements->levelProgress, 'fill' => 'bg-theme-'.\App\Devnull\Lane::token($lane), 'label' => "{$achievements->levelProgress}% of the way to the next rank"]) @@ -47,7 +47,7 @@ {{ $title }} ({{ $count }}) - + @endforeach @@ -59,7 +59,7 @@ @foreach ($achievements->badges as $index => $badge) @php($icon = \App\Icons\Lucide::forBadge($badge->icon)) - + @@ -86,16 +86,16 @@ @foreach ($achievements->ranks as $rank) @php($current = $rank->level === $achievements->level) - - + + {{ $rank->level }} {{ $rank->rank }} @if ($current) - - CURRENT + + CURRENT @endif diff --git a/resources/views/native/partials/achievements.blade.php b/resources/views/native/partials/achievements.blade.php index b29bb6f..6f9c0a3 100644 --- a/resources/views/native/partials/achievements.blade.php +++ b/resources/views/native/partials/achievements.blade.php @@ -32,7 +32,7 @@ {{ $achievements->nextRankLabel() }} - @include('native.partials.progress-bar', ['ref' => 'level-progress', 'percent' => $achievements->levelProgress, 'fill' => "bg-theme-lane-{$lane}", 'label' => "{$achievements->levelProgress}% of the way to the next rank"]) + @include('native.partials.progress-bar', ['ref' => 'level-progress', 'percent' => $achievements->levelProgress, 'fill' => 'bg-theme-'.\App\Devnull\Lane::token($lane), 'label' => "{$achievements->levelProgress}% of the way to the next rank"]) diff --git a/resources/views/native/partials/activity.blade.php b/resources/views/native/partials/activity.blade.php index 1360d69..7955370 100644 --- a/resources/views/native/partials/activity.blade.php +++ b/resources/views/native/partials/activity.blade.php @@ -20,7 +20,7 @@ @foreach ($cells as $cell) @if ($cell->level > 0) - + @endif @endforeach @@ -43,7 +43,7 @@ @php($swatch = new \App\Devnull\Data\ActivityCell(0, $level)) @if ($level > 0) - + @endif @endforeach diff --git a/resources/views/native/partials/colour-sheet.blade.php b/resources/views/native/partials/colour-sheet.blade.php new file mode 100644 index 0000000..b905754 --- /dev/null +++ b/resources/views/native/partials/colour-sheet.blade.php @@ -0,0 +1,44 @@ +{{-- + The sheet Custom opens on the Account screen, standing in for the system colour picker the web's Custom opens: a + swatch of the colour being mixed and its hex, then a slider each for hue, saturation and lightness, and Use color, + which previews it on the screen behind for Save color to save, like any other colour. + + The swatch is drawn in the colour itself rather than a lane's token, so dragging adds nothing to the theme. Only the + colour you use becomes a token, when the screen behind draws it. +--}} + + + Custom color + + + + {{ $mixedColour }} + + + + + Hue + {{ (int) $hue }} + + + + + + + Saturation + {{ (int) $saturation }} + + + + + + + Lightness + {{ (int) $lightness }} + + + + + Use color + + diff --git a/resources/views/native/partials/connection.blade.php b/resources/views/native/partials/connection.blade.php index c2611b3..a6307f2 100644 --- a/resources/views/native/partials/connection.blade.php +++ b/resources/views/native/partials/connection.blade.php @@ -9,7 +9,7 @@ @php($user = $person->user) - + @include('native.partials.avatar', ['user' => $user, 'size' => 40]) diff --git a/resources/views/native/partials/earlier-commits.blade.php b/resources/views/native/partials/earlier-commits.blade.php index e66a2ff..54f56dd 100644 --- a/resources/views/native/partials/earlier-commits.blade.php +++ b/resources/views/native/partials/earlier-commits.blade.php @@ -7,7 +7,7 @@ which runs on solid into the commit below. --}} @php($label = \App\Devnull\Display::count($count, 'earlier commit', 'earlier commits')) -@php($colour = $lane === \App\Devnull\Lane::Rail ? 'bg-theme-rail' : 'bg-theme-lane-'.$lane) +@php($colour = 'bg-theme-'.\App\Devnull\Lane::token($lane)) diff --git a/resources/views/native/partials/notification-group.blade.php b/resources/views/native/partials/notification-group.blade.php index 6af6b61..405223e 100644 --- a/resources/views/native/partials/notification-group.blade.php +++ b/resources/views/native/partials/notification-group.blade.php @@ -15,7 +15,7 @@ {{ $item->actor->name }} @php($itemIcon = \App\Icons\Lucide::forNotification($item->type)) - + {{ \App\Devnull\Display::relativeTime($item->createdAt) }} @if ($item->isFollow() && $item->actorIsFollowed !== null) @include('native.partials.follow-back', ['actor' => $item->actor, 'isFollowing' => $item->actorIsFollowed]) diff --git a/resources/views/native/partials/notification.blade.php b/resources/views/native/partials/notification.blade.php index 27aae34..13b7a23 100644 --- a/resources/views/native/partials/notification.blade.php +++ b/resources/views/native/partials/notification.blade.php @@ -13,7 +13,7 @@ weight from font_weight over the face it was given, so the face alone draws regular. --}} @php($icon = \App\Icons\Lucide::forNotification($notification->type)) -@php($iconColour = $notification->isModeration() ? 'text-theme-ink-muted' : 'text-theme-lane-'.$notification->actor->lane()) +@php($iconColour = $notification->isModeration() ? 'text-theme-ink-muted' : 'text-theme-'.\App\Devnull\Lane::token($notification->actor->lane())) @php($open = array_key_exists($notification->id, $expanded)) @php($failed = in_array($notification->id, $failedGroups, true)) @php($noun = $notification->groupNoun()) diff --git a/resources/views/native/partials/profile-header.blade.php b/resources/views/native/partials/profile-header.blade.php index 03668eb..f6ba54b 100644 --- a/resources/views/native/partials/profile-header.blade.php +++ b/resources/views/native/partials/profile-header.blade.php @@ -7,7 +7,7 @@ {{-- The avatar wears the author's lane as a ring, offset from the surface. --}} - + @include('native.partials.avatar', ['user' => $user, 'size' => 80]) @@ -31,7 +31,7 @@ owner's lane. EDGE's rows don't wrap on iOS, so the handle gives way first. --}} - + {{ $user->handle() }} @if ($profile->followsYou && ! $sketched) @@ -44,8 +44,8 @@ @endif @if ($profile->achievements && ! $sketched) - - {{ $profile->achievements->label() }} + + {{ $profile->achievements->label() }} @endif diff --git a/resources/views/native/partials/rail-avatar.blade.php b/resources/views/native/partials/rail-avatar.blade.php index 1b9097a..18a5407 100644 --- a/resources/views/native/partials/rail-avatar.blade.php +++ b/resources/views/native/partials/rail-avatar.blade.php @@ -7,13 +7,13 @@ @php($size ??= 48) @if ($staged) - @include('native.partials.dashed-ring', ['size' => 48, 'colour' => 'bg-theme-lane-'.$lane]) + @include('native.partials.dashed-ring', ['size' => 48, 'colour' => 'bg-theme-'.\App\Devnull\Lane::token($lane)]) @include('native.partials.avatar', ['user' => $user, 'size' => 40]) @else - + @include('native.partials.avatar', ['user' => $user, 'size' => $size - 8]) diff --git a/resources/views/native/partials/rail-node.blade.php b/resources/views/native/partials/rail-node.blade.php index 440d659..0e365eb 100644 --- a/resources/views/native/partials/rail-node.blade.php +++ b/resources/views/native/partials/rail-node.blade.php @@ -1,6 +1,6 @@ {{-- One commit on the graph: a ring in its lane (or Rail Gray for a deleted one) with a gap punched in the surface colour. --}} @php($filled ??= true) -@php($colour = $lane === \App\Devnull\Lane::Rail ? 'bg-theme-rail' : 'bg-theme-lane-'.$lane) +@php($colour = 'bg-theme-'.\App\Devnull\Lane::token($lane)) @if ($filled) diff --git a/resources/views/native/partials/rail.blade.php b/resources/views/native/partials/rail.blade.php index 149d278..6f5e3fe 100644 --- a/resources/views/native/partials/rail.blade.php +++ b/resources/views/native/partials/rail.blade.php @@ -26,7 +26,7 @@ @php($avatarSize ??= 48) @php($center = 32) @php($endDot = 20) -@php($segment = fn (int|string $lane): string => $lane === \App\Devnull\Lane::Rail ? 'bg-theme-rail' : 'bg-theme-lane-'.$lane) +@php($segment = fn (int|string $lane): string => 'bg-theme-'.\App\Devnull\Lane::token($lane)) @if ($node === null) @if ($previous !== null) diff --git a/resources/views/native/partials/streaks.blade.php b/resources/views/native/partials/streaks.blade.php index b258bef..dfb1342 100644 --- a/resources/views/native/partials/streaks.blade.php +++ b/resources/views/native/partials/streaks.blade.php @@ -5,7 +5,7 @@ @if ($streaks->current > 0) · - + current {{ $streaks->current }} @endif diff --git a/resources/views/native/partials/thread-elbow.blade.php b/resources/views/native/partials/thread-elbow.blade.php index 13dca8c..db8ebbc 100644 --- a/resources/views/native/partials/thread-elbow.blade.php +++ b/resources/views/native/partials/thread-elbow.blade.php @@ -5,7 +5,7 @@ is placed along the way (see Display::elbowDashes), and the ones on the corner are turned to follow it, as in dashed-ring. Any corner radius makes EDGE clip a node to its shape, so the last dash is cut 1pt short of the avatar. --}} -@php($colour = $lane === \App\Devnull\Lane::Rail ? 'bg-theme-rail' : 'bg-theme-lane-'.$lane) +@php($colour = 'bg-theme-'.\App\Devnull\Lane::token($lane)) @php($from ??= 31.5) @php($to ??= 64) diff --git a/resources/views/native/partials/thread-reply.blade.php b/resources/views/native/partials/thread-reply.blade.php index de94cf2..a36b9c6 100644 --- a/resources/views/native/partials/thread-reply.blade.php +++ b/resources/views/native/partials/thread-reply.blade.php @@ -23,7 +23,7 @@ @php($spine ??= null) @php($tombstones ??= false) @php($more ??= null) -@php($segment = fn (int|string $lane): string => $lane === \App\Devnull\Lane::Rail ? 'bg-theme-rail' : 'bg-theme-lane-'.$lane) +@php($segment = fn (int|string $lane): string => 'bg-theme-'.\App\Devnull\Lane::token($lane)) @php($avatarLine = 76 + $indent) @if ($reply->isDeleted()) @php($opened = $tombstones && array_key_exists($reply->id, $tombstoneReplies ?? [])) diff --git a/resources/views/native/this-device.blade.php b/resources/views/native/this-device.blade.php index 4dce1d8..2879e9d 100644 --- a/resources/views/native/this-device.blade.php +++ b/resources/views/native/this-device.blade.php @@ -7,204 +7,211 @@ 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') - @include('native.partials.refresh-failed') - - @if ($user !== null) - - @include('native.partials.settings-heading', [ - 'title' => 'Profile information', - 'description' => 'Mirrored from GitHub when you sign in. To change any of it, change it on GitHub, then sync it here.', - ]) + + @include('native.partials.colour-sheet') + + + + @include('native.partials.error') + @include('native.partials.refresh-failed') + + @if ($user !== null) + + @include('native.partials.settings-heading', [ + 'title' => 'Profile information', + 'description' => 'Mirrored from GitHub when you sign in. To change any of it, change it on GitHub, then sync it here.', + ]) + + + @include('native.partials.rail-avatar', ['user' => $user, 'lane' => $savedLane, 'size' => 56]) + + {{ $user->name }} + {{ $user->handle() }} + + - - @include('native.partials.rail-avatar', ['user' => $user, 'lane' => $savedLane, 'size' => 56]) - - {{ $user->name }} - {{ $user->handle() }} + {{-- The web's hairline table: mono labels, and null in muted mono for what GitHub doesn't have. --}} + + @foreach ($fields as $field) + @if (! $loop->first) + + @endif + + {{ $field['label'] }} + @if ($field['value'] === null) + null + @else + {{ $field['value'] }} + @endif + + @endforeach - - {{-- The web's hairline table: mono labels, and null in muted mono for what GitHub doesn't have. --}} - - @foreach ($fields as $field) - @if (! $loop->first) - + + Edit on GitHub + {{-- The label stays put while it spins, so the button keeps its size. --}} + Sync from GitHub + {{ $syncedAt }} + @if ($syncError) + {{ $syncError }} @endif - - {{ $field['label'] }} - @if ($field['value'] === null) - null - @else - {{ $field['value'] }} - @endif - - @endforeach + - - Edit on GitHub - {{-- The label stays put while it spins, so the button keeps its size. --}} - Sync from GitHub - {{ $syncedAt }} - @if ($syncError) - {{ $syncError }} - @endif - - + + @include('native.partials.settings-heading', [ + 'title' => 'Color', + 'description' => 'Your lane on the graph: the rail and nodes beside your commits, the ring around your avatar, and the dot before your handle. Automatic picks one from your account id.', + ]) + + {{-- You on the rail in the colour picked, as the web's preview draws you. --}} + + + + {{ $user->name }} + + + {{ $user->handle() }} + + + @include('native.partials.rail', ['previous' => $previewLane, 'tail' => $previewLane, 'node' => 'avatar', 'user' => $user, 'lane' => $previewLane]) + - - @include('native.partials.settings-heading', [ - 'title' => 'Color', - 'description' => 'Your lane on the graph: the rail and nodes beside your commits, the ring around your avatar, and the dot before your handle. Automatic picks one from your account id.', - ]) + {{-- + Automatic, the six lanes, then Custom. The one picked wears a ring in its colour. Automatic is a dashed + ring round a dot, in the lane it gives you. Custom is your custom colour, or a spectrum until you mix + one, and opens the sheet that mixes it. No press-* feedback: it would keep the screen from scrolling. + --}} + + @foreach ($lanes as $option) + + + + @if ($option['auto']) + + @include('native.partials.dashed-ring', ['size' => 28, 'colour' => 'bg-theme-'.Lane::token($option['lane'])]) + + + @elseif ($option['lane'] === null) + + @else + + @endif + + {{ $option['label'] }} + + + @endforeach + - {{-- You on the rail in the colour picked, as the web's preview draws you. --}} - - - - {{ $user->name }} - - - {{ $user->handle() }} - + {{-- In the colour picked, so you see it on a button before saving it. --}} + + Save color + @if ($laneError) + {{ $laneError }} + @endif - @include('native.partials.rail', ['previous' => $previewLane, 'tail' => $previewLane, 'node' => 'avatar', 'user' => $user, 'lane' => $previewLane]) - + {{-- - Automatic, then the six lanes. The one picked wears a ring in its colour. Automatic is a dashed ring - round a dot, in the lane it gives you. No press-* feedback: it would keep the screen from scrolling. + The web's pastéis mode section, word for word: its heading and two lines, with the switch beside them. + The switch is the system's own (a UISwitch on iOS), which takes taps since NativePHP/mobile-air#545. --}} - - @foreach ($lanes as $option) - - - - @if ($option['auto']) - - @include('native.partials.dashed-ring', ['size' => 28, 'colour' => 'bg-theme-lane-'.$option['lane']]) - - - @else - - @endif - - {{ $option['label'] }} - - - @endforeach - - - {{-- In the colour picked, so you see it on a button before saving it. --}} - - Save color - @if ($laneError) - {{ $laneError }} + + + + Pastéis mode + A little appreciation, fresh daily. Get 10 pastéis to give to other people’s commits, one per click. Your allowance resets at 00:00 UTC. + When off, all pastéis controls and counters are hidden. You still receive pastéis from others. + + + + @if ($pasteisModeError) + {{ $pasteisModeError }} @endif + @endif + + + @include('native.partials.settings-heading', [ + 'title' => 'Safety', + 'description' => 'Everyone you mute or block, and a way to undo it.', + ]) + + + Muted and blocked people + - {{-- - The web's pastéis mode section, word for word: its heading and two lines, with the switch beside them. - The switch is the system's own (a UISwitch on iOS), which takes taps since NativePHP/mobile-air#545. - --}} - - - - Pastéis mode - A little appreciation, fresh daily. Get 10 pastéis to give to other people’s commits, one per click. Your allowance resets at 00:00 UTC. - When off, all pastéis controls and counters are hidden. You still receive pastéis from others. + @if ($offersApple) + + @include('native.partials.settings-heading', ['title' => 'Apple ID']) + + + + apple id + {{ $appleStatus }} + - - - @if ($pasteisModeError) - {{ $pasteisModeError }} - @endif - - @endif - - @include('native.partials.settings-heading', [ - 'title' => 'Safety', - 'description' => 'Everyone you mute or block, and a way to undo it.', - ]) + + A quicker way back in on a new phone. The account is still your GitHub one. + - - Muted and blocked people - - + @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 + + @elseif ($appleLinked === true) + + Unlink Apple ID + + @endif + + @endif - @if ($offersApple) - - @include('native.partials.settings-heading', ['title' => 'Apple ID']) + + @include('native.partials.settings-heading', [ + 'title' => 'This phone', + 'description' => 'Signed in with a key that stays on this phone. dev-null.sh lists it by this fingerprint.', + ]) + {{-- dev-null.sh lists each phone by name and fingerprint, so this is how you tell which one is this phone. --}} - - apple id - {{ $appleStatus }} + + fingerprint + {{ $fingerprint ?: 'null' }} + @if ($remote !== null) + + + remote + {{ $remote }} + + @endif - - A quicker way back in on a new phone. The account is still your GitHub one. + + Your devices on dev-null.sh - - @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 - - @elseif ($appleLinked === true) - - Unlink Apple ID - - @endif - - @endif - - - @include('native.partials.settings-heading', [ - 'title' => 'This phone', - 'description' => 'Signed in with a key that stays on this phone. dev-null.sh lists it by this fingerprint.', - ]) - - {{-- dev-null.sh lists each phone by name and fingerprint, so this is how you tell which one is this phone. --}} - - - fingerprint - {{ $fingerprint ?: 'null' }} - - @if ($remote !== null) - - - remote - {{ $remote }} - - @endif - - Your devices on dev-null.sh + + Sign out + Deletes the key from this phone and signs you out. - - - Sign out - Deletes the key from this phone and signs you out. - - - - @include('native.partials.settings-heading', [ - 'title' => 'Delete account', - 'description' => "Removes your /dev/null account, every commit and reply you've made, and everyone you follow. Replies others left under your commits go too. Replies under your replies stay, hanging off a [deleted] tombstone. Your GitHub account is not touched.", - ]) + + @include('native.partials.settings-heading', [ + 'title' => 'Delete account', + 'description' => "Removes your /dev/null account, every commit and reply you've made, and everyone you follow. Replies others left under your commits go too. Replies under your replies stay, hanging off a [deleted] tombstone. Your GitHub account is not touched.", + ]) - - Delete account + + Delete account + - - + + diff --git a/tests/Feature/Screens/AccountColorTest.php b/tests/Feature/Screens/AccountColorTest.php index 4b693c9..e8b44b7 100644 --- a/tests/Feature/Screens/AccountColorTest.php +++ b/tests/Feature/Screens/AccountColorTest.php @@ -60,12 +60,12 @@ function laneSaves(): array ->all(); } -it('offers Automatic and the six lanes by name, with the one you have marked', function () { +it('offers Automatic, the six lanes by name and Custom, with the one you have marked', function () { serveApi(['GET /api/v1/me' => [currentUserJson(['lane' => 2, 'preferred_lane' => 2, 'automatic_lane' => 4])]]); $screen = Native::visit('/profile/device')->assertSet('chosenLane', 2); - expect(laneLabels($screen))->toBe(['Automatic (rose)', 'Orange', 'Green', 'Violet, selected', 'Teal', 'Rose', 'Ochre']) + expect(laneLabels($screen))->toBe(['Automatic (rose)', 'Orange', 'Green', 'Violet, selected', 'Teal', 'Rose', 'Ochre', 'Custom color']) ->and(previewColour($screen))->toBe(laneColour(2)) ->and(nodeByRef($screen, 'save-lane')['props'])->toMatchArray(['label' => 'Save color', 'disabled' => true]); @@ -208,3 +208,72 @@ function laneSaves(): array finishRefreshes(Native::visit('/profile/device'))->assertSet('chosenLane', 2); }); + +it('offers your custom colour as Custom, marked and drawn in it', function () { + serveApi(['GET /api/v1/me' => [currentUserJson(['lane' => '#1a2b3c', 'preferred_lane' => '#1a2b3c', 'automatic_lane' => 4])]]); + + $screen = Native::visit('/profile/device')->assertSet('chosenLane', '#1a2b3c'); + + expect(laneLabels($screen)[7])->toBe('Custom color (#1a2b3c), selected') + ->and(previewColour($screen))->toBe('#1a2b3c') + ->and(tabAccent($screen))->toBe('#1a2b3c') + ->and(strtolower(findNode(nodeByRef($screen, 'lane-custom'), fn (array $node): bool => ($node['type'] ?? null) === 'stack')['style']['border_color']))->toBe('#1a2b3c'); + + $screen->assertMissingElement('column', fn (array $node): bool => ($node['ref'] ?? null) === 'custom-spectrum'); +}); + +it('mixes a custom colour in a sheet, previews it, then saves it as the web\'s colour picker does', function () { + serveApi([ + 'GET /api/v1/me' => [currentUserJson(['lane' => 3, 'preferred_lane' => null, 'automatic_lane' => 3])], + 'PUT /api/v1/me/lane' => [currentUserJson(['lane' => '#33cc33', 'preferred_lane' => '#33cc33', 'automatic_lane' => 3])], + ]); + + // Until you have one, Custom is a spectrum, and it opens on the colour you're previewing. + $screen = Native::visit('/profile/device') + ->assertElement('column', fn (array $node): bool => ($node['ref'] ?? null) === 'custom-spectrum') + ->tap('lane-1') + ->tap('lane-custom') + ->assertSet('mixingColour', true) + ->assertSet('mixedColour', laneColour(1)) + ->assertSet('chosenLane', 1); + + expect(nodeByRef($screen, 'colour-sheet')['props']['visible'])->toBeTrue(); + + $screen->slide('hue', 120)->slide('saturation', 60)->slide('lightness', 50)->assertSet('mixedColour', '#33cc33'); + + expect(strtolower(nodeByRef($screen, 'mixed-swatch')['style']['bg_color']))->toBe('#33cc33') + ->and(nodeByRef($screen, 'hue-value')['props']['text'])->toBe('120') + ->and(laneSaves())->toBe([]); + + $screen->tap('use-colour') + ->assertSet('mixingColour', false) + ->assertSet('chosenLane', '#33cc33'); + + expect(laneLabels($screen)[7])->toBe('Custom color (#33cc33), selected') + ->and(previewColour($screen))->toBe('#33cc33') + ->and(tabAccent($screen))->toBe(laneColour(3)); + + $screen->tap('save-lane')->assertSet('laneError', null); + + expect(laneSaves())->toBe(['{"lane":null,"color":"#33cc33"}']) + ->and(app(Account::class)->user()?->lane())->toBe('#33cc33') + ->and(tabAccent($screen))->toBe('#33cc33'); +}); + +it('opens the mixer on your custom colour exactly, and changes nothing when you close it', function () { + serveApi(['GET /api/v1/me' => [currentUserJson(['lane' => '#1a2b3c', 'preferred_lane' => '#1a2b3c', 'automatic_lane' => 4])]]); + + $screen = Native::visit('/profile/device') + ->tap('lane-2') + ->tap('lane-custom') + ->assertSet('mixedColour', '#1a2b3c') + ->assertSet('hue', 210.0) + ->assertSet('saturation', 40.0) + ->assertSet('lightness', 17.0) + ->slide('lightness', 80) + ->dismissSheet('colour-sheet') + ->assertSet('mixingColour', false) + ->assertSet('chosenLane', 2); + + expect(laneSaves())->toBe([]); +}); diff --git a/tests/Feature/Screens/CustomColoursTest.php b/tests/Feature/Screens/CustomColoursTest.php new file mode 100644 index 0000000..087e90d --- /dev/null +++ b/tests/Feature/Screens/CustomColoursTest.php @@ -0,0 +1,84 @@ + 'http://devnull.test']); +}); + +/** + * Every node drawn in this colour, as its fill, text or border, in light mode, + * and what each draws in dark mode. + * + * @return list + */ +function drawnIn(TestableComponent $screen, string $colour): array +{ + $found = []; + + foreach (findNodes($screen->tree(), fn (array $node): bool => true) as $node) { + foreach (['bg_color', 'color', 'border_color'] as $key) { + if (strtolower((string) ($node['style'][$key] ?? '')) === $colour) { + $found[] = ['light' => $colour, 'dark' => isset($node['style']["dark_{$key}"]) ? strtolower($node['style']["dark_{$key}"]) : null]; + } + } + } + + return $found; +} + +it('adds a custom colour to the theme the first time it is drawn, the same in light and dark, with a label that reads on it', function (string $colour, string $lightLabel, string $darkLabel) { + $token = Lane::token($colour); + + expect(strtolower((string) Theme::get("light.{$token}")))->toBe($colour) + ->and(strtolower((string) Theme::get("dark.{$token}")))->toBe($colour) + ->and(strtolower((string) Theme::get("light.on-{$token}")))->toBe($lightLabel) + ->and(strtolower((string) Theme::get("dark.on-{$token}")))->toBe($darkLabel) + ->and(Lane::token($colour))->toBe($token); +})->with([ + 'a dark colour, in white' => ['#1a2b3c', '#ffffff', '#ffffff'], + 'a pale colour, in ink' => ['#fff3bf', '#121417', '#111112'], +]); + +it('draws someone who picked a custom colour on the web in it, on the rail beside their commits', function () { + signIn(); + serveApi(fn (Request $request) => Http::response(pageJson([ + commitJson(['id' => 'c1', 'author' => userJson(['id' => 'u2', 'username' => 'ada', 'name' => 'Ada', 'lane' => '#1a2b3c'])]), + ]))); + + $drawn = drawnIn(Native::visit('/log'), '#1a2b3c'); + + // Their avatar's ring and the rail under it, the same in dark mode, as on the web. + expect($drawn)->not->toBeEmpty() + ->and(array_unique(array_column($drawn, 'dark')))->toBe([null]); +}); + +it('draws a profile in its custom colour', function () { + signIn(); + serveApi([ + 'GET /api/v1/users/ada' => [['data' => userWithCountsJson(['id' => 'u2', 'username' => 'ada', 'name' => 'Ada', 'lane' => '#c0ffee']), 'is_following' => false]], + 'GET /api/v1/users/ada/commits' => [pageJson([])], + ]); + + $screen = Native::visit('/log/users/ada'); + $ring = findNode($screen->tree(), fn (array $node): bool => ($node['type'] ?? null) === 'stack' && (float) ($node['layout']['width'] ?? 0) === 92.0); + + expect(strtolower($ring['style']['bg_color']))->toBe('#c0ffee'); +}); + +it('wears your custom colour on the tab bar and the main buttons', function () { + signIn(['lane' => '#1a2b3c']); + serveApi(fn (Request $request) => Http::response(pageJson([]))); + + $screen = Native::visit('/log', platform: 'ios')->press('composeInSheet'); + $commit = array_map(fn (mixed $value): mixed => is_string($value) ? strtolower($value) : $value, nodeByRef($screen, 'sheet-commit')['props']); + + expect(strtolower(findNode($screen->tree(), fn (array $node): bool => isset($node['props']['active_color']))['props']['active_color']))->toBe('#1a2b3c') + ->and($commit)->toMatchArray(['variant' => 'lane-1a2b3c', 'tint_color' => '#1a2b3c', 'label_color' => '#ffffff']) + ->and($commit)->not->toHaveKey('dark_tint_color'); +}); diff --git a/tests/Unit/Devnull/HslTest.php b/tests/Unit/Devnull/HslTest.php new file mode 100644 index 0000000..170605c --- /dev/null +++ b/tests/Unit/Devnull/HslTest.php @@ -0,0 +1,36 @@ +hue, $hsl->saturation, $hsl->lightness])->toBe([$hue, $saturation, $lightness]); +})->with([ + ['#ff0000', 0.0, 100.0, 50.0], + ['#00ff00', 120.0, 100.0, 50.0], + ['#0000ff', 240.0, 100.0, 50.0], + ['#ff00ff', 300.0, 100.0, 50.0], + ['#1a2b3c', 210.0, 40.0, 17.0], + ['#ffffff', 0.0, 0.0, 100.0], + ['#000000', 0.0, 0.0, 0.0], +]); + +it('mixes a colour as lowercase #rrggbb', function (float $hue, float $saturation, float $lightness, string $hex) { + expect((new Hsl($hue, $saturation, $lightness))->hex())->toBe($hex); +})->with([ + [0, 100, 50, '#ff0000'], + [60, 100, 50, '#ffff00'], + [120, 60, 50, '#33cc33'], + [180, 100, 25, '#008080'], + [240, 100, 50, '#0000ff'], + [300, 100, 50, '#ff00ff'], + [210, 40, 17, '#1a2b3d'], + [0, 0, 100, '#ffffff'], +]); + +it('holds each value to its range', function () { + expect((new Hsl(360, 150, 50))->hex())->toBe('#ff0000') + ->and((new Hsl(-120, 100, 50))->hex())->toBe('#0000ff') + ->and((new Hsl(0, -10, 120))->hex())->toBe('#ffffff'); +}); diff --git a/tests/Unit/Devnull/LaneTest.php b/tests/Unit/Devnull/LaneTest.php index b65f622..8de71a7 100644 --- a/tests/Unit/Devnull/LaneTest.php +++ b/tests/Unit/Devnull/LaneTest.php @@ -15,25 +15,42 @@ [str_repeat('a', 40), 4], ]); -it('reads the lane the API sends', function (mixed $value, ?int $lane) { +it('reads the lane the API sends, a palette lane or a custom colour', function (mixed $value, int|string|null $lane) { expect(Lane::from($value))->toBe($lane); })->with([ [0, 0], [5, 5], ['3', 3], + ['#1a2b3c', '#1a2b3c'], + ['#1A2B3C', '#1a2b3c'], [6, null], [-1, null], [null, null], ['blue', null], + ['#fff', null], + ['1a2b3c', null], + ['#1a2b3c80', 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) { +it('only takes the palette where only a palette lane will do', function () { + expect(Lane::palette(4))->toBe(4) + ->and(Lane::palette('#1a2b3c'))->toBeNull() + ->and(Lane::isCustom('#1a2b3c'))->toBeTrue() + ->and(Lane::isCustom(3))->toBeFalse() + ->and(Lane::isCustom(Lane::Rail))->toBeFalse(); +}); + +it('names the theme token a lane is drawn in, Commit Blue before devnull says who you are', function (int|string|null $lane, string $token) { expect(Lane::token($lane))->toBe($token); })->with([ [0, 'lane-0'], [5, 'lane-5'], [null, 'primary'], + [Lane::Rail, 'rail'], + ['#1a2b3c', 'lane-1a2b3c'], + ['#1A2B3C', 'lane-1a2b3c'], + ['blue', 'primary'], ]); /** From 6fd49443cdd1eb26990cbbefad173df71ed9378a Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Mon, 19 Oct 2026 10:28:36 +0100 Subject: [PATCH 2/2] Follow the drag with the custom colour swatch The sliders were debounced, and iOS only sends a debounced slider once the finger rests, so the swatch didn't change until you stopped dragging. They now send every step. Co-Authored-By: Claude Opus 5.5 --- .../views/native/partials/colour-sheet.blade.php | 11 ++++++----- tests/Feature/Screens/AccountColorTest.php | 4 +++- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/resources/views/native/partials/colour-sheet.blade.php b/resources/views/native/partials/colour-sheet.blade.php index b905754..05ebfaa 100644 --- a/resources/views/native/partials/colour-sheet.blade.php +++ b/resources/views/native/partials/colour-sheet.blade.php @@ -3,8 +3,9 @@ swatch of the colour being mixed and its hex, then a slider each for hue, saturation and lightness, and Use color, which previews it on the screen behind for Save color to save, like any other colour. - The swatch is drawn in the colour itself rather than a lane's token, so dragging adds nothing to the theme. Only the - colour you use becomes a token, when the screen behind draws it. + The sliders send every step, not once the finger rests, so the swatch follows the drag. It's drawn in the colour + itself rather than a lane's token, so dragging adds nothing to the theme. Only the colour you use becomes a token, + when the screen behind draws it. --}} @@ -20,7 +21,7 @@ Hue {{ (int) $hue }} - + @@ -28,7 +29,7 @@ Saturation {{ (int) $saturation }} - + @@ -36,7 +37,7 @@ Lightness {{ (int) $lightness }} - + Use color diff --git a/tests/Feature/Screens/AccountColorTest.php b/tests/Feature/Screens/AccountColorTest.php index e8b44b7..2956ce6 100644 --- a/tests/Feature/Screens/AccountColorTest.php +++ b/tests/Feature/Screens/AccountColorTest.php @@ -237,7 +237,9 @@ function laneSaves(): array ->assertSet('mixedColour', laneColour(1)) ->assertSet('chosenLane', 1); - expect(nodeByRef($screen, 'colour-sheet')['props']['visible'])->toBeTrue(); + // Each slider sends every step as it's dragged, so the swatch follows your finger rather than waiting for it to rest. + expect(nodeByRef($screen, 'colour-sheet')['props']['visible'])->toBeTrue() + ->and(array_map(fn (string $ref): string => nodeByRef($screen, $ref)['props']['sync_mode'], ['hue', 'saturation', 'lightness']))->toBe(['live', 'live', 'live']); $screen->slide('hue', 120)->slide('saturation', 60)->slide('lightness', 50)->assertSet('mixedColour', '#33cc33');