feat(appearance): the pl mark drops Google's colours and becomes a choice
The topbar monogram wore Google's literal brand hexes, which is why it read as someone else's product. The mark is redrawn — shared top and bottom lines, a square-ish bowl, a tighter tail turn, ink centred in its frame — and its geometry now lives in exactly one place, a Twig macro that the topbar, the settings tiles and the favicon all draw through. The paint is a per-user setting: thirty-two colourways in LogoStyle (single colours, duotones, ink with one coloured flick, and sweeps), picked under Settings → Appearance → Logo, applied to the topbar live and saved through the same pipeline as every other appearance knob. Berry — the product's own sweep — is the default every account starts on. The favicon follows the person now, so it is a route rather than a file: /branding/favicon.svg answers in the viewer's colourway, revalidated by an ETag that IS the style value, and anonymously it serves the product default. The static icon set (favicon.svg, the PWA PNGs, apple-touch) is repainted in Berry as the sessionless fallback, and the manifest and theme-color meta follow its tile. Two colourways from the design board are deliberately absent: the Google original, and the four-distinct-hues restatement of it. A test pins both out, along with the seven-strokes-per-style contract the macro depends on. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
No files matched your search
@@ -8,7 +8,7 @@ export default class extends Controller {
|
||||
resetUrl: String,
|
||||
};
|
||||
|
||||
static targets = ['paneAlpha', 'paneBlur', 'radius', 'scrimAlpha', 'accent', 'theme', 'importInput', 'uploadInput',
|
||||
static targets = ['paneAlpha', 'paneBlur', 'radius', 'scrimAlpha', 'accent', 'theme', 'logoStyle', 'importInput', 'uploadInput',
|
||||
'inkColor', 'inkColorField', 'inkDefault', 'inkDerived', 'inkMuted', 'inkMutedField', 'inkFaint', 'inkFaintField',
|
||||
'mainTint', 'mainTintField', 'mainTintDefault', 'mainAlpha', 'mainAlphaField', 'mainAlphaMatch',
|
||||
'backgroundSolid', 'backgroundSolidSwatch',
|
||||
@@ -280,6 +280,41 @@ export default class extends Controller {
|
||||
this.queue();
|
||||
}
|
||||
|
||||
/**
|
||||
* A logo colourway tile was clicked.
|
||||
*
|
||||
* The repaint targets only the marks that FOLLOW the setting —
|
||||
* `[data-logo-live]`, today the topbar's — because the tiles beside this
|
||||
* one each preview their OWN style and must not be painted over by the
|
||||
* choice they offer. The strokes travel on the tile as two JSON lists
|
||||
* (light and dark chrome), indexed the way _logo_mark.html.twig draws,
|
||||
* so this stays geometry-blind: whichever chrome is on decides which
|
||||
* list is painted, stroke by stroke.
|
||||
*/
|
||||
pickLogo(event) {
|
||||
const tile = event.currentTarget;
|
||||
const style = tile.dataset.logoName;
|
||||
|
||||
this.logoStyleTarget.value = style;
|
||||
|
||||
this.element.querySelectorAll('[data-logo-name]').forEach((button) => {
|
||||
button.classList.toggle('ring-2', button.dataset.logoName === style);
|
||||
button.classList.toggle('ring-accent', button.dataset.logoName === style);
|
||||
});
|
||||
|
||||
const strokes = JSON.parse(
|
||||
this.root.classList.contains('dark')
|
||||
? tile.dataset.logoStrokesDark
|
||||
: tile.dataset.logoStrokes,
|
||||
);
|
||||
|
||||
document.querySelectorAll('[data-logo-live] [data-logo-stroke]').forEach((stroke) => {
|
||||
stroke.setAttribute('stroke', strokes[Number(stroke.dataset.logoStroke)]);
|
||||
});
|
||||
|
||||
this.queue();
|
||||
}
|
||||
|
||||
/*
|
||||
* Whether this theme paints dark right now.
|
||||
*
|
||||
|
||||
@@ -85,6 +85,12 @@ security:
|
||||
- { path: ^/jmap, roles: IS_AUTHENTICATED }
|
||||
- { path: ^/admin, roles: ROLE_ADMIN }
|
||||
- { path: ^/login, roles: PUBLIC_ACCESS }
|
||||
# The tab icon. Browsers fetch it for the login page too — before any
|
||||
# session exists — and an icon behind the login is a redirect the tab
|
||||
# strip renders as a missing favicon. Anonymous callers get the
|
||||
# product-default colourway; nothing user-specific leaves without a
|
||||
# session. See App\Controller\BrandingController.
|
||||
- { path: ^/branding/favicon\.svg$, roles: PUBLIC_ACCESS }
|
||||
# Device pairing, by necessity: a device that could authenticate here
|
||||
# would not need to pair. The gate is the code itself — 32 bytes of
|
||||
# CSPRNG, valid two minutes, burned on first use — see
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- translated-from: features/appearance.md sha1:61e233ebde0b00ad4eb0b350d5eaf44735b85891 -->
|
||||
<!-- translated-from: features/appearance.md sha1:ac2a6d5a3594f7ec06584fff2566c1a226a25cba -->
|
||||
|
||||
# Darstellung
|
||||
|
||||
@@ -27,6 +27,16 @@ eigene Karte schweben. Eines davon zu wählen setzt die Regler darunter auf die
|
||||
Layouts, damit die beiden nie einen Zustand beschreiben, den keines der Layouts hervorbringen
|
||||
würde. Flach ist die Vorgabe.
|
||||
|
||||
## Das Logo
|
||||
|
||||
Das „pl"-Zeichen hat zweiunddreißig Farbstimmungen — einfarbige, Zweiklänge, Tinte mit einem
|
||||
farbigen Schwung und Verläufe, die über die Striche laufen — und unter **Logo** suchst du deine
|
||||
aus. Die Wahl folgt dir überallhin, wo das Zeichen auftaucht: Die Kopfleiste trägt sie sofort,
|
||||
und auch das Tab-Symbol wird in deiner Farbstimmung ausgeliefert, der Browser-Tab passt also zu
|
||||
der Seite, die er öffnet. Ein neues Konto beginnt mit **Beere**, dem hauseigenen Verlauf. Dunkle
|
||||
Themes bekommen von jeder Farbstimmung die Striche für dunkle Oberflächen — ein tintendunkles
|
||||
Zeichen verschwindet so nie in einer dunklen Kopfleiste.
|
||||
|
||||
## Die Live-Vorschau
|
||||
|
||||
Neben den Bedienelementen steht eine zweite Karte, die eine Beispiel-Seitenleiste, eine
|
||||
|
||||
@@ -22,6 +22,15 @@ and keeps only the main pane boxed; Boxed floats every panel as a card. Choosing
|
||||
sliders below it to that layout's own numbers, so the two never end up describing a state neither
|
||||
layout would produce. Flat is the default.
|
||||
|
||||
## The logo
|
||||
|
||||
The "pl" mark has thirty-two colourways — single colours, two-tone pairs, ink with one coloured
|
||||
flick, and gradients that sweep across the strokes — and **Logo** lets you pick yours. The choice
|
||||
follows you everywhere the mark appears: the top bar wears it at once, and the tab icon is served
|
||||
in your colourway too, so the browser tab matches the page it opens. A new account starts on
|
||||
**Berry**, the product's own sweep. Dark themes get each colourway's dark-chrome strokes, so an
|
||||
ink-dark mark never disappears into a dark top bar.
|
||||
|
||||
## The live preview
|
||||
|
||||
Beside the controls sits a second card showing a sample sidebar, message list and reading pane in
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* The mark's colourway becomes a user choice.
|
||||
*
|
||||
* One column, defaulted to 'berry' — the new product default — so every
|
||||
* existing account wakes up wearing the new mark rather than a NULL. Additive
|
||||
* and boring on purpose: the thirty-two legal values live in
|
||||
* App\Domain\Enum\Theme\LogoStyle, and an unknown value in this column is
|
||||
* simply ignored by the reader (applyArray tryFroms and keeps the old value),
|
||||
* so no CHECK constraint tries to duplicate the enum here.
|
||||
*/
|
||||
final class Version20260817170000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return "Add user.appearance_logo_style so the pl mark's colourway is a per-user setting";
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE "user" ADD appearance_logo_style VARCHAR(16) DEFAULT \'berry\' NOT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE "user" DROP appearance_logo_style');
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 667 B After Width: | Height: | Size: 3.0 KiB |
@@ -1,15 +1,21 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="plMail">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" role="img" aria-label="plMail">
|
||||
<!--
|
||||
The same mark as icon-512.png and apple-touch-icon.png, drawn rather
|
||||
than resampled: a favicon is rendered at 16px, and an SVG is the only
|
||||
form of it that stays crisp at every size a browser and every pixel
|
||||
ratio a screen might ask for.
|
||||
The same mark as the PNG icons, drawn rather than resampled: a favicon
|
||||
is rendered at 16px, and only an SVG of the strokes themselves stays
|
||||
crisp at every size a browser and every pixel ratio a screen might ask
|
||||
for.
|
||||
|
||||
The blue is #2563eb, which is also the theme-color meta and what the
|
||||
PNG icons were exported from — one mark, one colour, three files that
|
||||
agree.
|
||||
The colourway is LogoStyle::Berry, the product default. Per-user
|
||||
favicons are served by the dynamic branding route — this file is the
|
||||
anonymous/fallback copy.
|
||||
-->
|
||||
<rect width="512" height="512" fill="#2563eb"/>
|
||||
<rect x="102" y="148" width="308" height="212" fill="#ffffff"/>
|
||||
<path d="M102 148 L256 288 L410 148" fill="none" stroke="#2563eb" stroke-width="22"/>
|
||||
<g transform="translate(-4.5 -0.5)" fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="5.5">
|
||||
<line x1="13" y1="11" x2="13" y2="42" stroke="#6d28d9"/>
|
||||
<path d="M13 11 Q13 7 17 7 L23 7 Q29 7 29 13" stroke="#7c3aed"/>
|
||||
<line x1="29" y1="13" x2="29" y2="22" stroke="#a21caf"/>
|
||||
<path d="M29 22 Q29 26 23 26 L17 26 Q13 26 13 22" stroke="#c026d3"/>
|
||||
<line x1="37" y1="7" x2="37" y2="26" stroke="#db2777"/>
|
||||
<line x1="37" y1="26" x2="37" y2="37" stroke="#e11d48"/>
|
||||
<path d="M37 37 Q37 42 42 42 L44 42" stroke="#f43f5e"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 770 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 699 B After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 7.3 KiB |
@@ -5,8 +5,8 @@
|
||||
"start_url": "/mail/inbox",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#2563eb",
|
||||
"theme_color": "#2563eb",
|
||||
"background_color": "#faf7f2",
|
||||
"theme_color": "#a21caf",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-192.png",
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Domain\Enum\Theme\LogoStyle;
|
||||
use App\Entity\User\User;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
/**
|
||||
* The mark, served in the colourway the viewer chose.
|
||||
*
|
||||
* A favicon is a static file for a product whose mark has one face; plMail's
|
||||
* has thirty-two now (Settings → Appearance → Logo), and a <link> can only
|
||||
* point somewhere — so it points here, and here answers with the SVG painted
|
||||
* for whoever is asking. Anonymous requests get the product default, which is
|
||||
* also what public/icons/favicon.svg holds: the static file stays as the
|
||||
* fallback for anything that will not send a cookie, and this route is why
|
||||
* the two can never disagree for a signed-in user.
|
||||
*
|
||||
* Cache-Control private: the answer depends on the session, and a shared
|
||||
* cache holding one user's berry for another's ink is exactly the bug a
|
||||
* favicon cannot visibly report. Revalidated by ETag rather than aged out —
|
||||
* the tag IS the style value, so an unchanged choice costs a 304 and a
|
||||
* changed one is visible on the very next page, instead of whenever a
|
||||
* max-age happened to expire.
|
||||
*/
|
||||
final class BrandingController extends AbstractController
|
||||
{
|
||||
#[Route('/branding/favicon.svg', name: 'app_branding_favicon')]
|
||||
public function favicon(Request $request): Response
|
||||
{
|
||||
$user = $this->getUser();
|
||||
|
||||
$style = $user instanceof User
|
||||
? $user->appearance->logoStyle
|
||||
: LogoStyle::DEFAULT;
|
||||
|
||||
$response = new Response(headers: [
|
||||
'Content-Type' => 'image/svg+xml',
|
||||
'Cache-Control' => 'private, no-cache',
|
||||
]);
|
||||
$response->setEtag($style->value);
|
||||
|
||||
if ($response->isNotModified($request)) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$response->setContent($this->renderView('branding/favicon.svg.twig', ['style' => $style]));
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Enum\Theme;
|
||||
|
||||
/**
|
||||
* The colourway of the "pl" mark — a user choice, like the theme beside it.
|
||||
*
|
||||
* The mark itself is seven strokes in a fixed draw order (see
|
||||
* _partials/_logo_mark.html.twig, the one place the geometry lives):
|
||||
* p-stem, p-top-curve, p-right-stem, p-bowl-bottom, l-upper, l-lower, l-tail.
|
||||
* A style is nothing but paint for those seven, in that order — one list for
|
||||
* light chrome and one for dark, because several styles draw in near-ink and
|
||||
* near-ink on a dark topbar is a mark that is not there.
|
||||
*
|
||||
* The catalogue is the design board's, minus two on purpose: the original
|
||||
* colourway was Google's literal brand hexes and is the reason this enum
|
||||
* exists, and the board's option F kept four-distinct-colours-per-stroke,
|
||||
* which read as that brand however the hues were shifted. Neither is a thing
|
||||
* a user should be able to put back.
|
||||
*
|
||||
* Everything reads the mark through this enum — the topbar, the settings
|
||||
* tiles, the favicon route — so a new style is one case here and nothing
|
||||
* anywhere else.
|
||||
*/
|
||||
enum LogoStyle: string
|
||||
{
|
||||
// ── Single colour ─────────────────────────────────────────────────────
|
||||
case ProductBlue = 'product-blue';
|
||||
case Ink = 'ink';
|
||||
case Slate = 'slate';
|
||||
case Forest = 'forest';
|
||||
case Burgundy = 'burgundy';
|
||||
case DeepViolet = 'deep-violet';
|
||||
|
||||
// ── Duotones: the p one colour, the l another ─────────────────────────
|
||||
case Postal = 'postal';
|
||||
case BronzeInk = 'bronze-ink';
|
||||
case PetrolCopper = 'petrol-copper';
|
||||
case MidnightGold = 'midnight-gold';
|
||||
case NavySky = 'navy-sky';
|
||||
case ForestLime = 'forest-lime';
|
||||
case PlumRose = 'plum-rose';
|
||||
case CharcoalAmber = 'charcoal-amber';
|
||||
case EspressoCream = 'espresso-cream';
|
||||
case CobaltCoral = 'cobalt-coral';
|
||||
case TealGold = 'teal-gold';
|
||||
case GraphiteRed = 'graphite-red';
|
||||
|
||||
// ── Ink with one gesture, and the deliberate multis ───────────────────
|
||||
case RedFlick = 'red-flick';
|
||||
case SkyFlick = 'sky-flick';
|
||||
case BronzeFlick = 'bronze-flick';
|
||||
case VioletFlick = 'violet-flick';
|
||||
case Tricolore = 'tricolore';
|
||||
|
||||
// ── Sweeps: one ramp across the strokes in draw order ─────────────────
|
||||
case IndigoViolet = 'indigo-violet';
|
||||
case Sunset = 'sunset';
|
||||
case Ocean = 'ocean';
|
||||
case Aurora = 'aurora';
|
||||
case Berry = 'berry';
|
||||
case Ember = 'ember';
|
||||
case Twilight = 'twilight';
|
||||
case Copper = 'copper';
|
||||
case BlueTonal = 'blue-tonal';
|
||||
|
||||
/**
|
||||
* Berry, because it was chosen, not because it is first. The sweep is the
|
||||
* default face of the product now, and the PNG icon set under
|
||||
* public/icons/ is exported in it — regenerate those if this ever moves.
|
||||
*/
|
||||
public const self DEFAULT = self::Berry;
|
||||
|
||||
/** Near-ink and its dark-chrome counterpart, shared by the flick styles. */
|
||||
private const string INK = '#2b2620';
|
||||
private const string PAPER = '#e8e2d9';
|
||||
|
||||
/**
|
||||
* The seven stroke colours, draw order, for light or dark chrome.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public function strokes(bool $dark = false): array
|
||||
{
|
||||
$palette = $this->palette();
|
||||
|
||||
return $dark ? ($palette['dark'] ?? $palette['light']) : $palette['light'];
|
||||
}
|
||||
|
||||
/**
|
||||
* The solid the PNG-style app icon uses behind a white mark, and what the
|
||||
* theme-color meta would honestly be for this style. One representative
|
||||
* colour per style, picked on the board.
|
||||
*/
|
||||
public function tile(): string
|
||||
{
|
||||
return $this->palette()['tile'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{light: list<string>, dark?: list<string>, tile: string}
|
||||
*/
|
||||
private function palette(): array
|
||||
{
|
||||
return match ($this) {
|
||||
self::ProductBlue => ['light' => self::mono('#2563eb'), 'tile' => '#2563eb'],
|
||||
self::Ink => ['light' => self::mono(self::INK), 'dark' => self::mono(self::PAPER), 'tile' => '#2b2620'],
|
||||
self::Slate => ['light' => self::mono('#475569'), 'dark' => self::mono('#94a3b8'), 'tile' => '#475569'],
|
||||
self::Forest => ['light' => self::mono('#166534'), 'dark' => self::mono('#4ade80'), 'tile' => '#166534'],
|
||||
self::Burgundy => ['light' => self::mono('#9f1239'), 'dark' => self::mono('#fb7185'), 'tile' => '#9f1239'],
|
||||
self::DeepViolet => ['light' => self::mono('#6d28d9'), 'dark' => self::mono('#a78bfa'), 'tile' => '#6d28d9'],
|
||||
|
||||
self::Postal => ['light' => self::duo('#1e3a6e', '#c8402f'), 'tile' => '#1e3a6e'],
|
||||
self::BronzeInk => ['light' => self::duo('#7d6b4f', '#3d372e'), 'dark' => self::duo('#a08a67', '#d8d0c2'), 'tile' => '#7d6b4f'],
|
||||
self::PetrolCopper => ['light' => self::duo('#0f766e', '#c2703d'), 'tile' => '#0f766e'],
|
||||
self::MidnightGold => ['light' => self::duo('#1e293b', '#d97706'), 'dark' => self::duo('#94a3b8', '#f59e0b'), 'tile' => '#1e293b'],
|
||||
self::NavySky => ['light' => self::duo('#1e3a8a', '#0ea5e9'), 'tile' => '#1e3a8a'],
|
||||
self::ForestLime => ['light' => self::duo('#166534', '#84cc16'), 'tile' => '#166534'],
|
||||
self::PlumRose => ['light' => self::duo('#86198f', '#fb7185'), 'tile' => '#86198f'],
|
||||
self::CharcoalAmber => ['light' => self::duo('#292524', '#f59e0b'), 'dark' => self::duo('#d6d3d1', '#f59e0b'), 'tile' => '#292524'],
|
||||
self::EspressoCream => ['light' => self::duo('#44403c', '#b8a385'), 'dark' => self::duo('#d6c7ae', '#8a7a5c'), 'tile' => '#44403c'],
|
||||
self::CobaltCoral => ['light' => self::duo('#1d4ed8', '#f87171'), 'tile' => '#1d4ed8'],
|
||||
self::TealGold => ['light' => self::duo('#0f766e', '#d97706'), 'tile' => '#0f766e'],
|
||||
self::GraphiteRed => ['light' => self::duo('#374151', '#dc2626'), 'dark' => self::duo('#9ca3af', '#f87171'), 'tile' => '#374151'],
|
||||
|
||||
self::RedFlick => ['light' => self::flick('#c8402f'), 'dark' => self::flickDark('#e85c4a'), 'tile' => '#c8402f'],
|
||||
self::SkyFlick => ['light' => self::flick('#0284c7'), 'dark' => self::flickDark('#38bdf8'), 'tile' => '#0284c7'],
|
||||
self::BronzeFlick => ['light' => self::flick('#7d6b4f'), 'dark' => self::flickDark('#a08a67'), 'tile' => '#7d6b4f'],
|
||||
self::VioletFlick => ['light' => self::flick('#8b5cf6'), 'dark' => self::flickDark('#a78bfa'), 'tile' => '#8b5cf6'],
|
||||
self::Tricolore => ['light' => ['#1e3a6e', '#c8402f', '#1e3a6e', '#c8402f', '#d4a017', '#d4a017', '#d4a017'], 'tile' => '#1e3a6e'],
|
||||
|
||||
self::IndigoViolet => ['light' => ['#4338ca', '#4f46e5', '#6366f1', '#7c3aed', '#8b5cf6', '#a855f7', '#c084fc'], 'tile' => '#4f46e5'],
|
||||
self::Sunset => ['light' => ['#e11d48', '#f43f5e', '#fb7185', '#f97316', '#fb923c', '#f59e0b', '#fbbf24'], 'tile' => '#f97316'],
|
||||
self::Ocean => ['light' => ['#1d4ed8', '#0369a1', '#0284c7', '#0891b2', '#0d9488', '#0f9f8f', '#14b8a6'], 'tile' => '#0e7490'],
|
||||
self::Aurora => ['light' => ['#16a34a', '#10b981', '#0d9488', '#0891b2', '#0284c7', '#2563eb', '#4f46e5'], 'tile' => '#0d9488'],
|
||||
self::Berry => ['light' => ['#6d28d9', '#7c3aed', '#a21caf', '#c026d3', '#db2777', '#e11d48', '#f43f5e'], 'tile' => '#a21caf'],
|
||||
self::Ember => ['light' => ['#991b1b', '#b91c1c', '#dc2626', '#ea580c', '#f97316', '#f59e0b', '#fbbf24'], 'tile' => '#dc2626'],
|
||||
self::Twilight => ['light' => ['#312e81', '#4338ca', '#6d28d9', '#9333ea', '#c026d3', '#db2777', '#f472b6'], 'tile' => '#6d28d9'],
|
||||
self::Copper => ['light' => ['#78350f', '#92400e', '#b45309', '#d97706', '#f59e0b', '#fbbf24', '#fcd34d'], 'tile' => '#b45309'],
|
||||
self::BlueTonal => ['light' => ['#1e3a8a', '#1e40af', '#1d4ed8', '#2563eb', '#3b82f6', '#60a5fa', '#7db4fa'], 'tile' => '#2563eb'],
|
||||
};
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private static function mono(string $hex): array
|
||||
{
|
||||
return array_fill(0, 7, $hex);
|
||||
}
|
||||
|
||||
/** The p in one colour, the l in the other. @return list<string> */
|
||||
private static function duo(string $p, string $l): array
|
||||
{
|
||||
return [$p, $p, $p, $p, $l, $l, $l];
|
||||
}
|
||||
|
||||
/** All ink except the l's tail. @return list<string> */
|
||||
private static function flick(string $tail): array
|
||||
{
|
||||
return [self::INK, self::INK, self::INK, self::INK, self::INK, self::INK, $tail];
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private static function flickDark(string $tail): array
|
||||
{
|
||||
return [self::PAPER, self::PAPER, self::PAPER, self::PAPER, self::PAPER, self::PAPER, $tail];
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ use App\Domain\Enum\Theme\BackgroundPreset;
|
||||
use App\Domain\Enum\Theme\Density;
|
||||
use App\Domain\Enum\Theme\FontFamily;
|
||||
use App\Domain\Enum\Theme\Layout;
|
||||
use App\Domain\Enum\Theme\LogoStyle;
|
||||
use App\Domain\Enum\Theme\Theme;
|
||||
use App\Domain\Enum\Theme\UnreadEmphasis;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
@@ -75,6 +76,15 @@ final class Appearance
|
||||
#[ORM\Column(type: 'string', length: 16, enumType: Layout::class, options: ['default' => 'flat'])]
|
||||
public Layout $layout = Layout::Flat;
|
||||
|
||||
/**
|
||||
* The mark's colourway — topbar and favicon both read it. Its own axis
|
||||
* beside the theme rather than derived from it: a theme says what the
|
||||
* chrome looks like, this says what the product's own mark looks like,
|
||||
* and either can change without the other's consent.
|
||||
*/
|
||||
#[ORM\Column(type: 'string', length: 16, enumType: LogoStyle::class, options: ['default' => 'berry'])]
|
||||
public LogoStyle $logoStyle = LogoStyle::DEFAULT;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 7, options: ['default' => self::DEFAULT_ACCENT])]
|
||||
public string $accent = self::DEFAULT_ACCENT {
|
||||
set {
|
||||
@@ -292,6 +302,7 @@ final class Appearance
|
||||
'version' => 1,
|
||||
'theme' => $this->theme->value,
|
||||
'layout' => $this->layout->value,
|
||||
'logoStyle' => $this->logoStyle->value,
|
||||
'accent' => $this->accent,
|
||||
'paneAlpha' => $this->paneAlpha,
|
||||
'paneBlur' => $this->paneBlur,
|
||||
@@ -332,6 +343,10 @@ final class Appearance
|
||||
$this->theme = Theme::tryFrom($data['theme']) ?? $this->theme;
|
||||
}
|
||||
|
||||
if (true === isset($data['logoStyle'])) {
|
||||
$this->logoStyle = LogoStyle::tryFrom((string) $data['logoStyle']) ?? $this->logoStyle;
|
||||
}
|
||||
|
||||
if (true === isset($data['accent'])) {
|
||||
$this->accent = $data['accent'];
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@
|
||||
and send it as X-CSRF-Token. Every such endpoint validates against the
|
||||
same 'ajax' id — until this tag existed they all sent an empty string. #}
|
||||
<meta name="csrf-token" content="{{ csrf_token('ajax') }}">
|
||||
<meta name="theme-color" content="#2563eb">
|
||||
<meta name="theme-color" content="#a21caf">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-title" content="plMail">
|
||||
|
||||
|
||||
@@ -16,6 +16,12 @@
|
||||
an SVG that stays sharp at 16px and any pixel ratio, and the 192px PNG for
|
||||
anything that will not take an SVG favicon. Browsers pick the first `rel`
|
||||
they understand, so the SVG is listed first.
|
||||
|
||||
The SVG comes from a route rather than a file, because the mark's
|
||||
colourway is a per-user setting now (Settings → Appearance → Logo) and a
|
||||
static file can only wear one. Anonymous pages get the product default
|
||||
from the same route; /icons/favicon.svg still exists, painted in that
|
||||
default, for anything that fetches icons without a session.
|
||||
#}
|
||||
<link rel="icon" type="image/svg+xml" href="/icons/favicon.svg">
|
||||
<link rel="icon" type="image/svg+xml" href="{{ path('app_branding_favicon') }}">
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/icons/icon-192.png">
|
||||
@@ -0,0 +1,38 @@
|
||||
{#
|
||||
The "pl" mark, geometry and nothing else — every colourway is a LogoStyle and
|
||||
every renderer of the mark goes through this macro, so the letters cannot
|
||||
drift between the topbar, the settings tiles and the favicon.
|
||||
|
||||
The shape, for whoever tunes it next (each fix was a reviewed round):
|
||||
- both letters start on one top line (y=7) and end on one bottom line
|
||||
(y=42): the p's stem descends to exactly where the l's tail rests;
|
||||
- the p's bowl ends high (y≈26) so the head reads square-ish, not tall;
|
||||
- the l runs straight down to y=37 before its tail turns on a tight radius
|
||||
— turned earlier, the column of ink under the l stopped visibly higher
|
||||
than the p's and the p read as deeper, bounding boxes notwithstanding;
|
||||
- the ink spans x 10.25–46.75 / y 4.25–44.75 raw, so the translate centres
|
||||
the DRAWING in the 48-frame; without it every icon tile leaned right.
|
||||
|
||||
Seven strokes, and their draw order is a contract: LogoStyle::strokes()
|
||||
returns paints for exactly this sequence, and the sweeps are built on it —
|
||||
reorder the strokes and every gradient breaks silently.
|
||||
|
||||
`data-logo-stroke` carries the index so the appearance page can repaint a
|
||||
mark live when a style is picked, without knowing the geometry. Only marks
|
||||
rendered with `live` opt into that repaint: the settings tiles each preview
|
||||
their OWN style and must not be painted over by the choice they offer.
|
||||
#}
|
||||
{% macro mark(style, size, dark = false, live = false) %}
|
||||
{% set c = style.strokes(dark) %}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="{{ size }}" height="{{ size }}" aria-hidden="true" data-logo-mark{% if live %} data-logo-live{% endif %}>
|
||||
<g transform="translate(-4.5 -0.5)" fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="5.5">
|
||||
<line x1="13" y1="11" x2="13" y2="42" stroke="{{ c[0] }}" data-logo-stroke="0"/>
|
||||
<path d="M13 11 Q13 7 17 7 L23 7 Q29 7 29 13" stroke="{{ c[1] }}" data-logo-stroke="1"/>
|
||||
<line x1="29" y1="13" x2="29" y2="22" stroke="{{ c[2] }}" data-logo-stroke="2"/>
|
||||
<path d="M29 22 Q29 26 23 26 L17 26 Q13 26 13 22" stroke="{{ c[3] }}" data-logo-stroke="3"/>
|
||||
<line x1="37" y1="7" x2="37" y2="26" stroke="{{ c[4] }}" data-logo-stroke="4"/>
|
||||
<line x1="37" y1="26" x2="37" y2="37" stroke="{{ c[5] }}" data-logo-stroke="5"/>
|
||||
<path d="M37 37 Q37 42 42 42 L44 42" stroke="{{ c[6] }}" data-logo-stroke="6"/>
|
||||
</g>
|
||||
</svg>
|
||||
{% endmacro %}
|
||||
@@ -20,22 +20,13 @@
|
||||
class="flex items-center gap-2 flex-shrink-0 md:w-52 no-underline"
|
||||
aria-label="{{ 'app.name'|trans }}"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="32" height="32" aria-hidden="true">
|
||||
<defs>
|
||||
<clipPath id="logo-clip">
|
||||
<rect width="48" height="48" rx="10"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<g clip-path="url(#logo-clip)" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="15" y1="10" x2="15" y2="38" stroke="#4285F4" stroke-width="5.5"/>
|
||||
<path d="M15 10 Q15 6 19 6 L25 6 Q31 6 31 12" stroke="#EA4335" stroke-width="5.5"/>
|
||||
<line x1="31" y1="12" x2="31" y2="26" stroke="#FBBC04" stroke-width="5.5"/>
|
||||
<path d="M31 26 Q31 30 25 30 L19 30 Q15 30 15 26" stroke="#34A853" stroke-width="5.5"/>
|
||||
<line x1="37" y1="7" x2="37" y2="26" stroke="#EA4335" stroke-width="5.5"/>
|
||||
<line x1="37" y1="26" x2="37" y2="35" stroke="#4285F4" stroke-width="5.5"/>
|
||||
<path d="M37 35 Q37 42 44 42" stroke="#FBBC04" stroke-width="5.5"/>
|
||||
</g>
|
||||
</svg>
|
||||
{# The mark in the user's chosen colourway (Settings → Appearance →
|
||||
Logo), dark strokes when the theme is dark. It used to be an inline
|
||||
SVG wearing Google's literal brand hexes; both the geometry and the
|
||||
paint live elsewhere now — see _logo_mark.html.twig and
|
||||
LogoStyle. #}
|
||||
{% import '_partials/_logo_mark.html.twig' as logo %}
|
||||
{{ logo.mark(appearance().logoStyle, 32, appearance().theme.isDark, true) }}
|
||||
<span class="hidden sm:inline font-semibold text-ink text-sm tracking-wide">{{ 'app.name'|trans }}</span>
|
||||
|
||||
{# Are live updates actually arriving? The failure is otherwise
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{# The tab icon, painted per user — see BrandingController. Geometry is the
|
||||
shared mark macro's, so the favicon and the topbar are the same drawing. #}
|
||||
{% import '_partials/_logo_mark.html.twig' as logo %}
|
||||
{{ logo.mark(style, 48) }}
|
||||
@@ -1,5 +1,6 @@
|
||||
{% import '_partials/_select.html.twig' as select %}
|
||||
{% import '_partials/_card_chrome.html.twig' as chrome %}
|
||||
{% import '_partials/_logo_mark.html.twig' as logo %}
|
||||
{% set a = appearance() %}
|
||||
|
||||
{# ── One shape for every segmented control ──────────────────────────────────
|
||||
@@ -296,6 +297,41 @@
|
||||
<input type="hidden" data-settings--appearance-target="theme" data-settings--appearance-field="theme" value="{{ a.theme.value }}">
|
||||
</section>
|
||||
|
||||
{# ── Logo ──────────────────────────────────────────────────────
|
||||
The mark's colourway — thirty-two of them, every direction the
|
||||
design board kept. The same tile grid as the themes above and
|
||||
for the same reason: the preview IS the choice, and auto-fill
|
||||
lets the card decide how many fit.
|
||||
|
||||
Each tile carries its stroke lists (light and dark chrome) as
|
||||
JSON, so pickLogo can repaint the topbar's live mark without
|
||||
the JS knowing what a stroke is — see _logo_mark.html.twig for
|
||||
the indexing contract. #}
|
||||
<section>
|
||||
<div class="mb-4">
|
||||
<h3 class="text-sm font-semibold text-ink">{{ 'settings.appearance.logo.heading'|trans }}</h3>
|
||||
<p class="text-xs text-ink-faint mt-0.5">{{ 'settings.appearance.logo.subheading'|trans }}</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-[repeat(auto-fill,minmax(5.5rem,1fr))] gap-3">
|
||||
{% for style in enum('App\\Domain\\Enum\\Theme\\LogoStyle').cases %}
|
||||
<button type="button"
|
||||
data-logo-name="{{ style.value }}"
|
||||
data-logo-strokes="{{ style.strokes(false)|json_encode }}"
|
||||
data-logo-strokes-dark="{{ style.strokes(true)|json_encode }}"
|
||||
data-action="click->settings--appearance#pickLogo"
|
||||
class="group flex flex-col items-center gap-2 p-2 rounded-pane cursor-pointer hover:bg-hover transition-colors
|
||||
{{ a.logoStyle == style ? 'ring-2 ring-accent' : '' }}">
|
||||
<span class="w-full h-10 rounded-lg border border-line flex items-center justify-center">
|
||||
{{ logo.mark(style, 30, a.theme.isDark) }}
|
||||
</span>
|
||||
<span class="text-xs text-ink-soft">{{ ('settings.appearance.logo.style.' ~ style.value)|trans }}</span>
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<input type="hidden" data-settings--appearance-target="logoStyle" data-settings--appearance-field="logoStyle" value="{{ a.logoStyle.value }}">
|
||||
</section>
|
||||
|
||||
{# ── Layout ────────────────────────────────────────────────────
|
||||
The second axis: the theme picks the palette, the layout picks how
|
||||
it is painted. Each option carries its own preset so the numbers
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Controller;
|
||||
|
||||
use App\Domain\Enum\Theme\LogoStyle;
|
||||
use App\Entity\User\User;
|
||||
use App\Repository\User\UserRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
|
||||
/**
|
||||
* The mark follows the person: topbar, tab icon and the setting that drives
|
||||
* both.
|
||||
*
|
||||
* The favicon is a ROUTE now, because a static file can wear exactly one
|
||||
* colourway and the colourway is a per-user choice. What is pinned here is
|
||||
* the whole loop: the setting round-trips through the appearance endpoint,
|
||||
* the favicon answers in the chosen palette, the topbar renders the same
|
||||
* strokes, and an anonymous request gets the product default rather than an
|
||||
* error or someone else's choice.
|
||||
*/
|
||||
final class BrandingFaviconTest extends WebTestCase
|
||||
{
|
||||
private const string ADMIN_EMAIL = 'e2e-admin@plmail.test';
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
// The colourway is a per-user preference with no fixture of its own —
|
||||
// put the seed user back on the default rather than leaking one
|
||||
// test's ink into the next suite's screenshots.
|
||||
if (null !== $user = $this->find()) {
|
||||
$user->appearance->logoStyle = LogoStyle::DEFAULT;
|
||||
static::getContainer()->get(EntityManagerInterface::class)->flush();
|
||||
}
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function testAnAnonymousRequestGetsTheDefaultColourway(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
$client->request('GET', '/branding/favicon.svg');
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
self::assertSame('image/svg+xml', $client->getResponse()->headers->get('Content-Type'));
|
||||
|
||||
$svg = (string) $client->getResponse()->getContent();
|
||||
|
||||
foreach (LogoStyle::DEFAULT->strokes() as $hex) {
|
||||
self::assertStringContainsString($hex, $svg);
|
||||
}
|
||||
}
|
||||
|
||||
public function testTheFaviconAnswersInTheUsersChosenColourway(): void
|
||||
{
|
||||
[$client, $user] = $this->signedIn();
|
||||
|
||||
$user->appearance->logoStyle = LogoStyle::Postal;
|
||||
static::getContainer()->get(EntityManagerInterface::class)->flush();
|
||||
|
||||
$client->request('GET', '/branding/favicon.svg');
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
$svg = (string) $client->getResponse()->getContent();
|
||||
|
||||
self::assertStringContainsString('#1e3a6e', $svg, "the postal navy 'p'");
|
||||
self::assertStringContainsString('#c8402f', $svg, "the postal red 'l'");
|
||||
self::assertStringNotContainsString('#a21caf', $svg, 'the default berry must not bleed through a choice');
|
||||
|
||||
// Private, because the answer depends on the session — a shared cache
|
||||
// serving one user's colourway to another is invisible until someone
|
||||
// wonders whose favicon they are looking at.
|
||||
self::assertStringContainsString('private', (string) $client->getResponse()->headers->get('Cache-Control'));
|
||||
}
|
||||
|
||||
public function testTheTopbarWearsTheSameStrokesTheFaviconDoes(): void
|
||||
{
|
||||
[$client, $user] = $this->signedIn();
|
||||
|
||||
$user->appearance->logoStyle = LogoStyle::MidnightGold;
|
||||
static::getContainer()->get(EntityManagerInterface::class)->flush();
|
||||
|
||||
$crawler = $client->request('GET', '/mail/inbox');
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
$mark = $crawler->filter('[data-logo-live] [data-logo-stroke]');
|
||||
|
||||
self::assertCount(7, $mark, 'the live mark draws exactly the seven contracted strokes');
|
||||
|
||||
$strokes = $mark->each(static fn ($node): string => (string) $node->attr('stroke'));
|
||||
|
||||
// The seed user's theme decides which chrome the topbar paints for,
|
||||
// so accept either list — what may not happen is a mix, or a palette
|
||||
// belonging to another style.
|
||||
self::assertContains(
|
||||
$strokes,
|
||||
[LogoStyle::MidnightGold->strokes(), LogoStyle::MidnightGold->strokes(true)],
|
||||
);
|
||||
}
|
||||
|
||||
public function testTheColourwayRoundTripsThroughTheAppearanceEndpoint(): void
|
||||
{
|
||||
[$client] = $this->signedIn();
|
||||
|
||||
$client->request(
|
||||
'POST',
|
||||
'/settings/appearance',
|
||||
server: ['CONTENT_TYPE' => 'application/json'],
|
||||
content: json_encode(['logoStyle' => 'ocean']),
|
||||
);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
self::assertSame(LogoStyle::Ocean, $this->reread()->appearance->logoStyle);
|
||||
|
||||
// An unknown value is ignored rather than defaulted: the payload is
|
||||
// browser-supplied, and "keep what I had" is the only answer that
|
||||
// cannot lose a choice to a typo in a client.
|
||||
$client->request(
|
||||
'POST',
|
||||
'/settings/appearance',
|
||||
server: ['CONTENT_TYPE' => 'application/json'],
|
||||
content: json_encode(['logoStyle' => 'chartreuse-dreams']),
|
||||
);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
self::assertSame(LogoStyle::Ocean, $this->reread()->appearance->logoStyle);
|
||||
}
|
||||
|
||||
/** @return array{KernelBrowser, User} */
|
||||
private function signedIn(): array
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
$user = $this->find();
|
||||
|
||||
if (null === $user) {
|
||||
self::markTestSkipped('run `app:test:seed-user --admin` first');
|
||||
}
|
||||
|
||||
$client->loginUser($user);
|
||||
|
||||
return [$client, $user];
|
||||
}
|
||||
|
||||
/** The user as the database now has them, not as the last request left them. */
|
||||
private function reread(): User
|
||||
{
|
||||
static::getContainer()->get(EntityManagerInterface::class)->clear();
|
||||
|
||||
$user = $this->find();
|
||||
|
||||
self::assertInstanceOf(User::class, $user);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
private function find(): ?User
|
||||
{
|
||||
$user = static::getContainer()->get(UserRepository::class)
|
||||
->findOneBy(['email' => self::ADMIN_EMAIL]);
|
||||
|
||||
return $user instanceof User ? $user : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Domain\Enum\Theme;
|
||||
|
||||
use App\Domain\Enum\Theme\LogoStyle;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* The catalogue of the mark's colourways, held to its two structural promises.
|
||||
*
|
||||
* The geometry contract: _logo_mark.html.twig draws exactly seven strokes and
|
||||
* indexes its paints, so every style must answer seven well-formed colours for
|
||||
* both chromes — an eighth would be ignored silently and a sixth would throw
|
||||
* in a template, which is the worst place to learn it.
|
||||
*
|
||||
* And the reason the enum exists at all: the original mark wore Google's
|
||||
* literal brand hexes, and the catalogue was built to retire them. No style
|
||||
* may quietly bring one back.
|
||||
*/
|
||||
final class LogoStyleTest extends TestCase
|
||||
{
|
||||
private const array GOOGLE_BRAND_HEXES = ['#4285f4', '#ea4335', '#fbbc04', '#34a853'];
|
||||
|
||||
public function testEveryStylePaintsSevenStrokesForBothChromes(): void
|
||||
{
|
||||
foreach (LogoStyle::cases() as $style) {
|
||||
foreach ([false, true] as $dark) {
|
||||
$strokes = $style->strokes($dark);
|
||||
|
||||
self::assertCount(7, $strokes, sprintf('%s (%s chrome)', $style->value, $dark ? 'dark' : 'light'));
|
||||
|
||||
foreach ($strokes as $i => $hex) {
|
||||
self::assertMatchesRegularExpression(
|
||||
'/^#[0-9a-f]{6}$/',
|
||||
$hex,
|
||||
sprintf('%s stroke %d (%s chrome)', $style->value, $i, $dark ? 'dark' : 'light'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
self::assertMatchesRegularExpression('/^#[0-9a-f]{6}$/', $style->tile(), $style->value . ' tile');
|
||||
}
|
||||
}
|
||||
|
||||
public function testNoStyleWearsAGoogleBrandColour(): void
|
||||
{
|
||||
foreach (LogoStyle::cases() as $style) {
|
||||
$paints = [...$style->strokes(), ...$style->strokes(true), $style->tile()];
|
||||
|
||||
foreach (self::GOOGLE_BRAND_HEXES as $forbidden) {
|
||||
self::assertNotContains(
|
||||
$forbidden,
|
||||
array_map(strtolower(...), $paints),
|
||||
sprintf('%s wears %s — the colourway the catalogue exists to retire', $style->value, $forbidden),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The count is the design decision, not an accident of editing: the board
|
||||
* had thirty-four colourways and two were rejected by name — the Google
|
||||
* original and its four-distinct-hues restatement. A case appearing or
|
||||
* vanishing should be a conversation, so the number is pinned.
|
||||
*/
|
||||
public function testTheCatalogueHoldsExactlyTheThirtyTwoAcceptedStyles(): void
|
||||
{
|
||||
self::assertCount(32, LogoStyle::cases());
|
||||
}
|
||||
|
||||
public function testTheDefaultIsBerry(): void
|
||||
{
|
||||
self::assertSame(LogoStyle::Berry, LogoStyle::DEFAULT);
|
||||
|
||||
// The PNG icon set under public/icons/ is exported in the default's
|
||||
// sweep with this tile behind the maskable variants — if this moves,
|
||||
// those files are stale (see the enum's own note on DEFAULT).
|
||||
self::assertSame('#a21caf', LogoStyle::DEFAULT->tile());
|
||||
}
|
||||
|
||||
/**
|
||||
* The dark list exists so near-ink marks survive dark chrome; a style
|
||||
* whose light strokes are ink-dark everywhere must therefore answer
|
||||
* something lighter when asked for dark. Spot-checked on the styles built
|
||||
* from the shared ink constant rather than asserted as a luminance rule —
|
||||
* the duotones legitimately keep dark members in dark chrome.
|
||||
*/
|
||||
public function testTheInkBuiltStylesSwapTheirInkAwayInDarkChrome(): void
|
||||
{
|
||||
foreach ([LogoStyle::Ink, LogoStyle::RedFlick, LogoStyle::SkyFlick, LogoStyle::BronzeFlick, LogoStyle::VioletFlick] as $style) {
|
||||
self::assertNotContains(
|
||||
'#2b2620',
|
||||
$style->strokes(true),
|
||||
sprintf('%s keeps near-ink strokes on dark chrome', $style->value),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1886,6 +1886,42 @@ settings:
|
||||
strong: Stark
|
||||
subtle: Dezent
|
||||
unreadEmphasis: Ungelesene Zeilen
|
||||
logo:
|
||||
heading: Logo
|
||||
style:
|
||||
aurora: 'Polarlicht'
|
||||
berry: 'Beere'
|
||||
blue-tonal: 'Blau in Blau'
|
||||
bronze-flick: 'Bronzener Schwung'
|
||||
bronze-ink: 'Bronze & Tinte'
|
||||
burgundy: 'Burgunder'
|
||||
charcoal-amber: 'Anthrazit & Bernstein'
|
||||
cobalt-coral: 'Kobalt & Koralle'
|
||||
copper: 'Kupfer'
|
||||
deep-violet: 'Tiefviolett'
|
||||
ember: 'Glut'
|
||||
espresso-cream: 'Espresso & Sahne'
|
||||
forest: 'Wald'
|
||||
forest-lime: 'Wald & Limette'
|
||||
graphite-red: 'Graphit & Rot'
|
||||
indigo-violet: 'Indigo–Violett'
|
||||
ink: 'Tinte'
|
||||
midnight-gold: 'Mitternacht & Gold'
|
||||
navy-sky: 'Marine & Himmel'
|
||||
ocean: 'Ozean'
|
||||
petrol-copper: 'Petrol & Kupfer'
|
||||
plum-rose: 'Pflaume & Rosé'
|
||||
postal: 'Post'
|
||||
product-blue: 'Produktblau'
|
||||
red-flick: 'Roter Schwung'
|
||||
sky-flick: 'Blauer Schwung'
|
||||
slate: 'Schiefer'
|
||||
sunset: 'Abendrot'
|
||||
teal-gold: 'Türkis & Gold'
|
||||
tricolore: 'Tricolore'
|
||||
twilight: 'Dämmerung'
|
||||
violet-flick: 'Violetter Schwung'
|
||||
subheading: 'Die Farbstimmung des pl-Zeichens. Topbar und Tab-Symbol tragen sie.'
|
||||
main:
|
||||
default: Theme-Standard
|
||||
heading: Hauptbereich
|
||||
|
||||
@@ -1866,6 +1866,42 @@ settings:
|
||||
strong: Strong
|
||||
subtle: Subtle
|
||||
unreadEmphasis: Unread rows
|
||||
logo:
|
||||
heading: Logo
|
||||
style:
|
||||
aurora: 'Aurora'
|
||||
berry: 'Berry'
|
||||
blue-tonal: 'Blue tonal'
|
||||
bronze-flick: 'Bronze flick'
|
||||
bronze-ink: 'Bronze & ink'
|
||||
burgundy: 'Burgundy'
|
||||
charcoal-amber: 'Charcoal & amber'
|
||||
cobalt-coral: 'Cobalt & coral'
|
||||
copper: 'Copper'
|
||||
deep-violet: 'Deep violet'
|
||||
ember: 'Ember'
|
||||
espresso-cream: 'Espresso & cream'
|
||||
forest: 'Forest'
|
||||
forest-lime: 'Forest & lime'
|
||||
graphite-red: 'Graphite & red'
|
||||
indigo-violet: 'Indigo–violet'
|
||||
ink: 'Ink'
|
||||
midnight-gold: 'Midnight & gold'
|
||||
navy-sky: 'Navy & sky'
|
||||
ocean: 'Ocean'
|
||||
petrol-copper: 'Petrol & copper'
|
||||
plum-rose: 'Plum & rose'
|
||||
postal: 'Postal'
|
||||
product-blue: 'Product blue'
|
||||
red-flick: 'Red flick'
|
||||
sky-flick: 'Sky flick'
|
||||
slate: 'Slate'
|
||||
sunset: 'Sunset'
|
||||
teal-gold: 'Teal & gold'
|
||||
tricolore: 'Tricolore'
|
||||
twilight: 'Twilight'
|
||||
violet-flick: 'Violet flick'
|
||||
subheading: 'The colourway of the pl mark. The top bar and your tab icon wear it.'
|
||||
main:
|
||||
default: Theme default
|
||||
heading: Main pane
|
||||
|
||||
@@ -1556,6 +1556,42 @@ settings:
|
||||
strong: Loud as a cannon
|
||||
subtle: A whisper
|
||||
unreadEmphasis: Unread rows
|
||||
logo:
|
||||
heading: 'Ship''s mark'
|
||||
style:
|
||||
aurora: 'Northern lights'
|
||||
berry: 'Berry'
|
||||
blue-tonal: 'Blue on blue'
|
||||
bronze-flick: 'Bronze flourish'
|
||||
bronze-ink: 'Bronze & ink'
|
||||
burgundy: 'Grog'
|
||||
charcoal-amber: 'Coal & amber'
|
||||
cobalt-coral: 'Cobalt & coral'
|
||||
copper: 'Copper'
|
||||
deep-violet: 'Deep violet'
|
||||
ember: 'Embers'
|
||||
espresso-cream: 'Grog & cream'
|
||||
forest: 'Kelp'
|
||||
forest-lime: 'Kelp & lime'
|
||||
graphite-red: 'Graphite & red'
|
||||
indigo-violet: 'Indigo–violet'
|
||||
ink: 'Squid ink'
|
||||
midnight-gold: 'Midnight & doubloons'
|
||||
navy-sky: 'Navy & sky'
|
||||
ocean: 'The open sea'
|
||||
petrol-copper: 'Petrol & copper'
|
||||
plum-rose: 'Plum & rose'
|
||||
postal: 'Mail packet'
|
||||
product-blue: 'Ship''s blue'
|
||||
red-flick: 'Red flourish'
|
||||
sky-flick: 'Sky flourish'
|
||||
slate: 'Slate'
|
||||
sunset: 'Sunset'
|
||||
teal-gold: 'Teal & doubloons'
|
||||
tricolore: 'Three flags'
|
||||
twilight: 'Twilight'
|
||||
violet-flick: 'Violet flourish'
|
||||
subheading: 'The colours yer mark sails under. Top bar and tab badge both fly them.'
|
||||
main:
|
||||
default: Theme default
|
||||
heading: Main deck
|
||||
|
||||