Files
pl_mail/templates/_layout/app.html.twig
T
Paul LütznerandClaude Opus 5 0aa628ff32 fix: a shell that fits the phone, a menu that fits the pane, and tests that survive the fixture
**The mobile double scrollbar.** `app.css` sets `html, body { height: 100dvh }` because mobile
browsers measure `100vh` against the viewport with the URL bar HIDDEN, and its comment says the
shells ask for `h-full`. Three did not: the mailbox, settings and admin shells were `h-screen`, so
the shell was taller than the body containing it, the body scrolled to make room, and the page grew
a second scrollbar beside the message list's own. The contract was already written down; these were
the three places not honouring it.

**The demo bar covered the last row.** It is `fixed` on purpose — a banner in the flow costs every
page a strip of reading height — but the cost is that the last row sits behind it, which on a phone
is the row you were reaching for. Scrollers get its height back as clearance.

**The Label-as menu was sliced off inside a thread.** The reading pane is `overflow-hidden` and the
panel was `absolute`, so it was cut at the pane's edge with no way to scroll to the rest — the thing
clipping it is not the thing that scrolls.

`position: fixed` does NOT fix this, which is the part worth recording: a fixed element is
positioned against the nearest ancestor carrying a transform, filter or backdrop-filter, and the
boxed layout gives the panes a backdrop blur. Measured rather than assumed — the panel reported
`top: 125px` inline and rendered at 210, ending eighty pixels below a 420px viewport. So it stays
where it is and is sized to the box that clips it, flipping above the button when there is more room
there.

**More demo data, from using the demo.** There was HTML mail, but List-Unsubscribe correctly files
it under Promotions, so the only formatted mail sat behind a tab nobody clicks and the reading pane
looked as though it could render nothing but plain text. There is now one from a person, in Primary.
And there was no conversation at all — every thread a singleton, which leaves the reading pane's
whole argument with nothing to demonstrate on.

Threading that conversation had to be done one turn at a time. MessageThreader resolves a reply's
parent with a SQL query that joins message.thread, so a parent created earlier in the same UNFLUSHED
batch is invisible to it and all four turns looked like conversation openers. The code already says
as much where Gmail meets the same wall from the other side.

**Tests that survive the fixture growing** — CODESTYLE 9.5, and three specs brought in line. An
assertion that must be edited every time the seeded mailbox gains a thread is asserting that number
rather than the behaviour, and it fails in a spec whose subject has nothing to do with the change.
Baselines where there is a "before", a relation where there is not: every message is its own thread
EXCEPT the conversation, whose turns collapse.

Proved by mutation rather than by reading: seeding a fifth thread and re-running found a third
brittle spec I had not spotted — unread-view's hard-coded "3" — which now takes the badge's own
starting value. Absence checks and own-fixture counts are left alone, and the rule says why.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 13:51:45 +02:00

308 lines
18 KiB
Twig

