feat(appearance): the logo is an icon, then its paint

Appearance gains logoMotif (default pl) and logoOriginal (default false),
with Version20260923223000 adding both columns so every account keeps the
mark it has. effectiveLogoPaint() says what the chosen icon wears: for the
pl mark it is effectiveLogoStyle(), unchanged; a motif wears its own design
while logoOriginal holds, then the theme's namesake colourway while linked
and the chosen one when not. A linked motif on a classic theme falls back to
its original rather than to Berry. Export, import and the settings save
carry both fields; an unknown motif keeps the current one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Paul LütznerandClaude Opus 5.5 committed 2026-09-23 22:53:20 +02:00
1 parent 562c385978
commit 24d7bdfc07
3 files changed
+203

No files matched your search

+46
View File
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* The logo becomes two choices: an icon, then its paint.
*
* appearance_logo_motif names the icon — the pl mark or one of the nine motifs
* beside it (App\Domain\Enum\Theme\LogoMotif). Defaulted to 'pl', so every
* existing account keeps exactly the mark it has; nothing about an account's
* colourway moves, since appearance_logo_style and appearance_logo_linked go on
* meaning what they meant and the mark still reads them.
*
* appearance_logo_original says a motif wears its own design rather than a
* colourway. FALSE for everyone: it is meaningless for the pl mark, which is
* all anybody can have chosen so far, and choosing a motif in the settings
* pane is what sets it.
*
* Additive, with no CHECK on the motif for the reason Version20260817170000
* gives for the colourway: the legal values live in the enum, and the reader
* ignores one it does not know.
*/
final class Version20260923223000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add user.appearance_logo_motif and user.appearance_logo_original: the logo is an icon, then its paint';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE "user" ADD appearance_logo_motif VARCHAR(16) DEFAULT \'pl\' NOT NULL');
$this->addSql('ALTER TABLE "user" ADD appearance_logo_original BOOLEAN DEFAULT FALSE NOT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE "user" DROP appearance_logo_motif');
$this->addSql('ALTER TABLE "user" DROP appearance_logo_original');
}
}
+86
View File
@@ -10,6 +10,7 @@ use App\Domain\Enum\Theme\Density;
use App\Domain\Enum\Theme\MotionLevel;
use App\Domain\Enum\Theme\FontFamily;
use App\Domain\Enum\Theme\Layout;
use App\Domain\Enum\Theme\LogoMotif;
use App\Domain\Enum\Theme\LogoStyle;
use App\Domain\Enum\Theme\Theme;
use App\Domain\Enum\Theme\UnreadEmphasis;
@@ -121,6 +122,11 @@ final class Appearance
* The colourway the mark actually wears: the theme's namesake style while
* linked (the classic seven have none and fall back to the product
* default), the user's own $logoStyle otherwise.
*
* This is the pl MARK's colourway, whatever the icon is — see
* effectiveLogoPaint() for what the chosen icon wears. The two agree
* whenever the icon is the mark, and this one keeps its meaning for the
* readers that predate the other icons (JMAP's `logoStyle`).
*/
public function effectiveLogoStyle(): LogoStyle
{
@@ -131,6 +137,72 @@ final class Appearance
return LogoStyle::tryFrom($this->theme->value) ?? LogoStyle::DEFAULT;
}
/**
* Which icon — the pl mark or one of the motifs beside it. Settings →
* Appearance → Logo, step one.
*
* The second question, which paint, is answered by the three fields around
* it ($logoOriginal, $logoLinked, $logoStyle), all of them kept whichever
* icon is chosen: going from the mark to the horn and back leaves the
* mark's colourway exactly where it was.
*/
#[ORM\Column(type: 'string', length: 16, enumType: LogoMotif::class, options: ['default' => 'pl'])]
public LogoMotif $logoMotif = LogoMotif::DEFAULT;
/**
* Whether a motif wears its own design rather than a colourway.
*
* The first answer to step two, and the one picking a motif selects: each
* motif was drawn in colours of its own, and a new icon should arrive
* looking the way it was drawn. False unlinks nothing — $logoLinked and
* $logoStyle say what happens instead.
*
* Meaningless for the pl mark, which has no design apart from its
* colourways (its "original" is the product default, and a colourway), so
* effectiveLogoPaint() ignores it there rather than anybody having to keep
* it false.
*/
#[ORM\Column(type: 'boolean', options: ['default' => false])]
public bool $logoOriginal = false;
/** The icon the product wears — topbar, favicon and launcher alike. */
public function effectiveLogoMotif(): LogoMotif
{
return $this->logoMotif;
}
/**
* What the chosen icon is painted in: a colourway, or null for the motif's
* own original design.
*
* For the pl mark this is effectiveLogoStyle(), unchanged, and never null.
* For any other motif it is null while $logoOriginal holds; otherwise the
* theme's namesake colourway while linked, the user's own $logoStyle when
* not.
*
* One difference from the mark, and it is deliberate: a linked motif on one
* of the classic seven themes, which have no namesake colourway, falls back
* to its ORIGINAL rather than to Berry. Berry is the mark's default because
* the mark was drawn to wear it; a horn in Berry is a colourway nobody
* chose, while the horn's own design is the one it was drawn in.
*/
public function effectiveLogoPaint(): ?LogoStyle
{
if (LogoMotif::Pl === $this->logoMotif) {
return $this->effectiveLogoStyle();
}
if (true === $this->logoOriginal) {
return null;
}
if (false === $this->logoLinked) {
return $this->logoStyle;
}
return LogoStyle::tryFrom($this->theme->value);
}
#[ORM\Column(type: 'string', length: 7, options: ['default' => self::DEFAULT_ACCENT])]
public string $accent = self::DEFAULT_ACCENT {
set {
@@ -392,6 +464,8 @@ final class Appearance
'layout' => $this->layout->value,
'logoStyle' => $this->logoStyle->value,
'logoLinked' => $this->logoLinked,
'logoMotif' => $this->logoMotif->value,
'logoOriginal' => $this->logoOriginal,
'accent' => $this->accent,
'paneAlpha' => $this->paneAlpha,
'popoverAlpha' => $this->popoverAlpha,
@@ -442,6 +516,18 @@ final class Appearance
$this->logoLinked = self::boolean($data['logoLinked']);
}
// Both tolerate absence — an export written before there was more than
// one icon carries neither, and importing it keeps the icon you have —
// and a motif this install does not know keeps the current one, like
// every other closed vocabulary here.
if (true === isset($data['logoMotif'])) {
$this->logoMotif = LogoMotif::tryFrom((string) $data['logoMotif']) ?? $this->logoMotif;
}
if (true === isset($data['logoOriginal'])) {
$this->logoOriginal = self::boolean($data['logoOriginal']);
}
if (true === isset($data['accent'])) {
$this->accent = $data['accent'];
}
@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App\Tests\Entity\Embeddable;
use App\Domain\Enum\Theme\LogoMotif;
use App\Domain\Enum\Theme\LogoStyle;
use App\Domain\Enum\Theme\Theme;
use App\Entity\Embeddable\Appearance;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
/**
* What the chosen icon is painted in — Appearance::effectiveLogoPaint().
*
* Four stored fields decide it (the motif, whether a motif wears its own
* design, whether the colour follows the theme, the colourway chosen by hand),
* and every reader — the topbar, the favicon, JMAP's `logoPaint`, and through
* that the phone's launcher icon — goes through this one method. So the rules
* are pinned here, each for the pl mark and for a motif, because the two part
* ways on purpose in exactly two places: the mark has no original of its own,
* and a motif on a classic theme falls back to its original rather than to the
* mark's Berry.
*/
final class AppearanceLogoTest extends TestCase
{
/**
* @return iterable<string, array{LogoMotif, bool, bool, Theme, ?LogoStyle}>
*/
public static function rules(): iterable
{
// motif, logoOriginal, logoLinked, theme → the paint (null = original)
yield 'a motif in its own design' => [LogoMotif::BlueHorn, true, true, Theme::Ocean, null];
yield 'the mark has no original: the flag is ignored' => [LogoMotif::Pl, true, true, Theme::Ocean, LogoStyle::Ocean];
yield 'a linked motif wears the theme\'s namesake' => [LogoMotif::BlueHorn, false, true, Theme::Ocean, LogoStyle::Ocean];
yield 'a linked mark wears the theme\'s namesake' => [LogoMotif::Pl, false, true, Theme::Ocean, LogoStyle::Ocean];
yield 'a linked motif on a classic theme falls back to its original, not Berry' => [LogoMotif::BlueHorn, false, true, Theme::Paper, null];
yield 'a linked mark on a classic theme falls back to Berry' => [LogoMotif::Pl, false, true, Theme::Paper, LogoStyle::DEFAULT];
yield 'an independent motif wears the chosen colourway' => [LogoMotif::BlueHorn, false, false, Theme::Ocean, LogoStyle::Postal];
yield 'an independent mark wears the chosen colourway' => [LogoMotif::Pl, false, false, Theme::Ocean, LogoStyle::Postal];
}
#[DataProvider('rules')]
public function testTheIconWearsThePaintItsFieldsAddUpTo(
LogoMotif $motif,
bool $original,
bool $linked,
Theme $theme,
?LogoStyle $expected,
): void {
$appearance = new Appearance();
$appearance->logoMotif = $motif;
$appearance->logoOriginal = $original;
$appearance->logoLinked = $linked;
$appearance->theme = $theme;
$appearance->logoStyle = LogoStyle::Postal;
self::assertSame($expected, $appearance->effectiveLogoPaint());
self::assertSame($motif, $appearance->effectiveLogoMotif());
// For the mark the paint IS the mark's colourway, so the reader that
// predates the icons (JMAP's `logoStyle`) keeps telling the truth.
if (LogoMotif::Pl === $motif) {
self::assertSame($appearance->effectiveLogoStyle(), $appearance->effectiveLogoPaint());
}
}
}