Files
cdd0cd4fd8 The installed app's icons follow the chosen logo, drawn by the browser (#229)
An app installed from the browser or added to an iPhone home screen kept the wax seal. The manifest and apple-touch-icon now name the chosen logo; the browser draws the PNGs on a canvas and uploads them per user, so the image needs no librsvg. Builds on #92. Closes #141.

Co-authored-by: mani <15911342+Flouss@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 18:16:55 +02:00

1378 lines
95 KiB
Twig

{% import '_partials/_select.html.twig' as select %}
{% import '_partials/_card_chrome.html.twig' as chrome %}
{% set a = appearance() %}
{# ── One theme tile ──────────────────────────────────────────────────────────
A macro because the picker renders the same tile from two places now: the
visible row of classics (plus the currently chosen logo theme) and the
"More" grid the rest wait in.
data-theme-NAME. `data-theme` is the palette switch (every theme block in
app.css is selected by it), so a tile wearing it declared that theme's
whole palette on itself and the label below inherited it — "Nord" written
in Nord's ink on Solar's cream, 1.22:1. The swatch still previews the
theme, because those are inline colours on that element alone and preview
is its whole job.
The theme's name is the only thing that says which tile is which, so it is
primary content, not a caption: it gets the soft ink (7:1 or better in
every theme) at 12px rather than muted ink at 11px. The 32 logo-colourway
themes are named by the logo style they mirror — those keys exist in every
locale already, and a theme called anything but its colourway's name would
break the link the linked-logo toggle promises. #}
{% macro theme_tile(theme, selected) %}
{% set swatch = theme.swatch %}
<button type="button"
data-theme-name="{{ theme.value }}"
data-dark="{{ theme.isDark ? '1' : '0' }}"
data-theme-defaults="{{ theme.defaults|json_encode }}"
data-action="click->settings--appearance#pickTheme"
class="group flex flex-col items-center gap-2 p-2 rounded-pane cursor-pointer hover:bg-hover transition-colors
{{ selected ? 'ring-2 ring-accent' : '' }}">
<span class="w-full h-10 rounded-lg border border-line flex items-end p-1 gap-1"
style="background:{{ swatch[0] }}">
<span class="w-2 h-2 rounded-full" style="background:{{ swatch[1] }}"></span>
<span class="w-2 h-2 rounded-full" style="background:{{ swatch[2] }}"></span>
</span>
<span class="text-xs text-ink-soft">{{ (theme.logoStyle is not null
? 'settings.appearance.logo.style.' ~ theme.value
: 'settings.appearance.theme.' ~ theme.value)|trans }}</span>
</button>
{% endmacro %}
{# ── One shape for every segmented control ──────────────────────────────────
Density (global and all three surfaces), preview lines, unread emphasis and
the typeface are the same control — pick one of a short list — and before
this they were three different controls to look at: some sat beside their
label, some under it, the surface rows were a different size from the
global one, and the typeface was a <select> that behaved like neither.
HORIZONTAL, one control per row, label above it, segments sharing the row
equally. Horizontal because the side-by-side layout leaves the controls
column ~600px at 1440 and the full width at 414 — a horizontal group fits
both, while a vertical stack of four would run the panel to twice its length
and read as a menu rather than as a choice already made. `auto-cols-fr`
rather than `inline-flex` is what makes the widths equal and, more to the
point, what stops the longest label ("Comfortable", "Komfortabel") pushing
the group past the column at 414px: the columns divide the row instead of
summing to it.
They stay real radios sharing a `name`, so arrow-key navigation within the
group is the browser's and costs nothing. `peer-focus-visible` is new: the
input is `sr-only`, so before this a keyboard user moving through the group
had no visible indication of where they were. #}
{# `max-w-md` so the group stops growing once it has room for its longest
label. Without it a three-option control stretched the full controls column
— 200px-wide segments carrying an 11px word, which reads as a row of empty
buttons rather than a compact choice. It still goes edge to edge at 414px,
where the full width is the only width there is. #}
{% set segGroup = 'grid grid-flow-col auto-cols-fr max-w-md rounded-pane border border-line overflow-hidden' %}
{% set segText = 'flex h-full items-center justify-center px-2 py-1.5 text-center text-[11px] leading-tight text-ink-muted
peer-checked:bg-accent peer-checked:text-accent-ink
peer-focus-visible:ring-2 peer-focus-visible:ring-inset peer-focus-visible:ring-accent
transition-colors' %}
{# `@container` on the panel itself, so every width decision below is made
against THIS panel's width rather than against whatever container happens to
be above it. That matters because the panel has two hosts of very different
shapes — the settings pane, where an ancestor container is the scroll body
and a good deal wider than the panel, and the setup wizard's modal, which
has no container ancestor at all and would have pinned every query to its
base state. One container, one set of thresholds, the same behaviour in
both. #}
{# `pane-fill-rest pane-fill-stack`: above 80rem this panel takes what its host's
scroll port has left rather than growing it — see the block in app.css and the
two hosts that hand it that height (settings/index.html.twig,
onboarding/_wizard.html.twig). Below 80rem both classes are inert and this is
an ordinary block that is as tall as it is. #}
<div class="@container pane-fill-rest pane-fill-stack"
data-controller="settings--appearance"
data-settings--appearance-update-url-value="{{ path('app_appearance_update') }}"
data-settings--appearance-upload-url-value="{{ path('app_appearance_background_upload') }}"
data-settings--appearance-import-url-value="{{ path('app_appearance_import') }}"
data-settings--appearance-reset-url-value="{{ path('app_appearance_reset') }}"
data-settings--appearance-app-icons-url-value="{{ path('app_appearance_app_icons') }}"
{# What a linked pl mark wears on a theme with no namesake colourway —
LogoStyle::DEFAULT, read here rather than spelled in the script. #}
data-settings--appearance-logo-default-value="{{ constant('App\\Domain\\Enum\\Theme\\LogoStyle::DEFAULT').value }}">
{# TWO PEER CARDS with the boundary between them, not one card wearing a
sidebar. The controls are a card and the preview is a card, each built
the way every other settings section builds one (`rounded-pane border
border-line bg-surface`, a header rule, a padded body), and the splitter
is the gap between the two rather than a rail inside one of them. The
previous shape said "the preview belongs to the controls"; it does not —
it is the other half of the page.
Container query, not a viewport breakpoint, for the reason settings
index.html.twig gives: this pane is not as wide as the window, and at md
the mail sidebar appears and takes from it, so pane width and viewport
width do not move together.
Below @3xl the two cards STACK and the preview starts collapsed, reached
through the control in the settings card's header. It used to be dropped
from the markup outright, which was a defensible answer while it was a
sidebar and is not one now: a phone is where you can least afford to
leave the page to find out what a knob did.
The second column is `auto` rather than the width itself, and the width
sits on the preview INSIDE it. That is what the calendar does, and for
the same reason: the handle overhangs the gap by a negative margin, so a
column sized to the stored number would have spent 24px of the preview
on the handle and the miniature would have been narrower than the number
says. Sized to content, the preview is exactly as wide as it is stored,
which is also what ui--split measures when it clamps.
The splitter is mounted HERE, on the row it splits, rather than on the
panel above — which is also where the calendar's lives, on the row
holding its two panes. The panel's own `data-controller` stays exactly
`settings--appearance`, so the six specs that select it by that whole
attribute value keep working; a controller list is a poor thing to make
load-bearing for a selector, but it is not this change's business to
rewrite them all either. The mobile disclosure is an action on
`settings--appearance` for the same reason: a second controller on the
panel root would have broken all six.
The preview's width is rendered into the first paint rather than restored
by JavaScript — a pane that appears at the default and jumps once the
controller connects is worse than one that does not remember at all. It
sits on this element because that is the one ui--split writes to. The
fallback covers the theoretical unauthenticated render; both real hosts
are behind a login. #}
{% set _previewWidth = app.user ? app.user.appearancePreviewWidth
: constant('App\\Entity\\User\\User::APPEARANCE_PREVIEW_DEFAULT_WIDTH') %}
{# `@3xl:gap-gutter` — the mail shell's gutter, not this page's card spacing,
and that is the whole of "make the gap feel like the calendar's". The row
gap is half the visible space between the two cards; the handle's own
negative margin is the other half (see the handle below), so gutter here
and gutter there give a 24px channel with the grip down the middle of it,
pixel for pixel what sits between the message list and the calendar.
`gap-6` still applies BELOW @3xl, where the two cards are stacked and this
is vertical space between two cards rather than a boundary.
`pane-fill-host` caps the row above 80rem so both cards can be exactly as
tall as it is; below that it is inert. #}
<div class="grid gap-6 @3xl:gap-gutter @3xl:grid-cols-[minmax(0,1fr)_auto] pane-fill-host"
style="--appearance-preview-width: {{ _previewWidth }}px"
data-controller="ui--split"
{# The same splitter the calendar pane uses, with its three positions
switched off — see ui--split's docblock. There is nothing to switch
between here: the preview is either beside the controls or, below
@3xl, not rendered. What is shared is the part that matters — the
drag, the clamp against what the two columns have between them, the
arrow keys, the double-click reset and the coalesced write on
release.
`variable-value` is why two boundaries can exist on one page at all
— and they do, in the setup wizard, where this panel opens over the
mail shell: the controller writes the property it is handed and
knows no pane by name. #}
data-ui--split-modes-value="false"
data-ui--split-variable-value="--appearance-preview-width"
data-ui--split-state-url-value="{{ path('app_appearance_pane_state') }}"
data-ui--split-token-value="{{ csrf_token('appearance_pane_state') }}"
data-ui--split-min-value="{{ constant('App\\Entity\\User\\User::APPEARANCE_PREVIEW_MIN_WIDTH') }}"
data-ui--split-max-value="{{ constant('App\\Entity\\User\\User::APPEARANCE_PREVIEW_MAX_WIDTH') }}"
data-ui--split-default-value="{{ constant('App\\Entity\\User\\User::APPEARANCE_PREVIEW_DEFAULT_WIDTH') }}"
{# What the CONTROLS column is never dragged below. They are the reason
the page exists; the preview is the thing that can be made small.
380, and it is a measured number rather than a round one. Two things
inside this card set it. The segmented groups stop clipping their
longest label at about 316px of card — that is the same in English
and in German, since `auto-cols-fr` divides the row rather than
summing it and only the text itself can overflow. The theme tiles
used to want a great deal more, because their column count was a
query on the PANEL and the panel does not narrow when this card
does: six fixed columns clipped the longest German theme name at
every width the drag could reach. That grid is `auto-fill` now (see
it below), so it answers to the card it is in and the tiles set no
floor at all. 380 is 316 with enough left over that the card reads
as a column of controls rather than as the last thing that fits.
This is the bound that actually bites at 1280: the row has 754px for
the two cards there, so the preview stops at 374 however far the
max below would let it go. That is the point of it — 900 is what the
boundary can reach on a screen with 900 to spare, not a promise that
the controls will be squeezed to make room for it. #}
data-ui--split-main-min-value="380"
data-ui--split-step-value="16">
{# ── Card one: the controls ───────────────────────────────────────
A real card, so the two halves of this page are peers. `min-w-0`
because it is the `1fr` column and its segmented groups would
otherwise refuse to shrink below their content. #}
{# `pane-fill-card` above 80rem: header, then a body that scrolls inside
the card. This is the half that stops the preview beside it being a
2500px box with 500px of miniature at the top — the row is no longer
2500px, because this card no longer sets its own height. It is what
.main-pane does for the message list, for the same reason. #}
{# Shell and band come from _partials/_card_chrome.html.twig, the same
strings settings/_card.html.twig uses. This section keeps its own
markup rather than embedding that card — it is also the setup
wizard's appearance step, it owns the @container the groups query,
it is half of a resizable split pair, and its body scrolls inside the
card via pane-fill-scroll. None of that survives the generic body. #}
<section class="min-w-0 {{ chrome.shell() }} pane-fill-card"
data-ui--split-target="main">
{# The card header, and the one place the preview can be reached on
a phone.
DISCLOSURE, not a dialog and not a replacement: pressing it
reveals the preview card above the controls and leaves every
control where it was. A modal would have hidden the controls the
preview exists to explain, and swapping one for the other would
have made "change a setting and watch" impossible — which is the
entire reason to want a preview on a small screen.
NOT PERSISTED, and that is a deliberate difference from the pane
WIDTH beside it. A width is a considered choice about how this
page should be shaped and is worth carrying to the next visit;
this is a peek. Persisted, it would cost a returning phone user a
third of a 568px screen for a glance they took once and forgot,
and it would need a second settings-bag key and a second CSRF'd
endpoint to do it. It starts closed on every load.
`@3xl:hidden` because above that width the preview is simply
there and a control to reveal what is already visible is noise.
`aria-controls` names the region and `aria-expanded` carries the
state; the label itself changes with it, so the control is
readable without either. #}
<header class="shrink-0 flex items-center justify-between gap-3 {{ chrome.band() }}">
<div class="min-w-0">
<h2 class="text-sm font-semibold text-ink">{{ 'settings.appearance.heading'|trans }}</h2>
<p class="text-xs text-ink-faint mt-0.5">{{ 'settings.appearance.subheading'|trans }}</p>
</div>
<button type="button"
data-appearance-preview-toggle
data-settings--appearance-target="previewToggle"
data-action="click->settings--appearance#togglePreview"
aria-expanded="false"
aria-controls="appearance-preview-region"
data-show-label="{{ 'settings.appearance.preview.show'|trans }}"
data-hide-label="{{ 'settings.appearance.preview.hide'|trans }}"
class="@3xl:hidden shrink-0 inline-flex items-center gap-2 px-3 min-h-9 rounded-pane
border border-line text-xs font-medium text-ink-muted
hover:bg-hover cursor-pointer transition-colors
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent">
<i class="fa-solid fa-eye text-[11px]" aria-hidden="true"
data-settings--appearance-target="previewToggleIcon"></i>
<span data-settings--appearance-target="previewToggleLabel">
{{ 'settings.appearance.preview.show'|trans }}
</span>
</button>
</header>
{# The card's scrolling body — the ONE scrollbar this section gains,
and it replaces the settings pane's rather than competing with
it: the panel now fills that pane exactly instead of lengthening
it, so there is nothing left out there to scroll. #}
<div class="{{ chrome.body() }} space-y-8 pane-fill-scroll">
{# ── Theme ─────────────────────────────────────────────────── #}
<section>
<div class="mb-4">
<h3 class="text-sm font-semibold text-ink">{{ 'settings.appearance.theme.heading'|trans }}</h3>
<p class="text-xs text-ink-faint mt-0.5">{{ 'settings.appearance.theme.subheading'|trans }}</p>
</div>
{# AUTO-FILL rather than three container-query breakpoints, and
the split is what forced it. The queries were on the PANEL,
and the panel's width no longer says anything about this
card's: drag the preview wider and the card narrows while the
panel does not, so a card dragged to 360px was still being
told to draw six columns. Measured with the German
catalogue's labels, six columns overflowed "Dämmerung" by
18px in a 336px card and still by 10px in a 416px one — into
a card that is `overflow-hidden`, so the name was cut rather
than merely tight, and a theme whose name you cannot read is
a theme you cannot pick.
`minmax(5.5rem, 1fr)` is the tile the longest name fits in
(88px, of which 16 is the tile's own padding), so the count
falls out of the width that is actually available instead of
being asserted about a different element. Seven columns —
one per theme — in a card with room for them, four at 1280,
three at 414, and it needs no new breakpoint the next time a
theme is added.
It costs one column on the 320px phone, which used to draw
three at 78px each. Those three could not hold "Dämmerung"
either: two columns of 122 is the width the label has always
needed and the first arrangement that actually gives it. #}
{# The seven classics stay on the page; the thirty-two logo
themes wait behind "More" — except the one currently chosen,
which is rendered among the visible tiles so the selection
is never hidden from its owner. The split is server-side
state, so the extras fold away again on the next load. #}
{% set _visibleThemes = [] %}
{% set _moreThemes = [] %}
{% for theme in enum('App\\Domain\\Enum\\Theme\\Theme').cases %}
{% if theme.logoStyle is null or theme == a.theme %}
{% set _visibleThemes = _visibleThemes|merge([theme]) %}
{% else %}
{% set _moreThemes = _moreThemes|merge([theme]) %}
{% endif %}
{% endfor %}
<div class="grid grid-cols-[repeat(auto-fill,minmax(5.5rem,1fr))] gap-3">
{% for theme in _visibleThemes %}
{{ _self.theme_tile(theme, a.theme == theme) }}
{% endfor %}
{# A tile-shaped disclosure, not a tile: it reveals the
hidden grid below and retires itself — the full set then
stays out for the rest of the page visit. The hidden
tiles are in the DOM either way (pickTheme's ring sweep
and the linked-logo repaint both query them), so this is
purely a class toggle on the container. #}
<button type="button"
data-action="click->settings--appearance#showMoreThemes"
data-settings--appearance-target="moreThemesButton"
aria-expanded="false"
aria-controls="appearance-more-themes"
class="group flex flex-col items-center gap-2 p-2 rounded-pane cursor-pointer hover:bg-hover transition-colors">
<span class="w-full h-10 rounded-lg border border-dashed border-line flex items-center justify-center text-ink-muted">
<i class="fa-solid fa-ellipsis" aria-hidden="true"></i>
</span>
<span class="text-xs text-ink-soft">{{ 'settings.appearance.theme.more'|trans }}</span>
</button>
</div>
<div id="appearance-more-themes"
class="hidden grid grid-cols-[repeat(auto-fill,minmax(5.5rem,1fr))] gap-3 mt-3"
data-settings--appearance-target="moreThemes">
{% for theme in _moreThemes %}
{{ _self.theme_tile(theme, false) }}
{% endfor %}
</div>
<input type="hidden" data-settings--appearance-target="theme" data-settings--appearance-field="theme" value="{{ a.theme.value }}">
</section>
{# ── Logo ──────────────────────────────────────────────────────
Two steps: the icon, then its colour.
The icon is the pl mark or one of the nine motifs beside it
(LogoMotif), each tile drawn in its own original design — the
mark in Berry, the product default and its own original. The
colour is then Original (a motif's own design, and what picking
a motif selects), the theme's namesake colourway, or one chosen
by hand from the thirty-two below. Original is not offered for
the mark: its original IS a colourway, so it would be a third
way of saying Berry.
Every tile is an <img> from the icon route rather than inline
markup, the colourways included, so the colourway grid can show
whichever icon is chosen — picking a motif swaps each tile's src
in place (pickMotif) and nothing else moves. Each colourway tile
still carries its stroke lists (light and dark chrome) as JSON:
the topbar's pl mark is inline, and repaintLogo paints it from
them without the JS knowing what a stroke is — see
_logo_mark.html.twig for the indexing contract.
Four hidden fields carry the whole choice to save(): logoMotif,
logoOriginal, logoLinked and logoStyle. All four are kept
whatever is chosen, so going from the mark to the horn and back
leaves the mark's colourway exactly where it was.
Selection is a ring, exactly as on the theme tiles, and
aria-pressed, so it is announced as well as drawn. #}
{% set _motif = a.logoMotif %}
{% set _classic = _motif.value == 'pl' %}
{% set _logoMode = not _classic and a.logoOriginal ? 'original' : (a.logoLinked ? 'linked' : 'independent') %}
{% set _iconTile = 'group flex flex-col items-center gap-2 p-2 rounded-pane cursor-pointer hover:bg-hover transition-colors' %}
{# `rounded-[22%]` is the tile's own corner (16 of 72 units, see
branding/icon.svg.twig), so the hairline follows the picture's
edge — a pale icon on a pale card has no other edge to read. #}
{% set _iconImage = 'size-12 rounded-[22%] ring-1 ring-line' %}
<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>
{# Step one. `data-logo-icon` is the tile's own URL — the icon
route for this motif in its original design, versioned — and
the one URL the script builds every other tile URL from: a
paint is the last path segment, so a colourway is that URL
with `original.svg` swapped (see iconUrl). The other three
are the same motif's tab icon and its top-bar logo on light
and dark chrome, which the script builds the same way for
whatever is picked. #}
<div class="mb-6">
<span class="block text-xs font-medium text-ink-muted mb-1.5" id="logo-motif-label">
{{ 'settings.appearance.logo.icon'|trans }}
</span>
<div class="grid grid-cols-[repeat(auto-fill,minmax(5.5rem,1fr))] gap-3"
role="group" aria-labelledby="logo-motif-label">
{# The installed app's URLs carry the user's key; see AppIconStorage. #}
{% set _app_icon_key = app_icons().key %}
{% for motif in enum('App\\Domain\\Enum\\Theme\\LogoMotif').cases %}
{% set _url = logo_icon_url(motif) %}
<button type="button"
data-logo-motif="{{ motif.value }}"
data-logo-icon="{{ _url }}"
data-logo-favicon="{{ logo_favicon_url(motif) }}"
data-logo-bleed="{{ logo_bleed_url(motif) }}"
data-logo-manifest="{{ logo_manifest_url(motif, null, _app_icon_key) }}"
data-logo-apple-touch="{{ logo_app_icon_url('bleed-180', _app_icon_key, motif) }}"
data-logo-bar="{{ logo_url(motif) }}"
data-logo-bar-dark="{{ logo_url(motif, null, true) }}"
data-action="click->settings--appearance#pickMotif"
aria-pressed="{{ motif == _motif ? 'true' : 'false' }}"
class="{{ _iconTile }} {{ motif == _motif ? 'ring-2 ring-accent' : '' }}">
<img src="{{ _url }}" alt="" width="48" height="48" class="{{ _iconImage }}" decoding="async">
<span class="text-xs text-ink-soft text-center leading-tight">{{ ('settings.appearance.logo.motif.' ~ motif.value)|trans }}</span>
</button>
{% endfor %}
</div>
<input type="hidden" data-settings--appearance-field="logoMotif"
data-settings--appearance-target="logoMotif" value="{{ _motif.value }}">
</div>
{# Step two. Original is `hidden` — the attribute — while the
icon is the mark, and a hidden grid item takes no column, so
the group is two segments then and three otherwise. While
linked the colour is the theme's: picking a theme above
repaints it live (see pickTheme). The colourway grid is
hidden rather than removed outside Independent: repaintLogo
still reads the strokes off its tiles, and choosing
Independent reveals it exactly as it was left. #}
<div class="mb-4">
<span class="block text-xs font-medium text-ink-muted mb-1.5" id="logo-paint-label">
{{ 'settings.appearance.logo.colour'|trans }}
</span>
<div class="{{ segGroup }}" role="radiogroup" aria-labelledby="logo-paint-label">
<label class="cursor-pointer" data-settings--appearance-target="logoOriginalOption" {{ _classic ? 'hidden' : '' }}>
<input type="radio" name="logoMode" class="peer sr-only" value="original"
data-action="change->settings--appearance#pickLogoMode"
aria-label="{{ 'settings.appearance.logo.original'|trans }}"
{{ _logoMode == 'original' ? 'checked' : '' }}>
<span class="{{ segText }}">{{ 'settings.appearance.logo.original'|trans }}</span>
</label>
<label class="cursor-pointer">
<input type="radio" name="logoMode" class="peer sr-only" value="linked"
data-action="change->settings--appearance#pickLogoMode"
aria-label="{{ 'settings.appearance.logo.linked'|trans }}"
{{ _logoMode == 'linked' ? 'checked' : '' }}>
<span class="{{ segText }}">{{ 'settings.appearance.logo.linked'|trans }}</span>
</label>
<label class="cursor-pointer">
<input type="radio" name="logoMode" class="peer sr-only" value="independent"
data-action="change->settings--appearance#pickLogoMode"
aria-label="{{ 'settings.appearance.logo.independent'|trans }}"
{{ _logoMode == 'independent' ? 'checked' : '' }}>
<span class="{{ segText }}">{{ 'settings.appearance.logo.independent'|trans }}</span>
</label>
</div>
<input type="hidden" data-settings--appearance-field="logoOriginal"
data-settings--appearance-target="logoOriginal" value="{{ a.logoOriginal ? '1' : '0' }}">
<input type="hidden" data-settings--appearance-field="logoLinked"
data-settings--appearance-target="logoLinked" value="{{ a.logoLinked ? '1' : '0' }}">
</div>
{# `loading="lazy"` does the work here: outside Independent the
grid is display:none, and a lazy image that is not rendered
is not fetched — so the thirty-two cost nothing until
somebody opens them. #}
<div class="grid grid-cols-[repeat(auto-fill,minmax(5.5rem,1fr))] gap-3"
role="group" aria-labelledby="logo-paint-label"
data-settings--appearance-target="logoGrid" {{ _logoMode != 'independent' ? 'hidden' : '' }}>
{% 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"
aria-pressed="{{ a.logoStyle == style ? 'true' : 'false' }}"
class="{{ _iconTile }} {{ a.logoStyle == style ? 'ring-2 ring-accent' : '' }}">
<img data-logo-paint="{{ style.value }}" src="{{ logo_icon_url(_motif, style) }}" alt=""
width="48" height="48" class="{{ _iconImage }}" loading="lazy" decoding="async">
<span class="text-xs text-ink-soft text-center leading-tight">{{ ('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
live in PHP only (Layout::defaults) — picking one moves the
sliders below. #}
<section>
<div class="mb-4">
<h3 class="text-sm font-semibold text-ink" id="layout-label">{{ 'settings.appearance.layout.heading'|trans }}</h3>
<p class="text-xs text-ink-faint mt-0.5">{{ 'settings.appearance.layout.subheading'|trans }}</p>
</div>
{# A segmented group like every other short list on this page.
It was the last <select> left here, which is the same
complaint the note at the top of this file records about the
typeface control: two ways to make the same kind of choice,
sitting one above the other. Two options do not need a
dropdown to hide one of them.
The per-option data attributes move onto the inputs; save()
already skips unchecked radios, so it needed no change. #}
<div class="{{ segGroup }}" role="radiogroup" aria-labelledby="layout-label">
{% for layout in enum('App\\Domain\\Enum\\Theme\\Layout').cases %}
<label class="cursor-pointer">
<input type="radio" name="layout" class="peer sr-only"
value="{{ layout.value }}"
data-settings--appearance-field="layout"
data-layout-class="{{ layout.htmlClass ?? '' }}"
data-layout-defaults="{{ layout.defaults|json_encode }}"
data-action="change->settings--appearance#pickLayout"
aria-label="{{ ('settings.appearance.layout.' ~ layout.value)|trans }}"
{{ a.layout == layout ? 'checked' : '' }}>
<span class="{{ segText }}">
{{ ('settings.appearance.layout.' ~ layout.value)|trans }}
</span>
</label>
{% endfor %}
</div>
</section>
{# ── Motion ────────────────────────────────────────────────────
Three steps rather than a checkbox, because the two complaints
about animation are separate dials: things appearing out of
nowhere with nothing to say they arrived, and waiting for the
interface to finish talking before you may use it. Minimal fades
without moving anything, which answers the first and costs
nothing against the second.
The tier travels on the radio as `data-motion-value` for the same
reason every density scale does: the live switch has to land
exactly where a reload would, and the enum is the one place the
values live. See App\Domain\Enum\Theme\MotionLevel. #}
<section>
<div class="mb-4">
{# The heading IS the group's accessible name — pointed at
rather than repeated. An `sr-only` span inside the
radiogroup would say the same word twice to a screen
reader, and it is also 1px-clipped by definition, which
preview-pane.spec.ts reads as 53px of overflow when it
checks that nothing in the controls column is cut off. #}
<h3 class="text-sm font-semibold text-ink" id="motion-label">{{ 'settings.appearance.motion.heading'|trans }}</h3>
<p class="text-xs text-ink-faint mt-0.5">{{ 'settings.appearance.motion.subheading'|trans }}</p>
</div>
<div class="{{ segGroup }}" role="radiogroup" aria-labelledby="motion-label">
{% for motion in enum('App\\Domain\\Enum\\Theme\\MotionLevel').cases %}
<label class="cursor-pointer">
<input type="radio" name="motion" class="peer sr-only"
value="{{ motion.value }}"
data-settings--appearance-field="motion"
data-motion-value="{{ motion.value }}"
data-motion-fast="{{ motion.fast }}"
data-motion-base="{{ motion.base }}"
data-motion-slow="{{ motion.slow }}"
data-motion-lift="{{ motion.lift }}"
data-motion-scale="{{ motion.scaleFrom }}"
data-motion-stagger="{{ motion.stagger }}"
data-action="change->settings--appearance#pickMotion"
aria-label="{{ ('settings.appearance.motion.' ~ motion.value)|trans }}"
{{ a.motion == motion ? 'checked' : '' }}>
<span class="{{ segText }}">
{{ ('settings.appearance.motion.' ~ motion.value)|trans }}
</span>
</label>
{% endfor %}
</div>
{# Said once, here, because it is the only place somebody would
look for it and be confused not to find it: the setting is
overruled by the operating system, not merged with it. #}
<p class="text-xs text-ink-faint mt-2">{{ 'settings.appearance.motion.reduced'|trans }}</p>
</section>
{# ── Density ───────────────────────────────────────────────── #}
<section>
<div class="mb-4">
<h3 class="text-sm font-semibold text-ink">{{ 'settings.appearance.density.heading'|trans }}</h3>
<p class="text-xs text-ink-faint mt-0.5">{{ 'settings.appearance.density.subheading'|trans }}</p>
</div>
<div class="space-y-3">
<div>
<span class="block text-xs font-medium text-ink-muted mb-1.5" id="density-global-label">
{{ 'settings.appearance.density.heading'|trans }}
</span>
<div class="{{ segGroup }}" role="radiogroup" aria-labelledby="density-global-label">
{% for density in enum('App\\Domain\\Enum\\Theme\\Density').cases %}
<label class="cursor-pointer">
{# Every padding scale travels on the radio, so the JS
resolving a surface has one place to read them from and
the numbers stay in the Density enum. The four sidebar
ones are its two row tiers, the space between rows and
the band above a section heading — see
settings--appearance's SURFACE_VARS, which maps each to
the custom property it feeds. #}
<input type="radio" name="density" class="peer sr-only"
value="{{ density.value }}"
data-settings--appearance-field="density"
data-row-y="{{ density.rowPadding }}"
data-tree-y="{{ density.treeRowPadding }}"
data-row-gap="{{ density.rowGap }}"
data-section-y="{{ density.sectionPadding }}"
data-gap="{{ density.gap }}"
data-list-row-y="{{ density.listRowPadding }}"
data-reading-row-y="{{ density.readingBlockPadding }}"
data-action="change->settings--appearance#pickDensity"
aria-label="{{ ('settings.appearance.density.' ~ density.value)|trans }}"
{{ a.density == density ? 'checked' : '' }}>
<span class="{{ segText }}">
{{ ('settings.appearance.density.' ~ density.value)|trans }}
</span>
</label>
{% endfor %}
</div>
</div>
{# ── Per surface ───────────────────────────────────────────
The one knob that can honestly differ between the three panes,
and the reason the others cannot is structural rather than
unfinished work: the list and the reading pane are ONE painted
surface (`.main-pane` wraps both and carries the background, the
blur, the border and the radius), so opacity, blur, corner
radius and the background scrim have no way to differ between
them without splitting that pane in two. Density can, because it
is padding inside rows rather than a property of the surface
under them.
Text size is global for a different reason: every size in the app
is a Tailwind `rem` utility, and `rem` resolves against the ROOT
— a font-size on one pane moves nothing inside it.
"Follow" rather than a duplicate of the global value, so a surface
that has not been given an opinion tracks the control above
instead of freezing at whatever it happened to be. #}
{% for surface in ['sidebar', 'list', 'reading'] %}
{% set current = attribute(a, surface ~ 'Density') %}
<div>
<span class="block text-xs font-medium text-ink-muted mb-1.5" id="surface-density-{{ surface }}">
{{ ('settings.appearance.density.surface.' ~ surface)|trans }}
</span>
<div class="{{ segGroup }}"
role="radiogroup" aria-labelledby="surface-density-{{ surface }}">
<label class="cursor-pointer">
<input type="radio" name="{{ surface }}Density" class="peer sr-only" value=""
data-settings--appearance-field="{{ surface }}Density"
data-surface-density="{{ surface }}"
data-action="change->settings--appearance#pickSurfaceDensity"
aria-label="{{ ('settings.appearance.density.surface.' ~ surface)|trans }} — {{ 'settings.appearance.density.follow'|trans }}"
{{ current is null ? 'checked' : '' }}>
<span class="{{ segText }}">
{{ 'settings.appearance.density.follow'|trans }}
</span>
</label>
{% for density in enum('App\\Domain\\Enum\\Theme\\Density').cases %}
<label class="cursor-pointer">
<input type="radio" name="{{ surface }}Density" class="peer sr-only"
value="{{ density.value }}"
data-settings--appearance-field="{{ surface }}Density"
data-surface-density="{{ surface }}"
data-action="change->settings--appearance#pickSurfaceDensity"
aria-label="{{ ('settings.appearance.density.surface.' ~ surface)|trans }} — {{ ('settings.appearance.density.' ~ density.value)|trans }}"
{{ current == density ? 'checked' : '' }}>
<span class="{{ segText }}">
{{ ('settings.appearance.density.' ~ density.value)|trans }}
</span>
</label>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
</section>
{# ── Mail list ─────────────────────────────────────────────────
What a row in the message list shows. Every default is what the
list did before these controls existed, so nothing moves for
anybody who never comes here. #}
<section>
<div class="mb-4">
<h3 class="text-sm font-semibold text-ink">{{ 'settings.appearance.list.heading'|trans }}</h3>
<p class="text-xs text-ink-faint mt-0.5">{{ 'settings.appearance.list.subheading'|trans }}</p>
</div>
<div class="space-y-4">
{# The two on/off settings. A checkbox cannot carry its own state into
the payload — save() reads `input.value`, which is "on" whether a
box is ticked or not — so each drives a hidden field holding "1" or
"0", the same pattern the ink and main-pane defaults use. #}
{% set toggles = [
{
field: 'accountCorner',
on: a.accountCorner,
vars: { on: { '--list-corner-display': 'block' }, off: { '--list-corner-display': 'none' } },
},
{
field: 'listAvatars',
on: a.listAvatars,
vars: { on: { '--list-avatar-hide': '0' }, off: { '--list-avatar-hide': '1' } },
},
{
field: 'tabUnreadCount',
on: a.tabUnreadCount,
vars: { on: { '--tab-unread-display': 'inline' }, off: { '--tab-unread-display': 'none' } },
},
] %}
{# Segmented like every other short choice on this page. The
radios are UI only — they carry no field attribute, so
save() never reads them; each change writes the hidden
field beside the group, which is what travels. #}
{% for toggle in toggles %}
<div>
<span class="block text-xs font-medium text-ink-muted" id="list-toggle-{{ toggle.field }}">
{{ ('settings.appearance.list.' ~ toggle.field)|trans }}
</span>
<span class="block text-[11px] text-ink-faint mt-0.5 mb-1.5">
{{ ('settings.appearance.list.' ~ toggle.field ~ 'Hint')|trans }}
</span>
<div class="{{ segGroup }}" role="radiogroup" aria-labelledby="list-toggle-{{ toggle.field }}">
{% for shown in [true, false] %}
<label class="cursor-pointer">
<input type="radio" name="{{ toggle.field }}Mode" class="peer sr-only"
value="{{ shown ? '1' : '0' }}"
data-toggles="{{ toggle.field }}"
data-css-vars-on="{{ toggle.vars.on|json_encode }}"
data-css-vars-off="{{ toggle.vars.off|json_encode }}"
data-action="change->settings--appearance#toggleListOption"
aria-label="{{ ('settings.appearance.list.' ~ toggle.field)|trans }} — {{ (shown ? 'settings.appearance.seg.shown' : 'settings.appearance.seg.hidden')|trans }}"
{{ toggle.on == shown ? 'checked' : '' }}>
<span class="{{ segText }}">
{{ (shown ? 'settings.appearance.seg.shown' : 'settings.appearance.seg.hidden')|trans }}
</span>
</label>
{% endfor %}
</div>
<input type="hidden" data-settings--appearance-field="{{ toggle.field }}"
data-toggle-field="{{ toggle.field }}" value="{{ toggle.on ? '1' : '0' }}">
</div>
{% endfor %}
{# Preview lines. The values are the CSS the row needs rather than the
count, for the reason AppearanceRenderer gives: nothing in CSS
derives "hidden" from a count, and `truncate`'s nowrap has to be
released for a second line to exist. Two is the ceiling because the
row has room for two. #}
<div>
<span class="block text-xs font-medium text-ink-muted mb-1.5" id="preview-lines-label">
{{ 'settings.appearance.list.previewLines'|trans }}
</span>
<div class="{{ segGroup }}"
role="radiogroup" aria-labelledby="preview-lines-label">
{% set lineOptions = {
0: { '--list-preview-display': 'none', '--list-preview-display-wide': 'none', '--list-preview-lines': '1', '--list-preview-wrap': 'nowrap' },
1: { '--list-preview-display': 'block', '--list-preview-display-wide': 'block', '--list-preview-lines': '1', '--list-preview-wrap': 'nowrap' },
2: { '--list-preview-display': '-webkit-box', '--list-preview-display-wide': 'block', '--list-preview-lines': '2', '--list-preview-wrap': 'normal' },
} %}
{% for lines, vars in lineOptions %}
<label class="cursor-pointer">
<input type="radio" name="previewLines" class="peer sr-only" value="{{ lines }}"
data-settings--appearance-field="previewLines"
data-css-vars="{{ vars|json_encode }}"
data-action="change->settings--appearance#applyVars"
aria-label="{{ ('settings.appearance.list.previewLines' ~ lines)|trans }}"
{{ a.previewLines == lines ? 'checked' : '' }}>
<span class="{{ segText }}">
{{ ('settings.appearance.list.previewLines' ~ lines)|trans }}
</span>
</label>
{% endfor %}
</div>
</div>
{# Unread emphasis. The BOLD WEIGHT is not part of this and does not
move: it survives a colour-blind reader, a photograph behind a
translucent pane and a printout, which the tint does not. What
varies is the tint's strength and, at Strong, an accent bar down the
row's leading edge. #}
<div>
<span class="block text-xs font-medium text-ink-muted mb-1.5" id="unread-emphasis-label">
{{ 'settings.appearance.list.unreadEmphasis'|trans }}
</span>
<div class="{{ segGroup }}"
role="radiogroup" aria-labelledby="unread-emphasis-label">
{% for emphasis in enum('App\\Domain\\Enum\\Theme\\UnreadEmphasis').cases %}
<label class="cursor-pointer">
<input type="radio" name="unreadEmphasis" class="peer sr-only" value="{{ emphasis.value }}"
data-settings--appearance-field="unreadEmphasis"
data-css-vars="{{ { '--unread-emphasis': emphasis.tintScale, '--unread-bar-w': emphasis.barWidth }|json_encode }}"
data-action="change->settings--appearance#applyVars"
aria-label="{{ ('settings.appearance.list.unread.' ~ emphasis.value)|trans }}"
{{ a.unreadEmphasis == emphasis ? 'checked' : '' }}>
<span class="{{ segText }}">
{{ ('settings.appearance.list.unread.' ~ emphasis.value)|trans }}
</span>
</label>
{% endfor %}
</div>
</div>
</div>
</section>
{# ── Typography ────────────────────────────────────────────────
The interface's typeface and size, and NOT the compose window's.
Those two pickers look alike and are opposites: the compose ones
write `style="font-family:…"` into the message HTML and go out
with the mail, these never leave the chrome. app.css pins the
editor's own size in px, outside the rem cascade this scale moves,
so composing at 1.25 still shows the size that will be sent. The
hint below says so, because nothing on screen otherwise would. #}
<section>
<div class="mb-4">
<h3 class="text-sm font-semibold text-ink">{{ 'settings.appearance.typography.heading'|trans }}</h3>
<p class="text-xs text-ink-faint mt-0.5">{{ 'settings.appearance.typography.subheading'|trans }}</p>
</div>
<div class="space-y-4">
{# Four families, which is a segmented control's length, so it is
one — matching every other short list on this page instead of
being the single <select> among them. Each option is drawn in
the face it names, which a <select> could not do for the
closed control and which makes the choice legible without
reading the labels at all. #}
<div>
<span class="block text-xs font-medium text-ink-muted mb-1.5" id="font-family-label">
{{ 'settings.appearance.typography.family'|trans }}
</span>
<div class="{{ segGroup }}"
role="radiogroup" aria-labelledby="font-family-label">
{% for family in enum('App\\Domain\\Enum\\Theme\\FontFamily').cases %}
<label class="cursor-pointer">
<input type="radio" name="fontFamily" class="peer sr-only" value="{{ family.value }}"
data-settings--appearance-field="fontFamily"
data-css-vars="{{ { '--app-font-family': family.stack }|json_encode }}"
data-action="change->settings--appearance#applyVars"
aria-label="{{ ('settings.appearance.typography.font.' ~ family.value)|trans }}"
{{ a.fontFamily == family ? 'checked' : '' }}>
<span class="{{ segText }}" style="font-family:{{ family.stack }}">
{{ ('settings.appearance.typography.font.' ~ family.value)|trans }}
</span>
</label>
{% endfor %}
</div>
</div>
<label class="block">
<span class="text-xs font-medium text-ink-muted">{{ 'settings.appearance.typography.scale'|trans }}</span>
{# The ends are Appearance::RANGE_FONT_SCALE, read from the constant
so the slider and the clamp cannot drift — the same reason the
JMAP session publishes them. #}
<input type="range"
min="{{ constant('App\\Entity\\Embeddable\\Appearance::RANGE_FONT_SCALE')['min'] }}"
max="{{ constant('App\\Entity\\Embeddable\\Appearance::RANGE_FONT_SCALE')['max'] }}"
step="0.025"
value="{{ a.fontScale }}"
data-settings--appearance-field="fontScale"
data-css-variable="--app-font-scale"
data-css-suffix=""
data-action="input->settings--appearance#slide"
aria-label="{{ 'settings.appearance.typography.scale'|trans }}"
class="w-full mt-2 accent-accent cursor-pointer">
<span class="block text-[11px] text-ink-faint mt-1">{{ 'settings.appearance.typography.scaleHint'|trans }}</span>
</label>
</div>
</section>
{# ── Accent ────────────────────────────────────────────────── #}
<section>
<div class="mb-4">
<h3 class="text-sm font-semibold text-ink">{{ 'settings.appearance.accent.heading'|trans }}</h3>
</div>
<div class="flex items-center gap-2 flex-wrap">
{% for hex in ['#2563eb', '#0d9488', '#7c3aed', '#db2777', '#ea580c', '#65a30d', '#0891b2', '#dc2626'] %}
<button type="button"
data-accent="{{ hex }}"
data-action="click->settings--appearance#pickAccent"
class="w-7 h-7 rounded-full border border-line cursor-pointer hover:scale-110 transition-transform"
style="background:{{ hex }}"></button>
{% endfor %}
<input type="color"
value="{{ a.accent }}"
data-settings--appearance-target="accent"
data-settings--appearance-field="accent"
data-action="input->settings--appearance#pickAccent"
class="w-7 h-7 rounded-full bg-transparent border border-line cursor-pointer">
</div>
</section>
{# ── Main pane ─────────────────────────────────────────────── #}
<section>
<div class="mb-4">
<h3 class="text-sm font-semibold text-ink">{{ 'settings.appearance.main.heading'|trans }}</h3>
<p class="text-xs text-ink-faint mt-0.5">{{ 'settings.appearance.main.subheading'|trans }}</p>
</div>
<div class="space-y-4">
{# Default/custom is a two-segment choice now, like every
other either/or on this page. The colour picker stays
live in both states: choosing a colour IS choosing
Custom, and pickMainTint flips the segment on the way
past — the exact behaviour the checkbox had. #}
<div>
<div class="flex items-center justify-between gap-3 mb-1.5">
<span class="text-xs font-medium text-ink-muted" id="main-tint-label">{{ 'settings.appearance.main.tint'|trans }}</span>
<input type="color" value="{{ a.mainTint ?? '#ffffff' }}"
data-settings--appearance-target="mainTint" data-action="input->settings--appearance#pickMainTint"
aria-label="{{ 'settings.appearance.main.tint'|trans }}"
class="w-7 h-7 rounded-full bg-transparent border border-line cursor-pointer">
</div>
<div class="{{ segGroup }}" role="radiogroup" aria-labelledby="main-tint-label">
<label class="cursor-pointer">
<input type="radio" name="mainTintMode" class="peer sr-only" value="default"
data-settings--appearance-target="mainTintDefault"
data-action="change->settings--appearance#toggleMainTintDefault"
aria-label="{{ 'settings.appearance.main.tint'|trans }} — {{ 'settings.appearance.seg.default'|trans }}"
{{ a.mainTint is null ? 'checked' : '' }}>
<span class="{{ segText }}">{{ 'settings.appearance.seg.default'|trans }}</span>
</label>
<label class="cursor-pointer">
<input type="radio" name="mainTintMode" class="peer sr-only" value="custom"
data-settings--appearance-target="mainTintCustom"
data-action="change->settings--appearance#toggleMainTintDefault"
aria-label="{{ 'settings.appearance.main.tint'|trans }} — {{ 'settings.appearance.seg.custom'|trans }}"
{{ a.mainTint is null ? '' : 'checked' }}>
<span class="{{ segText }}">{{ 'settings.appearance.seg.custom'|trans }}</span>
</label>
</div>
<input type="hidden" data-settings--appearance-field="mainTint" data-settings--appearance-target="mainTintField" value="{{ a.mainTint ?? '' }}">
</div>
{# Follow the glass, or carry an opacity of its own. The
'Follow' label is density's — the same word for the same
relationship — and the slider still disables while
following, exactly as it did under the checkbox. #}
<div>
<span class="block text-xs font-medium text-ink-muted mb-1.5" id="main-alpha-label">{{ 'settings.appearance.main.matchGlass'|trans }}</span>
<div class="{{ segGroup }} mb-2" role="radiogroup" aria-labelledby="main-alpha-label">
<label class="cursor-pointer">
<input type="radio" name="mainAlphaMode" class="peer sr-only" value="follow"
data-settings--appearance-target="mainAlphaMatch"
data-action="change->settings--appearance#toggleMainAlphaMatch"
aria-label="{{ 'settings.appearance.main.matchGlass'|trans }} — {{ 'settings.appearance.density.follow'|trans }}"
{{ a.mainAlpha is null ? 'checked' : '' }}>
<span class="{{ segText }}">{{ 'settings.appearance.density.follow'|trans }}</span>
</label>
<label class="cursor-pointer">
<input type="radio" name="mainAlphaMode" class="peer sr-only" value="custom"
data-settings--appearance-target="mainAlphaCustom"
data-action="change->settings--appearance#toggleMainAlphaMatch"
aria-label="{{ 'settings.appearance.main.matchGlass'|trans }} — {{ 'settings.appearance.seg.custom'|trans }}"
{{ a.mainAlpha is null ? '' : 'checked' }}>
<span class="{{ segText }}">{{ 'settings.appearance.seg.custom'|trans }}</span>
</label>
</div>
<input type="range" min="0.15" max="1" step="0.01" value="{{ a.mainAlpha ?? a.paneAlpha }}"
data-settings--appearance-target="mainAlpha" data-action="input->settings--appearance#slideMainAlpha"
aria-label="{{ 'settings.appearance.main.matchGlass'|trans }}"
class="w-full accent-accent cursor-pointer {{ a.mainAlpha is null ? 'opacity-40 pointer-events-none' : '' }}">
<input type="hidden" data-settings--appearance-field="mainAlpha" data-settings--appearance-target="mainAlphaField" value="{{ a.mainAlpha ?? '' }}">
</div>
</div>
</section>
{# ── Glass ─────────────────────────────────────────────────── #}
<section>
<div class="mb-4">
<h3 class="text-sm font-semibold text-ink">{{ 'settings.appearance.glass.heading'|trans }}</h3>
<p class="text-xs text-ink-faint mt-0.5">{{ 'settings.appearance.glass.subheading'|trans }}</p>
</div>
{# TWO opacity sliders, and the second one is not a refinement of
the first — it is the half the first was getting wrong.
`paneAlpha` is the layout: sidebar, top bar, main pane,
calendar. `popoverAlpha` is everything that floats over that
layout: the compose window, modals, menus, toasts. They used
to share one number, and a floating surface has one more
layer behind it than a pane does, so the same 0.7 that reads
as glass on the sidebar reads as the wallpaper coming through
the middle of a half-written message.
`hint` is what makes the pair legible as a pair: the labels
alone are two sliders both called Opacity, which is a worse
control than one. The other three carry no hint because
nothing about them is ambiguous.
The popover ends come off Appearance::RANGE_POPOVER_ALPHA
rather than being written here, the same way the text scale
reads its own constant: that floor of 0.5 is a design
decision with a paragraph behind it, and a slider that let
somebody drag past it and watch the server refuse would be
the drift the constant exists to prevent. #}
{% set popoverRange = constant('App\\Entity\\Embeddable\\Appearance::RANGE_POPOVER_ALPHA') %}
{% set sliders = [
{ field: 'paneAlpha', var: '--pane-alpha', suffix: '', min: 0.15, max: 1, step: 0.01, value: a.paneAlpha, hint: true },
{ field: 'popoverAlpha', var: '--popover-alpha', suffix: '', min: popoverRange.min, max: popoverRange.max, step: 0.01, value: a.popoverAlpha, hint: true },
{ field: 'paneBlur', var: '--pane-blur', suffix: 'px', min: 0, max: 60, step: 1, value: a.paneBlur },
{ field: 'radius', var: '--app-radius', suffix: 'rem', min: 0, max: 2, step: 0.125, value: a.radius },
{ field: 'scrimAlpha', var: '--scrim-alpha', suffix: '', min: 0, max: 0.7, step: 0.05, value: a.scrimAlpha },
] %}
<div class="space-y-4">
{% for slider in sliders %}
<label class="block">
<span class="text-xs font-medium text-ink-muted">{{ ('settings.appearance.glass.' ~ slider.field)|trans }}</span>
{% if slider.hint is defined %}
<span class="block text-[11px] text-ink-faint mt-0.5">
{{ ('settings.appearance.glass.' ~ slider.field ~ 'Hint')|trans }}
</span>
{% endif %}
<input type="range"
min="{{ slider.min }}" max="{{ slider.max }}" step="{{ slider.step }}"
value="{{ slider.value }}"
data-settings--appearance-field="{{ slider.field }}"
data-css-variable="{{ slider.var }}"
data-css-suffix="{{ slider.suffix }}"
data-action="input->settings--appearance#slide"
class="w-full mt-2 accent-accent cursor-pointer">
</label>
{% endfor %}
</div>
</section>
{# ── Text ──────────────────────────────────────────────────── #}
<section>
<div class="mb-4">
<h3 class="text-sm font-semibold text-ink">{{ 'settings.appearance.text.heading'|trans }}</h3>
<p class="text-xs text-ink-faint mt-0.5">{{ 'settings.appearance.text.subheading'|trans }}</p>
</div>
<div class="space-y-4">
{# Same shape as the main-pane tint above: the segments say
default or custom, the picker stays live, and choosing a
colour flips the segment to Custom (see pickInk). #}
<div>
<div class="flex items-center justify-between gap-3 mb-1.5">
<span class="text-xs font-medium text-ink-muted" id="ink-mode-label">{{ 'settings.appearance.text.color'|trans }}</span>
<input type="color" value="{{ a.inkColor ?? '#27272a' }}"
data-settings--appearance-target="inkColor" data-action="input->settings--appearance#pickInk"
aria-label="{{ 'settings.appearance.text.color'|trans }}"
class="w-7 h-7 rounded-full bg-transparent border border-line cursor-pointer">
</div>
<div class="{{ segGroup }}" role="radiogroup" aria-labelledby="ink-mode-label">
<label class="cursor-pointer">
<input type="radio" name="inkMode" class="peer sr-only" value="default"
data-settings--appearance-target="inkDefault"
data-action="change->settings--appearance#toggleInkDefault"
aria-label="{{ 'settings.appearance.text.color'|trans }} — {{ 'settings.appearance.seg.default'|trans }}"
{{ a.inkColor is null ? 'checked' : '' }}>
<span class="{{ segText }}">{{ 'settings.appearance.seg.default'|trans }}</span>
</label>
<label class="cursor-pointer">
<input type="radio" name="inkMode" class="peer sr-only" value="custom"
data-settings--appearance-target="inkCustom"
data-action="change->settings--appearance#toggleInkDefault"
aria-label="{{ 'settings.appearance.text.color'|trans }} — {{ 'settings.appearance.seg.custom'|trans }}"
{{ a.inkColor is null ? '' : 'checked' }}>
<span class="{{ segText }}">{{ 'settings.appearance.seg.custom'|trans }}</span>
</label>
</div>
<input type="hidden" data-settings--appearance-field="inkColor" data-settings--appearance-target="inkColorField" value="{{ a.inkColor ?? '' }}">
</div>
<div data-settings--appearance-target="inkDerived" class="space-y-2 {{ a.inkColor is null ? 'opacity-40 pointer-events-none' : '' }}">
<div class="flex items-center justify-between">
<span class="text-xs font-medium text-ink-muted">{{ 'settings.appearance.text.muted'|trans }} / {{ 'settings.appearance.text.faint'|trans }}</span>
<button type="button" data-action="click->settings--appearance#resetInkDerived"
class="text-[11px] text-accent hover:underline cursor-pointer">
{{ 'settings.appearance.text.autoDerive'|trans }}
</button>
</div>
<div class="flex items-center gap-3">
<input type="color" value="{{ a.inkMuted ?? '#71717a' }}"
data-settings--appearance-target="inkMuted" data-action="input->settings--appearance#pickInkMuted"
class="w-7 h-7 rounded-full bg-transparent border border-line cursor-pointer">
<input type="hidden" data-settings--appearance-field="inkMuted" data-settings--appearance-target="inkMutedField" value="{{ a.inkMuted ?? '' }}">
<input type="color" value="{{ a.inkFaint ?? '#a1a1aa' }}"
data-settings--appearance-target="inkFaint" data-action="input->settings--appearance#pickInkFaint"
class="w-7 h-7 rounded-full bg-transparent border border-line cursor-pointer">
<input type="hidden" data-settings--appearance-field="inkFaint" data-settings--appearance-target="inkFaintField" value="{{ a.inkFaint ?? '' }}">
</div>
</div>
</div>
</section>
{# ── Background ────────────────────────────────────────────── #}
<section>
<div class="mb-4">
<h3 class="text-sm font-semibold text-ink">{{ 'settings.appearance.background.heading'|trans }}</h3>
</div>
{# ── How a background applies without a reload ──────────────
Every tile below carries `data-bg-value`: the literal
`--app-bg` the server would write for that choice, produced
by BackgroundResolver itself through background_preset_css()
/ background_solid_css(). The controller sets what it finds
and never composes a value of its own.
An EMPTY data-bg-value means "remove the property", which is
what Theme is: AppearanceRenderer omits --app-bg entirely for
BackgroundKind::Theme so the stylesheet's own gradient for
the current theme takes over. Writing some theme's gradient
inline instead would look identical until the theme changed
underneath it. #}
<div class="grid grid-cols-3 @lg:grid-cols-5 gap-3 mb-4">
{# value="theme", not "preset", and `peer` like every other radio
here. Both were wrong, and together they made this tile a trap.
The controller posts EVERY appearance field on any change, so it
is not enough for this input to be read correctly — its value is
submitted whenever anything else on the page is touched. Saying
"preset" while being the tile that means "no picture" meant that
changing the density, or the accent, or the corner radius,
quietly rewrote backgroundKind from theme to preset. That is not
cosmetic: AppearanceRenderer floors --pane-alpha and --main-alpha
at 0.45 for any kind other than Theme, on the reasoning that
panel text over a photograph needs an opacity floor — so a user
running properly translucent panes had them jump opaque
app-wide, having touched nothing but the density.
And without `peer`, the sibling `peer-checked:ring-2` never
matched, so the tile never showed as selected even when it was —
which is why the wrong value went unnoticed. #}
<label class="cursor-pointer">
<input type="radio" name="backgroundKind" class="peer sr-only" value="theme"
data-settings--appearance-field="backgroundKind"
data-bg-value=""
data-action="change->settings--appearance#pickBackground"
{{ a.backgroundKind.value == 'theme' ? 'checked' : '' }}>
<span class="flex items-center justify-center h-16 rounded-pane border border-line text-xs text-ink-muted peer-checked:ring-2 peer-checked:ring-accent app-bg">
{{ 'settings.appearance.background.theme'|trans }}
</span>
</label>
{# The other end of the same group, with no tile of its own: picking
a PRESET is picking a preset thumbnail below, and pickBackground()
ticks this one on the way past so the kind travels with it. It has
to exist in the DOM for that lookup to find it.
Neither is ticked when the kind is `custom` or `solid` — an
uploaded picture has no tile here — and that is deliberate rather
than an omission: save() skips unchecked radios, so backgroundKind
is simply absent from the payload, and applyArray()'s isset()
guard leaves the stored kind alone. An uploaded background
therefore survives every later change to density, accent or
radius, which is exactly what the old single mislabelled radio
failed to do. #}
<input type="radio" name="backgroundKind" class="sr-only" value="preset"
data-settings--appearance-field="backgroundKind"
data-action="change->settings--appearance#pickBackground"
{{ a.backgroundKind.value == 'preset' ? 'checked' : '' }}>
{# A flat colour, which the domain has always supported —
BackgroundKind::Solid, Appearance::backgroundSolid, and
BackgroundResolver::solid() are all older than this tile
— and which nothing on this page could reach. The swatch
IS the tile: clicking it opens the colour picker and
ticks the kind on the way past, the same move
pickBackground() already makes for a preset. #}
{% set _solid = a.backgroundSolid ?? constant('App\\Service\\Appearance\\BackgroundResolver::DEFAULT_SOLID') %}
<input type="radio" name="backgroundKind" class="sr-only" value="solid"
data-settings--appearance-field="backgroundKind"
data-action="change->settings--appearance#pickBackground"
{{ a.backgroundKind.value == 'solid' ? 'checked' : '' }}>
<label class="cursor-pointer block relative">
<span class="sr-only">{{ 'settings.appearance.background.solid'|trans }}</span>
{# The SHAPE of the value, not the value: the colour
moves as the picker moves, so the controller
substitutes the hex into this rather than knowing
that a flat colour is spelled as a gradient. #}
<input type="color" value="{{ _solid }}"
data-settings--appearance-field="backgroundSolid"
data-settings--appearance-target="backgroundSolid"
data-bg-format="{{ background_solid_css('%s') }}"
data-action="input->settings--appearance#pickBackgroundSolid"
aria-label="{{ 'settings.appearance.background.solid'|trans }}"
class="peer absolute inset-0 w-full h-full opacity-0 cursor-pointer">
{# The caption sits in a chip rather than on the colour.
The colour is the user's and can be anything, so no
fixed ink is readable on it — a dark green label on a
dark green tile is the tile with no name at all. The
chip is the surface colour, which is the one thing
here that is guaranteed to carry the body ink. #}
<span class="flex items-end justify-center h-16 pb-1.5 rounded-pane border border-line
peer-focus-visible:ring-2 peer-focus-visible:ring-accent
{{ a.backgroundKind.value == 'solid' ? 'ring-2 ring-accent' : '' }}"
data-settings--appearance-target="backgroundSolidSwatch"
style="background-color:{{ _solid }}">
<span class="px-1.5 py-0.5 rounded bg-surface text-[10px] text-ink-soft">
{{ 'settings.appearance.background.solid'|trans }}
</span>
</span>
</label>
{% for preset in enum('App\\Domain\\Enum\\Theme\\BackgroundPreset').cases %}
<label class="cursor-pointer">
<input type="radio" name="backgroundPreset" class="peer sr-only" value="{{ preset.value }}"
data-settings--appearance-field="backgroundPreset"
data-bg-value="{{ background_preset_css(preset) }}"
data-action="change->settings--appearance#pickBackground"
{{ a.backgroundPreset == preset ? 'checked' : '' }}>
<span class="block h-16 rounded-pane border border-line bg-cover bg-center peer-checked:ring-2 peer-checked:ring-accent"
style="background-image:url('{{ asset(preset.thumbnail) }}')"></span>
</label>
{% endfor %}
</div>
<label class="inline-flex items-center gap-2 px-3 h-9 rounded-pane border border-line text-sm text-ink-muted hover:bg-hover cursor-pointer transition-colors">
<i class="fa-solid fa-upload text-xs" aria-hidden="true"></i>
{{ 'settings.appearance.background.upload'|trans }}
<input type="file" accept="image/jpeg,image/png,image/webp" class="sr-only"
data-settings--appearance-target="uploadInput"
data-action="change->settings--appearance#upload">
</label>
</section>
{# ── Import / export ─────────────────────────────────────────
Hidden inside the setup wizard: export sets a Content-Disposition,
and a download triggered from inside a turbo-frame either blows the
frame away or does nothing at all. Neither is worth showing someone
who has been in the app for ninety seconds. #}
{# `?? true`, not `|default(true)`: false is empty to the default filter, so
the wizard's `show_appearance_transfer: false` never took and the block
above showed there anyway. #}
{% if show_appearance_transfer ?? true %}
<section class="flex items-center gap-2 pt-4 border-t border-line">
<a href="{{ path('app_appearance_export') }}"
class="inline-flex items-center gap-2 px-3 h-9 rounded-pane border border-line text-sm text-ink-muted hover:bg-hover transition-colors">
<i class="fa-solid fa-download text-xs" aria-hidden="true"></i>
{{ 'settings.appearance.export'|trans }}
</a>
<label class="inline-flex items-center gap-2 px-3 h-9 rounded-pane border border-line text-sm text-ink-muted hover:bg-hover cursor-pointer transition-colors">
<i class="fa-solid fa-file-import text-xs" aria-hidden="true"></i>
{{ 'settings.appearance.import'|trans }}
<input type="file" accept="application/json" class="sr-only"
data-settings--appearance-target="importInput"
data-action="change->settings--appearance#importFile">
</label>
<button type="button" data-action="click->settings--appearance#reset"
class="ml-auto text-xs text-ink-faint hover:text-ink-muted cursor-pointer transition-colors">
{{ 'settings.appearance.reset'|trans }}
</button>
</section>
{% endif %}
</div>
</section>
{# ── Card two: the live preview ────────────────────────────────────
A mail list and a message, in miniature, wearing the settings on this
page. In its own column so it stays on screen while the controls that
drive it scroll past — the whole point is that nobody has to leave for
the inbox and come back to find out what a knob did.
SELF-CONTAINED by construction: not one line of it is fetched, and the
sample below is literal text, not a real thread. A preview that read
the inbox would be a second renderer of the mail list to keep in step
with the first, and it would put somebody's actual correspondence on a
settings page that a support screenshot is likely to capture.
It is styled by the SAME rules as the real list — data-surface="list",
data-account-corner, data-row-snippet, row-unread, list-row-y are the
real hooks from app.css, not a copy — so a preview that looks right
and a list that does not is a failure mode this cannot have.
`hidden @3xl:flex` is the state the SERVER renders, and above @3xl
it is the whole story: the preview is beside the controls and the
handle, which lives inside this element, comes with it. Below @3xl
the class is toggled by togglePreview() — removed to show, added back
to hide — rather than a `flex` being switched on over the top of it,
because `.hidden` out-orders `.flex` in the built stylesheet and a
toggle written that way looks exactly like broken JavaScript.
The HANDLE carries its own `hidden @3xl:flex` now that this wrapper
no longer does. Left behind when the layout stacks it would be a grab
target for a boundary that has no second side.
ORDER-FIRST below @3xl, and sticky with it. Stacked in DOM order the
preview would appear some 2700px below the control that revealed it,
which is a disclosure that discloses nothing; first in the stack and
pinned to the top of the scroll port, it stays on screen while the
controls it explains scroll underneath. Above @3xl neither applies:
the columns are side by side.
BETWEEN @3xl and 80rem the card inside is still `sticky`, because the
row is still as tall as the controls card and something has to keep
the miniature on screen. At 80rem and up the row is capped instead
and the card simply fills it — `pane-fill-card` retires the sticky,
since a card that is already the height of the row has nowhere left
to travel. #}
<aside id="appearance-preview-region"
data-settings--appearance-target="previewRegion"
class="hidden @3xl:flex min-w-0
order-first sticky top-0 z-10
@3xl:order-none @3xl:static @3xl:z-auto">
{# The handle, and it is now the calendar's handle down to the
pixel: `-ml-gutter w-[calc(var(--spacing-gutter)*2)]`, the same
expression _calendar_pane.html.twig carries and for the reason
its comment gives. Two gutters wide and pulled left by one, so
the grip lands in the MIDDLE of the space between the two cards
rather than to one side of it — the row's own `gap-gutter` falls
to this handle's left, so a handle merely one gutter wide would
put its grip one and a half gutters from the controls and half a
gutter from the preview.
It used to be `-ml-6 w-12` against a `gap-6` row. That is the
same formula at twice the scale and it was centred too; what it
was not was the mail shell's channel. Written in the gutter
token, this boundary and the one between the message list and the
calendar are the same 24px with the same two-pixel grip down the
middle, and they stay that way if the token ever moves.
role=separator with an orientation is what makes a boundary
reachable and meaningful without a mouse; arrow keys nudge it and
a double-click puts it back to the default, both the same actions
on the same controller. #}
<div
data-ui--split-target="handle"
data-action="pointerdown->ui--split#start keydown->ui--split#nudge dblclick->ui--split#reset"
role="separator"
aria-orientation="vertical"
aria-label="{{ 'settings.appearance.preview.resize'|trans }}"
tabindex="0"
class="group relative -ml-gutter w-[calc(var(--spacing-gutter)*2)] shrink-0
cursor-col-resize
hidden @3xl:flex items-center justify-center
focus:outline-none"
>
{# CENTRED, like the calendar's, which is a change this became
able to make rather than a preference. The grip used to be
`sticky top-24` because the handle was as tall as the
controls card — some 2700px — and a centred grip sat 1300px
below the fold. The row is capped to the scroll port now, so
the centre of the handle is on screen by construction and the
workaround is exactly what it looked like.
`.split-grip` is also what the strain state in app.css hooks,
and the controller drives it here too: dragging past either
limit flares the grip, which is the one thing that says "that
is the end" on a control two pixels wide. #}
<span class="split-grip w-0.5 h-8 rounded-full bg-line
group-hover:bg-accent group-focus-visible:bg-accent" aria-hidden="true"></span>
</div>
{# `w-full` below @3xl: stacked, the preview card is as wide as the
settings card, not 304px of a 320px screen. The stored width only
describes a boundary, and below @3xl there is no boundary. #}
<div data-ui--split-target="pane"
class="min-w-0 w-full @3xl:w-[var(--appearance-preview-width)]
pane-fill-stack">
{% include 'settings/_appearance_preview.html.twig' %}
</div>
</aside>
</div>
</div>