<!DOCTYPE html>
{# `lang` is what tells a screen reader which voice to read the page in, and
what a browser offers to translate from. It was absent entirely, so assistive
technology fell back to the OS language and read a German UI in an English
voice.
The request locale, not the user's stored preference: they agree for a
signed-in user, and for the login page the request locale is the only one
there is. Underscores become hyphens because BCP 47 writes en-PI where
Symfony writes en_PI, and an invalid tag is ignored the same as a missing
one. #}
<html lang="{{ app.request.locale|replace({'_': '-'}) }}"
class="{{ appearance_class() }}"
data-theme="{{ appearance_theme() }}"
{# How much the interface may move. Server-rendered beside the theme and
for the same reason: a tier the browser learns only after JavaScript
has run is a first paint at the wrong one, which for motion means
every element on the page playing its entrance at once the moment the
setting arrives. See assets/styles/motion.css. #}
data-motion="{{ appearance_motion() }}"
{# The RUNTIME debug flag, read by stimulus_bootstrap.js. The Stimulus
bundle bakes its own debug flag into the compiled asset at build time,
so an asset built in a debug context ships ~1000 console log lines to a
prod runtime that is not in debug at all. This attribute carries the
value of the kernel actually serving the request — false in prod, true
in dev — so the bootstrap can correct the baked-in one. #}
data-app-debug="{{ app.debug ? 'true' : 'false' }}"
{# The user's twelve-or-twenty-four-hour choice, for the JavaScript that
prints a time. Twig has `clock.time` and reads the setting directly;
`Intl.DateTimeFormat` in the browser has no idea it exists and follows
the *locale's* default instead — which is how the send-later presets
came to say "8:00 AM" to someone who had asked for a 24-hour clock.
On <html> rather than threaded through each controller as a Stimulus
value because it is wanted in three unrelated places already (the
send-later menu, the snooze menu, and anything that formats a time
next) and none of them share a template. Same argument as ClockGlobal
being a Twig global rather than a controller variable. Read through
`prefersHour12()` in assets/clock_format.js. #}
data-clock-hour12="{{ clock.is12Hour ? 'true' : 'false' }}"
style="{{ appearance_vars() }}">
<head>
<meta charset="UTF-8">
{# Without this the browser assumes a ~980px desktop canvas and scales the
whole app down. `viewport-fit=cover` lets the gradient run under the
notch; the safe-area padding in app.css keeps content clear of it.
Zoom is NOT pinned. `maximum-scale=1, user-scalable=no` used to be here
to make the app feel installed rather than page-like; it also took away
pinch-zoom, which WCAG 1.4.4 requires and which is the only way some
people read anything at all. The reason it was added — iOS zooming when
a small form control takes focus — is already handled by the 16px
form-control rule in app.css, which is what actually prevents that; iOS
ignores user-scalable anyway, so this only ever disabled zoom on Android
where nothing was wrong.
`interactive-widget=resizes-content`: Android's default (resizes-visual)
shrinks only the visual viewport for the keyboard and leaves the layout
viewport — where fixed elements live — at full height, so the page
behind the fullscreen compose window stayed pannable and showed through
below it. Resizing the content instead means fixed elements end above
the keyboard and there is nothing to pan. iOS ignores this keyword; the
compose controller's visualViewport tracking stays as its fallback. #}
<meta name="viewport"
content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content">
<title>{% block title %}Welcome!{% endblock %}</title>
{#
Which counts key the "(n)" in the title above came from, or empty on a page
whose title carries no count.
The title is rendered once, server-side, while the sidebar badges are
patched in place after every sync — so the tab kept saying (4) long after
the sidebar had moved to 3. Naming the key here lets the sidebar controller
rewrite the title from the same payload it patches the badges with, which
makes "the same source" a fact rather than an intention.
#}
<meta name="title-count-key" content="{% block title_count_key %}{% endblock %}">
{% include '_partials/_favicon.html.twig' %}
{# PWA: iOS only offers Web Push to apps added to the Home Screen, and it
needs the apple-touch-icon specifically — it ignores the manifest icons. #}
<link rel="manifest" href="/manifest.webmanifest">
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
{# The token the fetch-based endpoints check. Stimulus controllers that
build their own request body cannot carry a form field, so they read this
and send it as X-CSRF-Token. Every such endpoint validates against the
same 'ajax' id — until this tag existed they all sent an empty string. #}
<meta name="csrf-token" content="{{ csrf_token('ajax') }}">
<meta name="theme-color" content="#a21caf">
{# Both spellings. `mobile-web-app-capable` is the standardised one every
engine now reads and the only one Chrome does not warn about; the Apple
prefix stays because older iOS is the platform that actually needs it,
and dropping it would take the home-screen behaviour with it on exactly
the devices the tag was written for. #}
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-title" content="plMail">
{# nonce, not 'unsafe-inline': this script has to run before first paint, so
it cannot move into a file, and a policy that allowed every inline script
in order to permit this one would not be worth sending. Same value as the
header — see App\Security\Csp\CspNonce. #}
<script nonce="{{ csp_nonce() }}">
// The appearance is rendered onto <html>, and the server only knows it
// once the user is authenticated. Mirror it into localStorage so the
// logged-out screens (login above all) already paint in the theme the
// user picked instead of the defaults.
(function () {
const root = document.documentElement;
const key = 'plmail:appearance';
try {
{% if app.user %}
localStorage.setItem(key, JSON.stringify({
class: root.className,
theme: root.dataset.theme,
style: root.getAttribute('style') || '',
}));
{% else %}
const saved = JSON.parse(localStorage.getItem(key) || 'null');
if (saved) {
root.className = saved.class;
root.dataset.theme = saved.theme;
// A custom background is served from behind the firewall,
// so it would 404 here; every other variable replays fine.
root.setAttribute('style', saved.style
.split(';')
.filter((part) => part.indexOf('/settings/appearance/') === -1)
.join(';'));
}
{% endif %}
} catch (e) {}
})();
if (document.documentElement.dataset.theme === 'system' &&
window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.documentElement.classList.add('dark');
}
// Collapsed-sidebar state lives in localStorage, so it has to be applied
// before first paint or the wide sidebar flashes on every load.
try {
if (localStorage.getItem('plmail:sidebarRail') === '1') {
document.documentElement.classList.add('sidebar-rail');
}
} catch (e) {}
</script>
{% block stylesheets %}
{# Linked here rather than imported from assets/app.js. That import is
implemented by AssetMapper as a data: JavaScript module which appends
this very tag at runtime — which delays the fetch until the module
graph has run, and, more importantly, forces `script-src … data:` on
any CSP worth having. See the note at the top of assets/app.js. #}
<link rel="stylesheet" href="{{ asset('styles/app.css') }}">
<link rel="stylesheet" href="{{ asset('vendor/@fortawesome/fontawesome-free/css/all.css') }}">
{% endblock %}
{% block javascripts %}
{# The import map and its preloads are inline scripts too, and the
whole application is loaded through them — an unnonced import map
under an enforced script-src is a blank page. #}
{% block importmap %}{{ importmap('app', { nonce: csp_nonce() }) }}{% endblock %}
{% endblock %}
{% set frankenphpHotReload = app.request.server.get('FRANKENPHP_HOT_RELOAD') %}
{% if frankenphpHotReload %}
<meta name="frankenphp-hot-reload:url" content="{{ frankenphpHotReload }}">
<script src="https://cdn.jsdelivr.net/npm/idiomorph"></script>
<script src="https://cdn.jsdelivr.net/npm/frankenphp-hot-reload/+esm" type="module"></script>
{% endif %}
</head>
{# One topic, named once: it is both what we subscribe to and what the cookie
authorizes, and the two drifting apart is a silent no-updates bug. #}
{% set mercure_topics = app.user ? ['mail/user/' ~ app.user.id] : [] %}
<body
{# ui--tooltip is outside the block on purpose: it replaces the native
`title` tooltip application-wide, so a page overriding body_controllers
must not be able to drop it and fall back to the black box. #}
{# mail--mail-pane is deliberately not in this default: its targets live in
the mailbox layout, and a controller that connects without them throws
on every page that has no reading pane — which is every admin, settings
and calendar page. The mailbox layout adds it back. #}
{# has-demo-bar: the fixed demo bar overlays the panes, so scrollers need
room to bring their last row clear of it. See app.css. #}
class="{% block body_class %}{% endblock %}{% if app.user and demo.isEnabled %} has-demo-bar{% endif %}"
data-controller="ui--tooltip {% if app.user %}{% block body_controllers %}core--mercure{% endblock %}{% endif %}"
{% if app.user %}
{# URL only. The cookie that authorizes this subscription is attached by
MercureCookieSubscriber — passing `subscribe:` here would mint it too,
but that helper throws for a hub on another domain and would take the
whole page down with it. #}
data-core--mercure-url-value="{{ mercure(mercure_topics) }}"
data-core--mercure-auth-url-value="{{ path('mercure_auth') }}"
{# The pane owns the list refresh, so the events that should suspend it
are routed here too: a bulk action writes one request per selected
row, and a refresh landing mid-run undoes the rows it has already
finished. See mail_pane_controller#hold. #}
data-action="core--mercure:mailbox-synced->mail--mail-pane#onMailboxSynced
core--mercure:account-synced->mail--mail-pane#onAccountSynced
mail--list-toolbar:writing->mail--mail-pane#hold
mail--list-toolbar:written->mail--mail-pane#release"
data-mail--mail-pane-open-value="{% block pane_open %}false{% endblock %}"
{% endif %}
>
{% block body %}{% endblock %}
{% block javascripts_body %}{% endblock %}
{% include '_partials/_modal.html.twig' %}
{# After the modal, and only ever for a signed-in user who has not been
through setup: it drives the dialog above, so that has to exist first. #}
{% if app.user %}
{% include 'onboarding/_boot.html.twig' %}
{% endif %}
{# The demo strip, for a signed-in visitor on an install that is a demo.
Inside the app.user check for the same reason the session-expired bar is:
it posts to a route that requires a mailbox to deliver into, so on the
login screen it would be a button that could only fail. #}
{% if app.user and demo.isEnabled %}
{% include '_partials/_demo_bar.html.twig' %}
{% endif %}
{# The dock's placement lives on this wrapper, never on the turbo-frame it
holds. The undo stream reopens the window with `action="replace"` on the
frame element itself, so any class on the frame is thrown away with the
element it replaces — and because the frame is the ONE the Compose button
and every draft row navigate into, a classless frame left behind put the
reopened window, and every dock window opened after it, in normal flow
below the fold until a full page reload restored this markup. Positioning
the stable wrapper means the frame can be swapped freely and the dock
stays where it belongs.
`z-50` here because a fixed element creates a stacking context: left at
`auto` the whole dock painted below the topbar (z-20) and the mobile
drawer (z-40), which the fullscreen compose window below md has to cover. #}
<div class="fixed bottom-4 right-6 z-50 flex items-end gap-3"
data-controller="compose--dock"
data-compose--dock-i18n-value="{{ {
failed: 'compose.open_failed.failed'|trans,
signedOut: 'compose.open_failed.signed_out'|trans,
offline: 'compose.open_failed.offline'|trans,
dismiss: 'compose.open_failed.dismiss'|trans,
}|json_encode }}">
{# Normally empty — the dock is filled by whatever link targets this
frame. The `compose` parameter is the one exception: a compose URL
reached as a PAGE rather than as a frame (a bookmark, a middle-click,
the back button after a session expired) is redirected to the mailbox
carrying the URL it was trying to open, and the frame fetches it from
here. Its request then has the Turbo-Frame header, so the controller
answers with the fragment exactly as it does for every other route
into the window.
Only our own compose paths, and only paths — the value ends up as a
`src` this page fetches, so anything else would be an open redirect
with our session attached. #}
{% set composeSrc = app.request.query.get('compose') %}
{% set composeSrc = composeSrc starts with '/compose/' and '//' not in composeSrc ? composeSrc : null %}
<turbo-frame id="compose_dock"{% if composeSrc %} src="{{ composeSrc }}" loading="eager"{% endif %}></turbo-frame>
</div>
{#
The toast region, and the flashes waiting in it.
Turbo-stream responses append into this element, which is how almost
every toast in the app arrives. What had NO route in was the flash bag:
`addFlash()` is called from a dozen controllers — the account-health
repairs among them — and nothing in any template ever read it, so every
one of those sentences was written into the session and thrown away
unread on the next request.
That is why the health repairs looked like they did nothing. A repair
POSTs, redirects, and says its piece through a flash; with no renderer
the page simply reappeared unchanged, which is indistinguishable from a
button that is not wired up.
Rendered server-side into the same region rather than through a stream,
because a redirect is not a stream response — there is nothing to append
with. Reading `app.flashes` here also CLEARS the bag, which is what stops
the message reappearing on the next navigation.
#}
{# Only for somebody who HAS a session to lose. Besides being meaningless
on the login and install screens, the bar carries a link to /login — and
the install flow asserts that a freshly restored instance with no users
offers no way to sign in, because there is nobody to sign in as. #}
{% if app.user %}
{% include '_partials/_session_expired.html.twig' %}
{% endif %}
{% include '_partials/_confirm_dialog.html.twig' %}
<div id="toast-region" class="fixed bottom-4 left-4 z-[60] flex flex-col gap-2 items-end">
{% for type, messages in app.flashes(['success', 'error', 'info']) %}
{% for message in messages %}
{# data-turbo-temporary so Turbo strips it from the snapshot it
caches. Without it the back button restores a page with the
toast painted back on, announcing a thing that happened two
navigations ago as though it had just happened. #}
<div data-turbo-temporary data-flash-toast="{{ type }}">
{# Already translated by whoever set it: the flash bag holds
sentences, not keys, because the controllers that fill it
interpolate names and counts into them. `extra` is passed
empty rather than omitted — the partial reads into it for
the undo affordance, which a flash never has. #}
{% include '_partials/_toast.html.twig' with { message: message, type: type, extra: {} } only %}
</div>
{% endfor %}
{% endfor %}
</div>
</body>
</html>