{# `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. #} 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() }}"> {# 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`: the 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. WHICH BROWSERS HONOUR IT HAS MOVED, and nothing here assumes an answer any more. This used to say "iOS ignores this keyword" and treated the compose controller's visualViewport tracking as the fallback for that. Safari has since shipped support, and a window sized to the visual viewport on a browser that HAS resized the layout viewport comes out shorter than the page behind it — which is a strip of mail list under the composer, in a screenshot from an iPhone none of us owns. So the controller no longer asks which platform it is on: it fills the layout viewport, whatever that currently is, and pads its own foot by however much of itself is covered. Both readings of this keyword are correct under that rule, which is why the rule names neither. See compose_controller#_trackViewport. #} {% block title %}Welcome!{% endblock %} {# 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. #} {% 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. #} {# 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. #} {# Turbo's snapshot policy for THIS page. Empty by default — most pages are perfectly happy being restored from cache on a back navigation. The mail lists are not; see _layout/_mailbox.html.twig. #} {% block turbo_cache_control %}{% endblock %} {# 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. #} {# 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. #} {% 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. #} {% 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 %} {% endif %} {# 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] : [] %} {% 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. `compose-dock` rather than `bottom-4`: the distance from the bottom is one rem PLUS whatever the virtual keyboard has taken — clamped, on a screen too short to move that far — and that sum lives in app.css because Tailwind has no arbitrary value for it. A fixed element is placed against the layout viewport, which iOS does not shrink for the keyboard, so without the correction an iPad put the dock window's send row underneath it. See `--keyboard-inset` and `--keyboard-lift` in app.css, and the compose controller's _trackViewport, which is what publishes the first of them. #}
{# 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 %}
{# 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' %}
{% 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. #}
{# 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 %}
{% endfor %} {% endfor %}