From aa0edabcbc312721dfc49f074e7e9124ed752f60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paul=20L=C3=BCtzner?= Date: Sat, 1 Aug 2026 07:31:18 +0200 Subject: [PATCH] Render mail on a paper sheet, and let the row's meta take the row's shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reading view is paper-coloured in every theme. An email's HTML was authored against a light background — senders set dark text and assume white behind it — so rendering it on a near-black surface is both harsh to read and a misrepresentation of what was sent. mail-sheet re-points the palette rather than just the background. Everything inside is written against the theme's tokens, and on a dark theme those are pale-on-dark: a border at white/0.09 is invisible on cream and a bg-sunken of white/0.07 lightens where it should darken. Setting the channels means every existing utility keeps working and nothing new has to know the sheet exists. The meta area now takes the shape of the row it sits beside, which is what the previous attempt got wrong by forcing one column at both widths. A stacked row is three lines tall and a vertical stack balances it; a wide row is one line, where the same stack has to spread out and leaves the date floating above a lone star with air between them. Column when narrow, single line when wide. The star follows Gmail: shown when starred, or when reached for. An outline star on every row is noise carrying no information, and it was most of what made the row feel empty. It stays put below md, where there is no hover to reveal it with. Order within the group is star then paperclip: the star is a control and sits inboard, the paperclip is only a fact about the row. The layout test reads flex-direction on the content block now. It used to assert on which of two date elements was displayed, and there is only one of each after this. Co-Authored-By: Claude Opus 5 --- assets/styles/app.css | 50 +++++++ templates/_partials/_thread_row.html.twig | 153 ++++++++++------------ templates/mail/_message_content.html.twig | 4 +- templates/mail/_thread_message.html.twig | 4 +- tests/e2e/calendar.spec.ts | 17 +-- 5 files changed, 128 insertions(+), 100 deletions(-) diff --git a/assets/styles/app.css b/assets/styles/app.css index 4f8934e1..33adccaa 100644 --- a/assets/styles/app.css +++ b/assets/styles/app.css @@ -45,6 +45,15 @@ --app-bg: linear-gradient(to bottom right, #eff6ff, #f1f5f9, #e0e7ff); --scrim-alpha: 0; + /* The sheet a message body is rendered on. Deliberately the same warm + paper in every theme: an email's HTML was authored against a light + background — senders set dark text and assume white behind it — so + rendering it on a near-black surface is both harsh to read and a + misrepresentation of what was sent. Themes may override it; none + currently need to. */ + --rgb-sheet: 247 245 239; + --rgb-sheet-ink: 35 34 32; + /* user-tunable — overwritten inline on by AppearanceRenderer */ --rgb-main: var(--rgb-surface); @@ -382,6 +391,47 @@ 0 4px 6px -4px rgb(var(--rgb-shadow) / var(--shadow-alpha)); } +@utility mail-sheet { + /* Re-points the whole palette, not just the background. + Everything inside here — dividers, muted labels, inset blocks, hover + states — is written against the theme's tokens, and on a dark theme + those are pale-on-dark: a border at white/0.09 is invisible on cream and + a bg-sunken of white/0.07 lightens where it should darken. Setting the + channels rather than restyling each descendant means every existing + utility keeps working and nothing new has to know about the sheet. */ + --rgb-surface: 247 245 239; + --rgb-main: 247 245 239; + --rgb-ink: 35 34 32; + --rgb-ink-soft: 82 78 72; + --rgb-ink-muted: 107 102 94; + --rgb-ink-faint: 150 144 134; + --rgb-line: 35 34 32; + --rgb-border: 35 34 32; + --rgb-raised: 35 34 32; + --rgb-sunken: 35 34 32; + --rgb-field: 35 34 32; + --rgb-field-border: 35 34 32; + --rgb-inverse: 35 34 32; + --rgb-inverse-ink: 247 245 239; + + --main-alpha: 1; + --line-alpha: 0.09; + --border-alpha: 0.10; + --raised-alpha: 0.04; + --sunken-alpha: 0.05; + --hover-alpha: 0.045; + --field-alpha: 0.03; + --field-border-alpha: 0.15; + --shadow-alpha: 0.06; + + background-color: rgb(var(--rgb-surface)); + color: rgb(var(--rgb-ink)); + + /* Plenty of mail leaves its links to the client, and an accent tuned for + the surrounding theme is not necessarily readable on paper. */ + & a:not([class*="text-"]):not([class*="bg-"]) { color: #1d4ed8; } +} + @utility app-bg { position: relative; background-image: var(--app-bg); diff --git a/templates/_partials/_thread_row.html.twig b/templates/_partials/_thread_row.html.twig index 7315a20b..bb0f29ce 100644 --- a/templates/_partials/_thread_row.html.twig +++ b/templates/_partials/_thread_row.html.twig @@ -134,20 +134,9 @@ the conversation it answers. aria-hidden="true"> - {# Star — wide layout only; the stacked one lives in the meta column - on the right, beside the time. #} - - {# Content — one line when the list is wide, three when it is not #} -
+
{# Row 1 (stacked) / sender column (wide) #}
@@ -175,82 +164,43 @@ the conversation it answers.
- {# Meta column — stacked layout only. - Mirrors the reference app: the time sits top-right and the row's - three states hang under it. The star is duplicated rather than moved - because the wide layout keeps it on the left, and only one of the two - is ever rendered — display:none takes the other out of the - accessibility tree, so a screen reader still finds exactly one. That - is safe here because starring POSTs and the server re-renders the - whole row; neither button holds state of its own. #} -
- - {% if date %} - {% if date|date('Y-m-d') == 'now'|date('Y-m-d') %} - {{ date|date('g:i a') }} - {% elseif date|date('Y') == 'now'|date('Y') %} - {{ date|date('M j') }} - {% else %} - {{ date|date('M j Y') }} + {# Meta column — one column at every width. + Date on top, then the star and the paperclip, then the unread dot. + The star is a control and sits inboard of the paperclip, which is + only a fact about the row; both are on the trailing edge where the + eye already is after reading the time. + + The hover actions still swap for the date, but only on the date's + own row and only where there is a hover to speak of — they used to + be absolutely positioned over the whole column, which is why the + star could not live here before. #} + {# The meta area takes the row's own shape. A stacked row is three + lines tall and a vertical stack sits beside it; a wide row is one + line, where the same stack has to spread out and leaves the date + floating over a lone star with air between them. So: column when + narrow, single line when wide. #} +
+ +
+ + {% if date %} + {% if date|date('Y-m-d') == 'now'|date('Y-m-d') %} + {{ date|date('g:i a') }} + {% elseif date|date('Y') == 'now'|date('Y') %} + {{ date|date('M j') }} + {% else %} + {{ date|date('M j Y') }} + {% endif %} {% endif %} - {% endif %} - - - {# Attachment and star share a line, star outermost — it is the one - you reach for, so it sits where the thumb already is, and the - paperclip is a fact about the row rather than a control. #} -
- {% if hasAttachment %} - - - {{ 'thread_row.has_attachment'|trans }} - - {% endif %} - - -
- - {# Unread, as a dot, on its own line. The bold text says it too, but - a dot is what you count down a column without reading anything. #} - {% if unread %} - - {{ 'thread_row.unread'|trans|default('Unread') }} - {% endif %} -
- - {# Date + hover actions slot — desktop only #} -
diff --git a/templates/mail/_message_content.html.twig b/templates/mail/_message_content.html.twig index 0ea3ea5d..d5e537b9 100644 --- a/templates/mail/_message_content.html.twig +++ b/templates/mail/_message_content.html.twig @@ -39,11 +39,11 @@
{% if message.bodyHtmlSafe %} -
+
{{ message.bodyHtmlSafe|raw }}
{% else %} -
+
{{ message.bodyText }}
{% endif %} diff --git a/templates/mail/_thread_message.html.twig b/templates/mail/_thread_message.html.twig index 3c136568..0bfc5843 100644 --- a/templates/mail/_thread_message.html.twig +++ b/templates/mail/_thread_message.html.twig @@ -120,11 +120,11 @@ Variables: {# ── Body — full pane width ─────────────────────────────────────────── #}
{% if message.bodyHtmlSafe %} -
+
{{ message.bodyHtmlSafe|raw }}
{% else %} -
{{ message.bodyText }}
+
{{ message.bodyText }}
{% endif %} {% if message.hasAttachments %} diff --git a/tests/e2e/calendar.spec.ts b/tests/e2e/calendar.spec.ts index 26c13960..de033d8a 100644 --- a/tests/e2e/calendar.spec.ts +++ b/tests/e2e/calendar.spec.ts @@ -259,15 +259,13 @@ test.describe("calendar pane", () => { await page.setViewportSize({ width: 1440, height: 900 }); await ensurePaneClosed(page); - const inlineDate = page.locator('[data-row-date="inline"]').first(); - const stackedDate = page.locator('[data-row-date="stacked"]').first(); + // The content block is what changes shape: a column when the list is + // narrow, a row when it is wide. Read on flex-direction rather than on + // visibility — the row's parts sit in zero-height positioning contexts + // that Playwright calls hidden either way. + const layout = page.locator("[data-row-layout]").first(); - // On display rather than visibility: the inline date column is laid out - // as a zero-height positioning context for an absolutely placed span, - // so Playwright calls it hidden either way. Which layout the container - // query picked is the actual subject here. - await expect(inlineDate).toHaveCSS("display", "flex"); - await expect(stackedDate).toHaveCSS("display", "none"); + await expect(layout).toHaveCSS("flex-direction", "row"); await page.locator("[data-calendar-toggle]").click(); await expect(page.locator("turbo-frame#calendar-pane-frame")).toBeVisible(); @@ -280,8 +278,7 @@ test.describe("calendar pane", () => { await page.mouse.move(20, box.y + box.height / 2, { steps: 12 }); await page.mouse.up(); - await expect(stackedDate).toHaveCSS("display", "flex"); - await expect(inlineDate).toHaveCSS("display", "none"); + await expect(layout).toHaveCSS("flex-direction", "column"); }); /**