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..05ebfaa --- /dev/null +++ b/resources/views/native/partials/colour-sheet.blade.php @@ -0,0 +1,45 @@ +{{-- + 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 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. +--}} + + + 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..2956ce6 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,74 @@ 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); + + // 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'); + + 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'], ]); /**