0.15, 'max' => 1.0]; /** * The floating surfaces' own opacity, and why its floor is not 0.15. * * A popover is read against whatever it covers, and what it covers is * usually a pane that is already letting the wallpaper through. The two * translucencies multiply: a menu at 0.3 over a pane at 0.3 leaves 49% of * the picture behind them both showing through the labels. That is the * complaint this setting was split out of the pane opacity to answer, so * the range itself refuses the bottom of it — half is as see-through as a * surface carrying words over other words is allowed to get. * * The floor is also deliberately above the 0.45 AppearanceRenderer imposes * on --pane-alpha under a photograph, which is why no such floor is needed * here: the range already clears it. */ public const array RANGE_POPOVER_ALPHA = ['min' => 0.5, 'max' => 1.0]; public const array RANGE_PANE_BLUR = ['min' => 0, 'max' => 60]; public const array RANGE_RADIUS = ['min' => 0.0, 'max' => 2.0]; public const array RANGE_SCRIM_ALPHA = ['min' => 0.0, 'max' => 0.7]; public const array RANGE_MAIN_ALPHA = ['min' => 0.15, 'max' => 1.0]; /** * How many lines of the message preview a list row shows: none, one, two. * * Two is the ceiling because the row only has room for two. Wide, the * subject and the preview share a single line by design, so a second line * there would push the subject off its own row; the clamp is applied in the * stacked layout only and app.css says so. Three was tried and is a wall of * grey text at any density below Comfortable. */ public const array RANGE_PREVIEW_LINES = ['min' => 0, 'max' => 2]; /** * The interface text scale, as a multiplier on the 16px root. * * The ends are where the app was actually opened and looked at, not where * a round number fell. Both were driven at 1440×900 and 414×851, in a light * theme and a dark one, across the settings pane, the thread list and the * compose window — see tests/e2e/appearance-shots.spec.ts, which takes * those twelve screenshots and asserts the one thing that must not happen * at either end: the page never scrolls sideways. Nothing clips at 0.875 * and nothing overflows at 1.25; the settings nav wraps to more lines at * the top end, which is the layout doing its job rather than breaking. * * Wider was not offered because it was not checked. A scale is a promise * about every screen in the app at once, and the honest range is the one * somebody has looked at. */ public const array RANGE_FONT_SCALE = ['min' => 0.875, 'max' => 1.25]; /** * Paper rather than System. A new install should look like something * somebody chose, and "follow the OS" resolves to whichever of plain white * or plain dark the machine happens to prefer — the two least considered * palettes here. Anyone who wants the OS to decide can still pick System; * this only changes what an account starts as. */ #[ORM\Column(type: 'string', length: 16, enumType: Theme::class, options: ['default' => 'paper'])] public Theme $theme = Theme::Paper; #[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; /** * Whether the mark follows the theme rather than $logoStyle. * * Every logo colourway is also a theme now (their enum values are * identical), so the natural default is that picking a theme dresses the * mark to match — that is what linked means. Unlinking is for the person * who wants the ocean chrome with the berry mark; their $logoStyle then * speaks for itself again. Readers go through effectiveLogoStyle() so the * two states cannot be confused. */ #[ORM\Column(type: 'boolean', options: ['default' => true])] public bool $logoLinked = true; /** * 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 { if (false === $this->logoLinked) { return $this->logoStyle; } 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' => 'wax-seal'])] 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. * * True from the start, because an account starts on a motif now * (LogoMotif::DEFAULT) and that motif should arrive the way picking one * does: in its own design. False here would paint a new account's seal in * its theme's colourway, which is a choice nobody made. */ #[ORM\Column(type: 'boolean', options: ['default' => true])] public bool $logoOriginal = true; /** 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 { $this->accent = 1 === preg_match('/^#[0-9a-fA-F]{6}$/', $value) ? strtolower($value) : self::DEFAULT_ACCENT; } } // The knob defaults below mirror Layout::Flat's preset — the default // layout and the default appearance have to agree, or a fresh account // renders in a state no layout would produce. #[ORM\Column(type: 'float', options: ['default' => 1.0])] public float $paneAlpha = 1.0 { set { $this->paneAlpha = max(self::RANGE_PANE_ALPHA['min'], min(self::RANGE_PANE_ALPHA['max'], $value)); } } /** * The composer, modals, dropdowns, menus and toasts — everything that * floats OVER the layout rather than being part of it. * * Its own knob rather than a share of $paneAlpha because the two are read * in different situations. A pane is the ground you read the app on and * can afford to be glass; a popover is a small box of dense text laid over * that ground, and at the same opacity the row it covers reads straight * through its labels. One slider could not be right for both, and it was * being set for the panes. * * Defaults fully opaque, which is what the floating surfaces already were: * the `popover` utility in app.css has been hardcoded opaque since it was * written, for exactly the reason above. This turns that rule into a value, * so somebody who wants frosted menus can have them. */ #[ORM\Column(type: 'float', options: ['default' => 1.0])] public float $popoverAlpha = 1.0 { set { $this->popoverAlpha = max(self::RANGE_POPOVER_ALPHA['min'], min(self::RANGE_POPOVER_ALPHA['max'], $value)); } } #[ORM\Column(type: 'smallint', options: ['default' => 0])] public int $paneBlur = 0 { set { $this->paneBlur = max(self::RANGE_PANE_BLUR['min'], min(self::RANGE_PANE_BLUR['max'], $value)); } } /** Corner radius in rem. */ #[ORM\Column(type: 'float', options: ['default' => 0.75])] public float $radius = 0.75 { set { $this->radius = max(self::RANGE_RADIUS['min'], min(self::RANGE_RADIUS['max'], $value)); } } #[ORM\Column(type: 'string', length: 16, enumType: Density::class, options: ['default' => 'comfortable'])] public Density $density = Density::Comfortable; /** * How much the interface moves. See {@see MotionLevel} for the vocabulary. * * Defaults to Full for everyone, including existing installs, and that is a * deliberate choice rather than an oversight about not moving somebody's * furniture. Motion here is additive — nothing lands in a different place, * nothing takes longer to become usable — so the upgrade is a interface * that explains itself slightly better, and the two people in ten who would * rather it did not have a setting one click away. Defaulting to None would * mean shipping a feature switched off, which nobody discovers. * * An operating system asking for reduced motion overrules this without * consulting it — see assets/styles/motion.css. That is not a preference to * be merged with another preference; it is somebody telling their machine * that movement makes them ill. */ #[ORM\Column(type: 'string', length: 16, enumType: MotionLevel::class, options: ['default' => 'full'])] public MotionLevel $motion = MotionLevel::Full; #[ORM\Column(type: 'string', length: 16, enumType: BackgroundKind::class, options: ['default' => 'theme'])] public BackgroundKind $backgroundKind = BackgroundKind::Theme; #[ORM\Column(type: 'string', length: 32, nullable: true, enumType: BackgroundPreset::class)] public ?BackgroundPreset $backgroundPreset = null; /** Filename only, relative to the per-user upload directory. */ #[ORM\Column(type: 'string', length: 128, nullable: true)] public ?string $backgroundFile = null; #[ORM\Column(type: 'string', length: 7, nullable: true)] public ?string $backgroundSolid = null; #[ORM\Column(type: 'float', options: ['default' => 0.0])] public float $scrimAlpha = 0.0 { set { $this->scrimAlpha = max(self::RANGE_SCRIM_ALPHA['min'], min(self::RANGE_SCRIM_ALPHA['max'], $value)); } } #[ORM\Column(type: 'string', length: 7, nullable: true)] public ?string $inkColor = null { set { $this->inkColor = self::normaliseHex($value); } } #[ORM\Column(type: 'string', length: 7, nullable: true)] public ?string $inkMuted = null { set { $this->inkMuted = self::normaliseHex($value); } } #[ORM\Column(type: 'string', length: 7, nullable: true)] public ?string $inkFaint = null { set { $this->inkFaint = self::normaliseHex($value); } } #[ORM\Column(type: 'string', length: 7, nullable: true)] public ?string $mainTint = null { set { $this->mainTint = self::normaliseHex($value); } } #[ORM\Column(type: 'float', nullable: true)] public ?float $mainAlpha = null { set { $this->mainAlpha = null === $value ? null : max(self::RANGE_MAIN_ALPHA['min'], min(self::RANGE_MAIN_ALPHA['max'], $value)); } } /* ── Mail list display ──────────────────────────────────────────────── What a row in the message list shows. Every default below is what the list already did before these settings existed, so an install that upgrades looks identical until somebody changes something. */ /** * The account corner: a coloured triangle clipped to a row's lower-left, * saying which account the row arrived on. * * Optional because it is identity vocabulary, and not everybody wants their * list carrying it — but ON by default, because a unified list across three * accounts is genuinely ambiguous without it (the same Message-ID delivered * to three addresses puts three identical rows on screen) and the person * who does not want it is one click from not having it. It is drawn only in * a unified list on a multi-account install anyway; on one account it has * never appeared at all, and this setting does not change that. */ #[ORM\Column(type: 'boolean', options: ['default' => true])] public bool $accountCorner = true; /** * The sender disc — the coloured circle carrying an initial. * * "Off" does not remove the control, and cannot: the disc IS the row's * checkbox (the real input is sr-only behind it, and every bulk action in * the toolbar reads it). So off means the disc loses its colour and its * letter and becomes a plain outlined circle in the same slot — the * identity goes, the selection target and the row's geometry stay. See * app.css, which paints that over it rather than removing anything. */ #[ORM\Column(type: 'boolean', options: ['default' => true])] public bool $listAvatars = true; /** * The unread count beside each inbox tab's name. * * ON by default, because it answers a question nothing else on the strip * can: the sidebar's Inbox badge says there is unread mail and not which * tab it is in. OPTIONAL, for the same reason the account corner is: it is * one more number on a strip that already carries the "3 new" pill, and * the person who reads the pill and the bold rows and wants nothing else * is one click from not having it. * * Off hides the number and nothing else. The tint on a tab's icon and the * pill are separate statements and stay. */ #[ORM\Column(type: 'boolean', options: ['default' => true])] public bool $tabUnreadCount = true; #[ORM\Column(type: 'smallint', options: ['default' => 1])] public int $previewLines = 1 { set { $this->previewLines = max(self::RANGE_PREVIEW_LINES['min'], min(self::RANGE_PREVIEW_LINES['max'], $value)); } } #[ORM\Column(type: 'string', length: 16, enumType: UnreadEmphasis::class, options: ['default' => 'standard'])] public UnreadEmphasis $unreadEmphasis = UnreadEmphasis::Standard; /* ── Typography ───────────────────────────────────────────────────────── */ #[ORM\Column(type: 'string', length: 16, enumType: FontFamily::class, options: ['default' => 'system'])] public FontFamily $fontFamily = FontFamily::System; #[ORM\Column(type: 'float', options: ['default' => 1.0])] public float $fontScale = 1.0 { set { $this->fontScale = max(self::RANGE_FONT_SCALE['min'], min(self::RANGE_FONT_SCALE['max'], $value)); } } /* ── Per-surface density ────────────────────────────────────────────── Three nullable overrides on ONE existing knob, rather than three copies of the whole appearance. Null means "follow $density", and that is what makes this additive: the columns arrive null on every existing row, so nothing renders differently until somebody sets one. It is also what keeps the token surface finite — AppearanceRenderer resolves each surface to a concrete pair of values and emits six variables, instead of every knob gaining three scoped twins. Density and no other knob, for a reason that is structural rather than a shortage of time: the list and the reading pane are ONE painted surface. `.main-pane` in _layout/_mailbox.html.twig wraps both and carries the background, the blur and the border, so opacity, blur, radius and the scrim cannot differ between them without splitting that pane in two. Density can, because it is padding inside rows rather than a property of the surface they sit on. */ #[ORM\Column(type: 'string', length: 16, nullable: true, enumType: Density::class)] public ?Density $sidebarDensity = null; #[ORM\Column(type: 'string', length: 16, nullable: true, enumType: Density::class)] public ?Density $listDensity = null; #[ORM\Column(type: 'string', length: 16, nullable: true, enumType: Density::class)] public ?Density $readingDensity = null; /** The density a surface actually renders at: its own, or the global one. */ public function densityFor(string $surface): Density { return match ($surface) { 'sidebar' => $this->sidebarDensity ?? $this->density, 'list' => $this->listDensity ?? $this->density, 'reading' => $this->readingDensity ?? $this->density, default => $this->density, }; } /** * Switch layout and seed its knob defaults. The settings pane sends the * seeded values itself (so the sliders and the payload stay in step); this * is for callers that only have a layout — importing an export written * before a knob existed, for instance. */ public function applyLayout(Layout $layout): static { $this->layout = $layout; return $this->applyArray($layout->defaults()); } private static function normaliseHex(?string $value): ?string { if (null === $value || '' === $value) { return null; } return 1 === preg_match('/^#[0-9a-fA-F]{6}$/', $value) ? strtolower($value) : null; } /** Export payload — background file is deliberately excluded, it is not portable. */ public function toArray(): array { return [ 'version' => 1, 'theme' => $this->theme->value, '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, 'paneBlur' => $this->paneBlur, 'radius' => $this->radius, 'density' => $this->density->value, 'motion' => $this->motion->value, 'backgroundKind' => BackgroundKind::Custom === $this->backgroundKind ? BackgroundKind::Theme->value : $this->backgroundKind->value, 'backgroundPreset' => $this->backgroundPreset?->value, 'backgroundSolid' => $this->backgroundSolid, 'scrimAlpha' => $this->scrimAlpha, 'inkColor' => $this->inkColor, 'inkMuted' => $this->inkMuted, 'inkFaint' => $this->inkFaint, 'mainTint' => $this->mainTint, 'mainAlpha' => $this->mainAlpha, 'accountCorner' => $this->accountCorner, 'listAvatars' => $this->listAvatars, 'tabUnreadCount' => $this->tabUnreadCount, 'previewLines' => $this->previewLines, 'unreadEmphasis' => $this->unreadEmphasis->value, 'fontFamily' => $this->fontFamily->value, 'fontScale' => $this->fontScale, 'sidebarDensity' => $this->sidebarDensity?->value, 'listDensity' => $this->listDensity?->value, 'readingDensity' => $this->readingDensity?->value, ]; } public function applyArray(array $data): static { // First: a layout seeds the knobs, so any explicit knob value in the // same payload has to be applied after it to win. if (true === isset($data['layout'])) { $this->layout = Layout::tryFrom($data['layout']) ?? $this->layout; } if (true === isset($data['theme'])) { $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['logoLinked'])) { $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']; } if (true === isset($data['paneAlpha'])) { $this->paneAlpha = floatval($data['paneAlpha']); } if (true === isset($data['popoverAlpha'])) { $this->popoverAlpha = floatval($data['popoverAlpha']); } if (true === isset($data['paneBlur'])) { $this->paneBlur = intval($data['paneBlur']); } if (true === isset($data['radius'])) { $this->radius = floatval($data['radius']); } if (true === isset($data['density'])) { $this->density = Density::tryFrom($data['density']) ?? $this->density; } if (true === isset($data['motion'])) { $this->motion = MotionLevel::tryFrom((string) $data['motion']) ?? $this->motion; } if (true === isset($data['backgroundKind'])) { $this->backgroundKind = BackgroundKind::tryFrom($data['backgroundKind']) ?? $this->backgroundKind; } if (true === array_key_exists('backgroundPreset', $data)) { // Both "no preset" and "a preset we don't know" mean null, but // tryFrom() only accepts a string — reset and import both send the // key with a null value. $preset = $data['backgroundPreset']; $this->backgroundPreset = is_string($preset) ? BackgroundPreset::tryFrom($preset) : null; } if (true === array_key_exists('backgroundSolid', $data)) { $solid = $data['backgroundSolid']; $this->backgroundSolid = is_string($solid) && 1 === preg_match('/^#[0-9a-fA-F]{6}$/', $solid) ? strtolower($solid) : null; } if (true === isset($data['scrimAlpha'])) { $this->scrimAlpha = floatval($data['scrimAlpha']); } if (true === array_key_exists('inkColor', $data)) { $this->inkColor = is_string($data['inkColor']) ? $data['inkColor'] : null; } if (true === array_key_exists('inkMuted', $data)) { $this->inkMuted = is_string($data['inkMuted']) ? $data['inkMuted'] : null; } if (true === array_key_exists('inkFaint', $data)) { $this->inkFaint = is_string($data['inkFaint']) ? $data['inkFaint'] : null; } if (true === array_key_exists('mainTint', $data)) { $this->mainTint = is_string($data['mainTint']) ? $data['mainTint'] : null; } if (true === array_key_exists('mainAlpha', $data)) { $raw = $data['mainAlpha']; $this->mainAlpha = '' === $raw || null === $raw ? null : floatval($raw); } if (true === isset($data['accountCorner'])) { $this->accountCorner = self::boolean($data['accountCorner']); } if (true === isset($data['listAvatars'])) { $this->listAvatars = self::boolean($data['listAvatars']); } if (true === isset($data['tabUnreadCount'])) { $this->tabUnreadCount = self::boolean($data['tabUnreadCount']); } if (true === isset($data['previewLines'])) { $this->previewLines = intval($data['previewLines']); } if (true === isset($data['unreadEmphasis'])) { $this->unreadEmphasis = UnreadEmphasis::tryFrom((string) $data['unreadEmphasis']) ?? $this->unreadEmphasis; } if (true === isset($data['fontFamily'])) { $this->fontFamily = FontFamily::tryFrom((string) $data['fontFamily']) ?? $this->fontFamily; } if (true === isset($data['fontScale'])) { $this->fontScale = floatval($data['fontScale']); } // array_key_exists, not isset: null is the meaningful value here — it // is how a surface goes back to following the global density — and // isset() cannot tell "follow the global" from "not mentioned". foreach (['sidebarDensity', 'listDensity', 'readingDensity'] as $surface) { if (false === array_key_exists($surface, $data)) { continue; } $value = $data[$surface]; $this->{$surface} = is_string($value) && '' !== $value ? Density::tryFrom($value) : null; } return $this; } /** * A checkbox over the wire. * * The settings pane posts every field as the string in its DOM node, so a * boolean arrives as "1"/"0"; JMAP and a theme-export file send a real * bool. "0" is truthy to PHP's cast, so both spellings are named here * rather than trusted to (bool). */ private static function boolean(mixed $value): bool { if (true === is_bool($value)) { return $value; } return false === in_array($value, ['0', 0, 'false', '', null], true); } }