Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions app/Devnull/Data/CurrentUser.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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,
Expand All @@ -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(
Expand Down
11 changes: 6 additions & 5 deletions app/Devnull/Data/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
{
Expand All @@ -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);
}
Expand Down
70 changes: 70 additions & 0 deletions app/Devnull/Hsl.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<?php

namespace App\Devnull;

/**
* A colour as hue (0 to 360), saturation and lightness (0 to 100), which is
* how the Account screen's custom colour sliders mix one. devnull takes it as
* lowercase `#rrggbb`.
*/
final readonly class Hsl
{
public function __construct(
public float $hue,
public float $saturation,
public float $lightness,
) {}

/**
* The hue, saturation and lightness of a `#rrggbb` colour, rounded to
* whole steps as the sliders move.
*/
public static function fromHex(string $hex): self
{
[$red, $green, $blue] = array_map(fn (string $pair): float => 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]));
}
}
105 changes: 95 additions & 10 deletions app/Devnull/Lane.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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;
Expand All @@ -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
Expand All @@ -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.
*/
Expand Down
4 changes: 2 additions & 2 deletions app/NativeComponents/Layouts/TabsLayout.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
Loading
Loading