Render mail on a paper sheet, and let the row's meta take the row's shape

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 <noreply@anthropic.com>
This commit is contained in:
Paul LütznerandClaude Opus 5 committed 2026-08-01 07:31:18 +02:00
1 parent 24e5e5c93b
commit aa0edabcbc
5 files changed
+128 -100

No files matched your search

+50
View File
@@ -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 <html> 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);
+67 -86
View File
@@ -134,20 +134,9 @@ the conversation it answers.
aria-hidden="true"><i class="fa-solid fa-check text-xs"></i></span>
</label>
{# Star — wide layout only; the stacked one lives in the meta column
on the right, beside the time. #}
<button
type="button"
data-action="click->mail--message-row#toggleStar"
data-mail--message-row-star-url-param="{{ path('app_status_star', {'type': type, 'id': rowId}) }}"
class="hidden @xl:block z-10 pointer-events-auto cursor-pointer flex-shrink-0 text-ink-faint hover:text-amber-400 transition-colors {{ starred ? '!text-amber-400' : '' }}"
aria-label="{{ starred ? 'thread_row.unstar'|trans : 'thread_row.star'|trans }}"
>
<i class="fa-{{ starred ? 'solid' : 'regular' }} fa-star text-[18px]"></i>
</button>
{# Content — one line when the list is wide, three when it is not #}
<div class="flex-1 min-w-0 flex flex-col @xl:flex-row @xl:items-center @xl:gap-4">
<div data-row-layout
class="flex-1 min-w-0 flex flex-col @xl:flex-row @xl:items-center @xl:gap-4">
{# Row 1 (stacked) / sender column (wide) #}
<div class="flex items-center justify-between gap-2 @xl:justify-start @xl:w-40 @xl:flex-shrink-0">
@@ -175,82 +164,43 @@ the conversation it answers.
</div>
</div>
{# 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. #}
<div data-row-date="stacked"
class="@xl:hidden flex flex-col items-end gap-1.5 shrink-0 ml-2 self-start pt-0.5">
<span class="text-xs text-ink-faint whitespace-nowrap">
{% 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. #}
<div data-row-meta
class="flex flex-shrink-0 ml-2
flex-col items-end gap-1.5 self-start pt-0.5
@xl:flex-row-reverse @xl:items-center @xl:gap-2
@xl:self-center @xl:pt-0 @xl:w-36">
<div class="relative flex items-center justify-end w-full h-6 @xl:w-auto @xl:flex-1">
<span class="absolute inset-0 flex items-center justify-end
text-xs text-ink-faint whitespace-nowrap
@xl:group-hover:invisible">
{% 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 %}
</span>
{# 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. #}
<div class="flex items-center gap-2">
{% if hasAttachment %}
<span class="text-ink-faint" title="{{ 'thread_row.has_attachment'|trans }}">
<i class="fa-solid fa-paperclip text-xs" aria-hidden="true"></i>
<span class="sr-only">{{ 'thread_row.has_attachment'|trans }}</span>
</span>
{% endif %}
<button
type="button"
data-action="click->mail--message-row#toggleStar"
data-mail--message-row-star-url-param="{{ path('app_status_star', {'type': type, 'id': rowId}) }}"
class="z-10 pointer-events-auto cursor-pointer text-ink-faint hover:text-amber-400 transition-colors {{ starred ? '!text-amber-400' : '' }}"
aria-label="{{ starred ? 'thread_row.unstar'|trans : 'thread_row.star'|trans }}"
>
<i class="fa-{{ starred ? 'solid' : 'regular' }} fa-star text-sm"></i>
</button>
</div>
{# 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 %}
<span class="w-2 h-2 rounded-full bg-success" aria-hidden="true"></span>
<span class="sr-only">{{ 'thread_row.unread'|trans|default('Unread') }}</span>
{% endif %}
</div>
{# Date + hover actions slot — desktop only #}
<div data-row-date="inline"
class="hidden @xl:flex flex-shrink-0 w-36 relative h-full items-center">
<span class="absolute inset-0 flex items-center justify-end group-hover:invisible text-xs text-gray-400 dark:text-gray-600">
{% if hasAttachment %}
<span class="flex-shrink-0 mr-2 text-gray-400 dark:text-gray-600"
title="{{ 'thread_row.has_attachment'|trans }}">
<i class="fa-solid fa-paperclip text-[12px]" aria-hidden="true"></i>
<span class="sr-only">{{ 'thread_row.has_attachment'|trans }}</span>
</span>
{% endif %}
{% 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 %}
</span>
<div class="absolute inset-0 flex items-center justify-end invisible group-hover:visible pointer-events-auto gap-1">
<div class="hidden @xl:flex absolute inset-0 items-center justify-end invisible group-hover:visible pointer-events-auto gap-1">
{% if false == draft %}
<button type="button" data-action="click->mail--message-row#archive"
data-mail--message-row-archive-url-param="{{ path('app_status_archive', {'type': type, 'id': rowId}) }}"
@@ -305,6 +255,37 @@ the conversation it answers.
</button>
{% endif %}
</div>
</div>
<div class="flex items-center gap-2 @xl:order-first">
<button
type="button"
data-action="click->mail--message-row#toggleStar"
data-mail--message-row-star-url-param="{{ path('app_status_star', {'type': type, 'id': rowId}) }}"
class="z-10 pointer-events-auto cursor-pointer transition-colors
hover:text-amber-400
{{ starred
? 'text-amber-400'
: 'text-ink-faint opacity-0 group-hover:opacity-100 focus-visible:opacity-100 max-md:opacity-100' }}"
aria-label="{{ starred ? 'thread_row.unstar'|trans : 'thread_row.star'|trans }}"
>
<i class="fa-{{ starred ? 'solid' : 'regular' }} fa-star text-sm"></i>
</button>
{% if hasAttachment %}
<span class="text-ink-faint" title="{{ 'thread_row.has_attachment'|trans }}">
<i class="fa-solid fa-paperclip text-xs" aria-hidden="true"></i>
<span class="sr-only">{{ 'thread_row.has_attachment'|trans }}</span>
</span>
{% endif %}
</div>
{# Unread, as a dot. The bold text says it too, but a dot is what
you count down a column without reading anything. #}
{% if unread %}
<span class="w-2 h-2 rounded-full bg-success shrink-0 @xl:order-first" aria-hidden="true"></span>
<span class="sr-only">{{ 'thread_row.unread'|trans|default('Unread') }}</span>
{% endif %}
</div>
</div>
+2 -2
View File
@@ -39,11 +39,11 @@
</div>
{% if message.bodyHtmlSafe %}
<div class="text-sm overflow-x-auto">
<div class="mail-sheet text-sm overflow-x-auto">
{{ message.bodyHtmlSafe|raw }}
</div>
{% else %}
<div class="whitespace-pre-wrap text-sm text-ink-soft">
<div class="mail-sheet whitespace-pre-wrap text-sm">
{{ message.bodyText }}
</div>
{% endif %}
+2 -2
View File
@@ -120,11 +120,11 @@ Variables:
{# ── Body — full pane width ─────────────────────────────────────────── #}
<div data-mail--thread-message-target="body" class="{{ expanded ? '' : 'hidden' }} mt-4">
{% if message.bodyHtmlSafe %}
<div class="text-sm overflow-x-auto">
<div class="mail-sheet text-sm overflow-x-auto">
{{ message.bodyHtmlSafe|raw }}
</div>
{% else %}
<pre class="whitespace-pre-wrap break-words font-sans text-sm text-ink-soft">{{ message.bodyText }}</pre>
<pre class="mail-sheet whitespace-pre-wrap break-words font-sans text-sm">{{ message.bodyText }}</pre>
{% endif %}
{% if message.hasAttachments %}
+7 -10
View File
@@ -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");
});
/**