mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 10:04:18 +00:00
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:
1 parent
24e5e5c93b
commit
aa0edabcbc
5 files changed
+128
-100
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user