mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 10:04:18 +00:00
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>
1378 lines
95 KiB
Twig
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>
|