Files
pl_mail/templates/settings/_appearance_preview.html.twig
T
6c719b3327 Give the mail row two lines between a phone's three and a wide list's one (#62)
A row went to one line once the list was 36rem wide, which with the reading pane open left the subject about 120px. One line now starts at 75rem. Below that the row is two lines, sender and subject then the preview across the whole row, and under 36rem the three it always was.

The hover actions show on any pointer that can hover, not only in one-line rows. A scheduled draft keeps its snippet beside the badge from 36rem. With the two-line preview setting on, a one-line row no longer wraps its preview.

Co-authored-by: Walid Mani <Flouss@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:19:17 +02:00

352 lines
23 KiB
Twig
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% import '_partials/_card_chrome.html.twig' as chrome %}
{#
The appearance preview: a mail list and a message, in miniature.
Its own file because it is markup, not logic — the panel around it is already
long — and because keeping it separate makes the one rule about it easy to
state and easy to check: NOTHING HERE IS FETCHED AND NOTHING HERE IS REAL. The
three rows are literal strings from the translation catalogue. There is no
query, no `messages` variable, no route.
What it is not: a second implementation of the thread row. It carries the same
hooks app.css drives the real row through (data-surface, data-account-corner,
data-row-avatar-face, data-row-snippet, row-unread, list-row-y, reading-y), so
the preview and the list are painted by one set of rules. It is a smaller
arrangement of the same parts, not a drawing of them — a drawing would be free
to be wrong.
`@container` on the list: the snippet's two-line clamp is a container query
matching the real list's `@rowwide`, and this pane is narrow, so the preview shows
the stacked behaviour — which is the one the second line exists for.
#}
{# Sticky within its own column rather than across the top of the panel: the
controls scroll past it, and it costs the panel width it has instead of
height it does not.
`top-0` sticks to the top of the settings pane's scroll port. The card's
ground is `bg-surface` FULLY OPAQUE, and the "fully" is the whole point:
this started life as `bg-surface/95 backdrop-blur`, which looked right and
measured wrong — the 5% that showed through lifted the ground under the
sample snippet enough to drop --rgb-ink-faint to 4.20:1 in Dark and 4.25:1
in Dusk. The theme guarantees that tier ≥4.5:1 ON --rgb-surface
(ThemeInkContrastTest holds every theme to it), so the guarantee only holds
where the surface is the surface. Stacked on a phone this card is pinned
over the controls scrolling beneath it, which is the case that makes an
opaque ground load-bearing rather than merely correct.
The heading and subtitle sit INSIDE the sticky card, above the coloured
block, so the thing and its name travel together — left in the main column
they would have labelled whatever control happened to scroll level with
them. They are the card's HEADER now, built the same way the settings card
beside it builds one, which is what makes the two read as peers.
`bg-surface` and a border, because this is a card in its own right and no
longer a bare column inside somebody else's. #}
{# `pane-fill-card` above 80rem: the card is exactly as tall as the row, which
is exactly as tall as the controls card beside it — the thing that was asked
for — and it retires the `sticky` above, which was how the card stayed on
screen while a 2500px row scrolled past it. Both halves matter: full height
ALONE, on a row still sized by the controls, is a 2500px card holding 500px
of miniature. The row is capped first (see pane-fill-host in
_appearance.html.twig) and the miniature grows into what is left (see
pane-fill-grow below), so what fills is the preview, not the ground under
it. #}
<section class="sticky top-0 {{ chrome.shell() }} pane-fill-card"
aria-label="{{ 'settings.appearance.preview.heading'|trans }}">
<div class="shrink-0 flex items-start justify-between gap-3 {{ chrome.band() }}">
<div class="min-w-0">
<h2 class="text-sm font-semibold text-ink">{{ 'settings.appearance.preview.heading'|trans }}</h2>
<p class="text-xs text-ink-faint mt-0.5">{{ 'settings.appearance.preview.subheading'|trans }}</p>
</div>
{# The second way out, and the reason it has to exist.
Below @3xl this card is PINNED over the controls, so the moment
anyone scrolls, the "Hide preview" in the settings card header goes
under it — the control that opened this would be buried by what it
opened. A revealed panel that cannot be dismissed from itself is a
trap on a screen this size.
Same action, same state: togglePreview() reads the region's own class
rather than either button's attribute, so the two controls cannot
disagree about what is on screen. `@3xl:hidden` because above that
width nothing is pinned and nothing is covered. #}
<button type="button"
data-appearance-preview-toggle
data-action="click->settings--appearance#togglePreview"
aria-controls="appearance-preview-region"
aria-expanded="true"
class="@3xl:hidden shrink-0 inline-flex items-center justify-center w-9 min-h-9
rounded-pane text-ink-faint hover:bg-hover hover:text-ink-muted
cursor-pointer transition-colors
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent">
<span class="sr-only">{{ 'settings.appearance.preview.hide'|trans }}</span>
<i class="fa-solid fa-xmark text-xs" aria-hidden="true"></i>
</button>
</div>
{# The body scrolls INSIDE the card below @3xl, and only there.
Stacked and pinned on a 320×568 screen, the scroll port under the top bar
and the settings header is 431px, not 568. An uncapped preview is 324 of
those and leaves room for a control and a half; capped at 30svh the card
is about 250 and leaves 180, which is two rows of segmented buttons — the
thing you came to watch change. The reading-pane half is still reachable
by scrolling the card itself. Above @3xl there is a whole column for it
and no cap at all. #}
<div class="px-4 py-4 max-h-[30svh] overflow-y-auto @3xl:max-h-none @3xl:overflow-visible
pane-fill-scroll pane-fill-stack">
{# aria-hidden, and this is deliberate rather than lazy. Every control on
this page has its own accessible name saying what it does; the preview
adds no information a screen-reader user can act on, and read out it is
three invented senders and a paragraph of filler between them and the
next control. The section keeps its own label so the region is still
announced and skippable. #}
{# `pane-fill-grow pane-fill-stack`: in a card that is now the height of
the row, the miniature GROWS into it rather than sitting at the top of
an empty box — the list keeps its three rows and the reading half
takes what is left, which is what the pane it is a picture of does.
It grows and never shrinks (`min-height` is left at `auto`), so on a
short window the card's own scroll is what gives, not the sample. #}
{# aria-hidden STAYS, and the rows being buttons does not change that.
Nothing in here goes anywhere: pressing a row swaps which sample
message is shown, which is a picture changing, not a place to be. A
screen-reader user gets no information from any of it that the
controls beside it do not already give them by name — and eight
invented senders in the tab order between one control and the next
would be worse than useless. `tabindex="-1"` on the buttons keeps
them out of it even though the subtree is hidden, because
aria-hidden and focusability disagreeing is the one combination that
produces a focus stop announcing nothing at all. #}
<div class="rounded-pane border border-line overflow-hidden
pane-fill-grow pane-fill-stack" aria-hidden="true">
{# THE LIST IS WHAT FILLS NOW, and the pair of utilities swapped
round to do it. It was `pane-fill-keep` — content height — with
the reading half taking everything left, which on a full-height
card put eight rows in 168px above 1,900px of one sample message.
The setting being watched is density, and density needs the list.
`min-h-0` is the load-bearing half of the swap: a flex item's
automatic minimum size is its content, so without it the list
would push the card taller instead of scrolling inside it. #}
<div data-surface="list" class="@container divide-y divide-line pane-fill-grow min-h-0 overflow-y-auto">
{# EIGHT, not three, and the number is the point rather than a
preference: this card is as tall as the controls beside it,
and three rows left most of that height empty — the setting
somebody is watching is DENSITY, which is a property of a
list long enough to have a rhythm. The list scrolls inside
itself once the card runs out, so a short window shows fewer
rows rather than a squashed card. #}
{% for row in [
{ unread: true, tone: 'bg-sky-500/20 text-sky-700 dark:text-sky-300' },
{ unread: true, tone: 'bg-violet-500/20 text-violet-700 dark:text-violet-300' },
{ unread: false, tone: 'bg-emerald-500/20 text-emerald-700 dark:text-emerald-300' },
{ unread: false, tone: 'bg-amber-500/20 text-amber-700 dark:text-amber-300' },
{ unread: false, tone: 'bg-rose-500/20 text-rose-700 dark:text-rose-300' },
{ unread: false, tone: 'bg-teal-500/20 text-teal-700 dark:text-teal-300' },
{ unread: false, tone: 'bg-indigo-500/20 text-indigo-700 dark:text-indigo-300' },
{ unread: false, tone: 'bg-lime-500/20 text-lime-700 dark:text-lime-300' },
] %}
{# `relative`, because the Strong emphasis bar is an ::before on
this element and an absolute child needs a positioned parent
— the same reason the real row carries it. #}
<div class="relative" data-unread="{{ row.unread ? 'true' : 'false' }}" data-preview-row>
{% if loop.index == 1 %}
<span data-account-corner
class="pointer-events-none absolute bottom-0 left-0 h-2.5 w-2.5 bg-sky-500 [clip-path:polygon(0_0,0_100%,100%_100%)]"></span>
{% endif %}
{# A button, so the row can be pressed to change which
sample the reading half shows. `w-full text-left`
because a button is neither by default and the row
has to keep the list's geometry exactly — this is the
same arrangement of the same parts, and a row that
centred itself would be a drawing of one. #}
<button type="button"
tabindex="-1"
data-preview-open="{{ loop.index }}"
data-action="click->settings--appearance#pickPreviewRow"
class="w-full text-left cursor-pointer flex items-center gap-3 pl-3 pr-3 list-row-y
{{ loop.index == 1 ? 'bg-hover' : '' }}
{{ row.unread ? 'row-unread' : 'bg-transparent' }}">
<span data-row-avatar-face
class="flex items-center justify-center rounded-full w-8 h-8 text-xs font-semibold select-none shrink-0 {{ row.tone }}">
{{ ('settings.appearance.preview.sender' ~ loop.index)|trans|first|upper }}
</span>
<span class="min-w-0 flex-1 flex flex-col">
<span class="truncate text-sm {{ row.unread ? 'font-semibold text-ink' : 'font-normal text-ink-muted' }}">
{{ ('settings.appearance.preview.sender' ~ loop.index)|trans }}
</span>
<span class="truncate text-sm mt-0.5 {{ row.unread ? 'font-semibold text-ink' : 'font-normal text-ink-soft' }}">
{{ ('settings.appearance.preview.subject' ~ loop.index)|trans }}
</span>
<span data-row-snippet class="truncate text-sm text-ink-faint mt-0.5">
{{ 'settings.appearance.preview.snippet'|trans }}
</span>
</span>
<span class="text-xs text-ink-faint shrink-0">{{ 'now'|date(clock.time) }}</span>
</button>
</div>
{% endfor %}
</div>
{# The reading pane half, so the per-surface density controls have
something to differ against. `mail-sheet` is the real utility: the
reading pane switches to the theme's light sheet in every theme, and
a preview that kept the app's surface would show the setting against
the wrong background. #}
{# And the reading half keeps its content height, so it sits under
the list at whatever size the sample needs rather than swallowing
the card. It still grows when the composer opens, which is the
one time it should. #}
<div data-surface="reading" class="mail-sheet border-t border-line px-3 pane-fill-keep">
{# One pane per row, all rendered, one shown. The alternative is
building the sample in JavaScript, which would put eight
senders and a body in a second place and translate them a
second time — the thing this file's own header refuses about
the thread row. Toggling `hidden` keeps every string in the
catalogue where it already is. #}
{% for row in [
{ tone: 'bg-sky-500/20 text-sky-700' },
{ tone: 'bg-violet-500/20 text-violet-700' },
{ tone: 'bg-emerald-500/20 text-emerald-700' },
{ tone: 'bg-amber-500/20 text-amber-700' },
{ tone: 'bg-rose-500/20 text-rose-700' },
{ tone: 'bg-teal-500/20 text-teal-700' },
{ tone: 'bg-indigo-500/20 text-indigo-700' },
{ tone: 'bg-lime-500/20 text-lime-700' },
] %}
<div class="reading-y {{ loop.index == 1 ? '' : 'hidden' }}"
data-preview-message="{{ loop.index }}">
<div class="flex items-start gap-2">
<span class="w-6 h-6 rounded-full text-[10px] font-semibold flex items-center justify-center shrink-0 {{ row.tone }}">
{{ ('settings.appearance.preview.sender' ~ loop.index)|trans|first|upper }}
</span>
<span class="min-w-0 flex-1">
<span class="block text-xs font-semibold text-ink truncate">
{{ ('settings.appearance.preview.sender' ~ loop.index)|trans }}
</span>
{# The recipient line and its chevron, the way a
real message carries them. Static: pressing
it opens the block below rather than a
popover, because a popover is positioned
against a viewport this card is a miniature
of and would hang outside it. #}
<button type="button"
tabindex="-1"
data-action="click->settings--appearance#togglePreviewDetails"
class="inline-flex items-center gap-1 text-[11px] text-ink-faint hover:text-ink-muted cursor-pointer transition-colors">
{{ 'settings.appearance.preview.to'|trans }}
<i class="fa-solid fa-chevron-down text-[8px]" aria-hidden="true"></i>
</button>
</span>
<span class="text-[11px] text-ink-faint shrink-0">{{ 'now'|date(clock.time) }}</span>
</div>
{# The header dropdown, closed. Same fields the real one
shows — from, to, date — so the density and type
settings have the same block to act on. #}
<dl data-preview-details
class="hidden mt-2 rounded-pane bg-hover/60 px-2.5 py-2 text-[11px] leading-relaxed">
{% for field in ['from', 'to', 'date'] %}
<div class="flex gap-2">
<dt class="w-10 shrink-0 text-ink-faint">{{ ('settings.appearance.preview.field_' ~ field)|trans }}</dt>
<dd class="min-w-0 flex-1 truncate text-ink-soft">
{# The date through intl_date and the time
through the user's 12/24-hour setting,
composed here — the two answer different
questions and only one is a property of
the language. Same split every date in
the app makes; see LocaleDateExtension. #}
{{ 'date' == field
? 'now'|intl_date('dMMMy') ~ ', ' ~ 'now'|date(clock.time)
: ('settings.appearance.preview.value_' ~ field)|trans }}
</dd>
</div>
{% endfor %}
</dl>
<p class="text-xs text-ink-soft mt-1.5 leading-relaxed">
{{ 'settings.appearance.preview.body'|trans }}
</p>
{# The composer, from the one place it can be opened in
a picture. Reply is the button a reader actually
reaches for, so it is the one that opens the sample
window below. #}
<div class="mt-2 flex gap-1.5">
<button type="button"
tabindex="-1"
data-action="click->settings--appearance#togglePreviewCompose"
class="inline-flex items-center gap-1.5 h-7 px-2.5 rounded-pane border border-line
text-[11px] text-ink-muted hover:bg-hover cursor-pointer transition-colors">
<i class="fa-solid fa-reply text-[9px]" aria-hidden="true"></i>
{{ 'settings.appearance.preview.reply'|trans }}
</button>
</div>
</div>
{% endfor %}
{# The compose window, closed, and OUTSIDE the per-message loop
so switching rows does not shut it — somebody comparing
densities wants it to stay put while they click about.
A miniature of the real window's parts, not the window: that
one is a Turbo frame with a live editor, a draft, an
attachment queue and its own controller, and none of it can
be rendered without a message to hang it on. What the
appearance settings actually change here is the sheet, the
border radius, the density and the type — all of which this
carries. #}
<div data-preview-compose
class="hidden mt-2 mb-3 rounded-pane border border-line bg-surface overflow-hidden">
<div class="flex items-center justify-between gap-2 px-2.5 py-1.5 border-b border-line">
<span class="text-[11px] font-semibold text-ink truncate">
{{ 'settings.appearance.preview.compose_heading'|trans }}
</span>
<button type="button"
tabindex="-1"
data-action="click->settings--appearance#togglePreviewCompose"
class="shrink-0 w-5 h-5 inline-flex items-center justify-center rounded-pane
text-ink-faint hover:bg-hover cursor-pointer transition-colors">
<i class="fa-solid fa-xmark text-[9px]" aria-hidden="true"></i>
</button>
</div>
<div class="px-2.5 py-2 space-y-1.5">
{% for field in ['to', 'subject'] %}
<div class="flex items-center gap-2 border-b border-line/60 pb-1">
<span class="w-10 shrink-0 text-[11px] text-ink-faint">
{{ ('settings.appearance.preview.field_' ~ field)|trans }}
</span>
<span class="min-w-0 flex-1 truncate text-[11px] text-ink-soft">
{{ ('settings.appearance.preview.compose_' ~ field)|trans }}
</span>
</div>
{% endfor %}
<p class="text-[11px] text-ink-soft leading-relaxed pt-0.5">
{{ 'settings.appearance.preview.compose_body'|trans }}
</p>
</div>
<div class="flex items-center gap-1.5 px-2.5 py-1.5 border-t border-line">
<span class="inline-flex items-center gap-1.5 h-6 px-2 rounded-pane bg-accent text-accent-ink text-[11px] font-medium">
<i class="fa-solid fa-paper-plane text-[8px]" aria-hidden="true"></i>
{{ 'settings.appearance.preview.compose_send'|trans }}
</span>
<span class="w-6 h-6 inline-flex items-center justify-center rounded-pane text-ink-faint">
<i class="fa-solid fa-paperclip text-[9px]" aria-hidden="true"></i>
</span>
</div>
</div>
</div>
</div>
</div>
</section>