Files
6c719b3327 Give the mail row two lines between a phone's three and a wide list's one (#62)
A row went to one line once the list was 36rem wide, which with the reading pane open left the subject about 120px. One line now starts at 75rem. Below that the row is two lines, sender and subject then the preview across the whole row, and under 36rem the three it always was.

The hover actions show on any pointer that can hover, not only in one-line rows. A scheduled draft keeps its snippet beside the badge from 36rem. With the two-line preview setting on, a one-line row no longer wraps its preview.

Co-authored-by: Walid Mani <Flouss@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:19:17 +02:00

835 lines
40 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
version: alpha
name: plMail
description: >-
Self-hosted mail and calendar, drawn like correspondence on warm paper: one
flat cream sheet, soft near-black ink, pencil-weight hairlines instead of
shadows, and a single muted clay accent. The token values are the default
Paper theme with the Flat layout; every other theme restates the same roles.
colors:
# The spec's conventional names, pointing at plMail's own. The code says `accent`.
primary: "{colors.accent}"
on-primary: "{colors.accent-ink}"
# Surfaces. In code, raised / hover / sunken / line and the field tokens are
# ink (or white) at a low alpha; the hex here is that result flattened onto
# `surface`, for reference only.
app-bg: "#F2EFE7"
surface: "#F7F5EF"
raised: "#EFEDE7"
hover: "#F0EEE8"
sunken: "#EFEDE7"
line: "#E4E2DC"
# Ink, strongest first: what it is, what it is about, its metadata, its furniture.
ink: "#232220"
ink-soft: "#524E48"
ink-muted: "#5F5B54"
ink-faint: "#716C64"
# The one accent. It is a user setting in the app: use the role, never the hex.
accent: "#7D6B4F"
accent-strong: "#63553E"
accent-soft: "#EBE7DF"
accent-ink: "#FFFFFF"
# The accent as TEXT is derived from whatever accent is set, so it reads on every
# surface (see Colors). This is Paper's clay run through that derivation.
accent-text: "#685841"
field: "#FCFBF9"
field-border: "#D7D4CB"
danger: "#C12020"
danger-soft: "#F3E4DE"
warning: "#995203"
success: "#0D7433"
info: "#56667E"
info-soft: "#EAEAE6"
inverse: "#2D2B28"
inverse-ink: "#F7F5EF"
# The sheet a message body is rendered on. Light in every theme, dark ones included.
sheet: "#F7F5EF"
sheet-ink: "#232220"
sheet-link: "#1D4ED8"
typography:
headline-lg:
fontFamily: &sans 'ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif'
fontSize: 24px
fontWeight: 600
lineHeight: 32px
letterSpacing: -0.025em
headline-md:
fontFamily: *sans
fontSize: 20px
fontWeight: 600
lineHeight: 28px
title:
fontFamily: *sans
fontSize: 16px
fontWeight: 600
lineHeight: 24px
body-md:
fontFamily: *sans
fontSize: 14px
fontWeight: 400
lineHeight: 20px
body-strong:
fontFamily: *sans
fontSize: 14px
fontWeight: 600
lineHeight: 20px
body-reading:
fontFamily: *sans
fontSize: 14px
fontWeight: 400
lineHeight: 1.625
label-md:
fontFamily: *sans
fontSize: 14px
fontWeight: 500
lineHeight: 20px
label-sm:
fontFamily: *sans
fontSize: 12px
fontWeight: 500
lineHeight: 16px
caption:
fontFamily: *sans
fontSize: 12px
fontWeight: 400
lineHeight: 16px
micro:
fontFamily: *sans
fontSize: 11px
fontWeight: 400
lineHeight: 1.35
overline:
fontFamily: *sans
fontSize: 11px
fontWeight: 600
lineHeight: 16px
letterSpacing: 0.05em
badge:
fontFamily: *sans
fontSize: 10px
fontWeight: 500
lineHeight: 14px
mono:
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
fontSize: 14px
fontWeight: 400
lineHeight: 20px
rounded:
none: 0px
sm: 4px
control: 8px
pane: 12px
full: 9999px
spacing:
unit: 4px
xs: 4px
sm: 8px
md: 12px
lg: 16px
xl: 24px
gutter: 12px
row-y: 8px
list-row-y: 10px
reading-y: 16px
pane-header: 44px
control-sm: 32px
control-md: 36px
avatar: 36px
sidebar-rail: 56px
dialog-width: 448px
breakpoint-md: 768px
breakpoint-lg: 1024px
components:
button-primary:
backgroundColor: "{colors.accent}"
textColor: "{colors.accent-ink}"
typography: "{typography.label-md}"
rounded: "{rounded.control}"
height: "{spacing.control-md}"
padding: 0 16px
button-primary-hover:
backgroundColor: "{colors.accent-strong}"
textColor: "{colors.accent-ink}"
button-primary-sm:
backgroundColor: "{colors.accent}"
textColor: "{colors.accent-ink}"
typography: "{typography.label-sm}"
rounded: "{rounded.control}"
height: "{spacing.control-sm}"
padding: 0 10px
button-secondary:
backgroundColor: "{colors.field}"
textColor: "{colors.ink-soft}"
typography: "{typography.label-md}"
rounded: "{rounded.control}"
height: "{spacing.control-sm}"
padding: 0 14px
button-secondary-hover:
backgroundColor: "{colors.hover}"
textColor: "{colors.ink-soft}"
button-quiet:
backgroundColor: "#E6E4DE"
textColor: "{colors.ink-soft}"
typography: "{typography.label-sm}"
rounded: "{rounded.control}"
height: "{spacing.control-sm}"
padding: 0 10px
button-quiet-hover:
backgroundColor: "#D9D7D2"
textColor: "{colors.ink-soft}"
button-quiet-destructive-hover:
backgroundColor: "{colors.danger-soft}"
textColor: "{colors.danger}"
button-icon:
backgroundColor: "{colors.surface}"
textColor: "{colors.ink-faint}"
rounded: "{rounded.control}"
size: "{spacing.control-sm}"
button-icon-hover:
backgroundColor: "{colors.hover}"
textColor: "{colors.ink}"
input-field:
backgroundColor: "{colors.field}"
textColor: "{colors.ink}"
typography: "{typography.body-md}"
rounded: "{rounded.control}"
height: "{spacing.control-md}"
padding: 0 12px
chip-filter:
backgroundColor: "{colors.surface}"
textColor: "{colors.ink-muted}"
typography: "{typography.micro}"
rounded: "{rounded.control}"
height: 24px
padding: 0 8px
chip-filter-active:
backgroundColor: "#EDEAE2"
textColor: "{colors.accent-text}"
label-chip:
backgroundColor: "{colors.surface}"
textColor: "{colors.ink-muted}"
typography: "{typography.micro}"
rounded: "{rounded.full}"
padding: 1px 8px
count-pill:
backgroundColor: "{colors.accent}"
textColor: "{colors.accent-ink}"
typography: "{typography.label-sm}"
rounded: "{rounded.full}"
padding: 2px 6px
badge-danger:
backgroundColor: "{colors.danger-soft}"
textColor: "{colors.danger}"
typography: "{typography.badge}"
rounded: "{rounded.full}"
padding: 2px 8px
badge-warning:
backgroundColor: "#EFE8DC"
textColor: "{colors.warning}"
typography: "{typography.badge}"
rounded: "{rounded.full}"
padding: 2px 8px
badge-success:
backgroundColor: "#E4EBE0"
textColor: "{colors.success}"
typography: "{typography.badge}"
rounded: "{rounded.full}"
padding: 2px 8px
badge-info:
backgroundColor: "{colors.info-soft}"
textColor: "{colors.info}"
typography: "{typography.badge}"
rounded: "{rounded.full}"
padding: 2px 8px
badge-neutral:
backgroundColor: "#E6E4DE"
textColor: "{colors.ink-soft}"
typography: "{typography.badge}"
rounded: "{rounded.full}"
padding: 2px 8px
tag-neutral:
backgroundColor: "{colors.raised}"
textColor: "{colors.ink-muted}"
typography: "{typography.micro}"
rounded: "{rounded.full}"
padding: 2px 8px
inline-code:
backgroundColor: "{colors.sunken}"
textColor: "{colors.ink}"
typography: "{typography.mono}"
rounded: "{rounded.sm}"
padding: 0 4px
divider:
backgroundColor: "{colors.line}"
height: 1px
nav-row:
backgroundColor: "{colors.app-bg}"
textColor: "{colors.ink-muted}"
typography: "{typography.body-md}"
rounded: "{rounded.full}"
height: 36px
padding: 8px 12px
nav-row-hover:
backgroundColor: "{colors.hover}"
nav-row-active:
backgroundColor: "{colors.accent-soft}"
textColor: "{colors.accent-text}"
typography: "{typography.label-md}"
list-row:
backgroundColor: "{colors.surface}"
textColor: "{colors.ink-muted}"
typography: "{typography.body-md}"
padding: 10px 16px 10px 12px
list-row-unread:
backgroundColor: "#FAF9F5"
textColor: "{colors.ink}"
typography: "{typography.body-strong}"
list-row-hover:
backgroundColor: "#FCFBF9"
avatar:
backgroundColor: "#2F6B52"
textColor: "#FFFFFF"
typography: "{typography.body-strong}"
rounded: "{rounded.full}"
size: "{spacing.avatar}"
pane:
backgroundColor: "{colors.surface}"
textColor: "{colors.ink}"
rounded: "{rounded.pane}"
menu:
backgroundColor: "{colors.surface}"
textColor: "{colors.ink-muted}"
typography: "{typography.body-md}"
rounded: "{rounded.pane}"
padding: 4px 0
menu-row-hover:
backgroundColor: "{colors.hover}"
textColor: "{colors.ink}"
tooltip:
backgroundColor: "{colors.inverse}"
textColor: "{colors.inverse-ink}"
typography: "{typography.label-sm}"
rounded: "{rounded.control}"
padding: 6px 10px
dialog:
backgroundColor: "{colors.surface}"
textColor: "{colors.ink}"
rounded: "{rounded.pane}"
width: "{spacing.dialog-width}"
toast:
backgroundColor: "{colors.surface}"
textColor: "{colors.ink}"
typography: "{typography.label-md}"
rounded: "{rounded.pane}"
padding: 14px 16px
alert-danger:
backgroundColor: "{colors.danger-soft}"
textColor: "{colors.danger}"
typography: "{typography.body-md}"
rounded: "{rounded.control}"
padding: 12px 14px
mail-sheet:
backgroundColor: "{colors.sheet}"
textColor: "{colors.sheet-ink}"
typography: "{typography.body-reading}"
mail-sheet-link:
backgroundColor: "{colors.sheet}"
textColor: "{colors.sheet-link}"
search-hit:
backgroundColor: "#DCD7CC"
textColor: "{colors.ink}"
rounded: 3px
new-dot:
backgroundColor: "{colors.accent}"
rounded: "{rounded.full}"
size: 6px
---
# plMail
## Overview
plMail is a self-hosted mail and calendar client. The people who use it run the server themselves
and keep the app open all day in place of Gmail. It is a **fortieth-time interface**: whatever a
screen does, it will do forty more times before lunch, so it has to stay calm, fast and quiet.
It should look like **correspondence on good paper**: a sheet of warm, uncoated cream; soft
near-black ink, never pure black; rules drawn in pencil rather than boxes drawn in pen; and one
muted clay accent, reserved for the thing you are meant to press. The window is a single material.
The app background and the panes are two close shades of the same cream, so the mail list reads as
the page itself rather than as a card floating over a picture. Nothing glows or shouts, and the page
is flat: no gradient, no texture.
The standard of finish is the Claude apps: calm, warm, typographic and unhurried. plMail matches
their care, not their look. Gmail is the benchmark for what the app can *do*, never for how it
*looks*.
Four traits follow from the audience:
- **Dense, not cramped.** Mail is a list you scan. Rows are tight at every density and still meet
their touch floors.
- **Honest.** A state says only what is true. The live-update dot stays neutral until a connection
exists, a loading model gets breathing dots rather than a fake progress bar, and "no results"
mentions the sync window when that is the reason.
- **Self-contained.** No webfont, no CDN, no runtime request to a third party. Anything the app
draws, it ships: the icon font and the emoji font are vendored.
- **Bilingual from the first line.** English and German, with German using the informal *du*.
German runs about 30% longer, and layouts are designed at that length.
When no rule covers a decision, pick the quieter option: a hairline over a box, weight over size,
ink over colour, stillness over movement.
The tokens in this file describe **Paper** with the **Flat** layout, which is what a new account
starts on. The app ships about forty themes and a set of appearance settings. None of them adds a
role; each one restates the same roles with different values (see *Themes* below).
## Colors
The palette is **roles, not colours**. Every colour on screen is one of the roles below, and each
role is a Tailwind utility (`bg-surface`, `text-ink-muted`, `border-line`, `bg-hover`,
`ring-accent`, …) declared in `@theme inline` in `assets/styles/app.css`. A theme is a block of RGB
channels, so switching theme re-resolves everything and no component knows which theme it is in.
The hex values here are Paper's. Hairlines, hover, raised, sunken and the fields are ink (or white)
at a low alpha in code, and the hex is the flattened result. **Never write a hex, `rgb()` or a
Tailwind palette colour (`zinc-400`, `blue-600`) into a template.**
**Surfaces**, from the back:
- **App background** {colors.app-bg}: the desk the sheet lies on. In the Flat layout the top bar
and the sidebar sit directly on it, with no card of their own.
- **Surface** {colors.surface}: the sheet. Panes, menus, dialogs and toasts use it. The step down to
the app background is deliberately small, so the window reads as one material.
- **Raised** {colors.raised}, **hover** {colors.hover} and **sunken** {colors.sunken}: ink at
3.5–4%. These are surface shifts, not colours. Hover is the lightest step there is, and it is how
a row answers the pointer.
- **Line** {colors.line}: the hairline, ink at 9%. It divides rows, separates a header from its body
and edges a pane. It is a rule, not a frame.
**Ink** has four steps. They form a hierarchy of meaning rather than of size:
- **Ink** {colors.ink} is what the message is: unread senders and subjects, titles, anything being
read.
- **Ink soft** {colors.ink-soft} is what it is about: secondary lines and button labels.
- **Ink muted** {colors.ink-muted} marks read rows, resting navigation and descriptions.
- **Ink faint** {colors.ink-faint} is furniture: timestamps, counts, placeholders, overlines and
resting icons.
**The accent is clay**: {colors.accent}, deepening to {colors.accent-strong} on hover and press (the
accent mixed 15% toward black in oklab). Its tint is {colors.accent-soft} (10%), and white
{colors.accent-ink} goes on top of it. The accent has few jobs:
- the Compose button, and the one primary action in a view;
- the active navigation row (a soft pill with accent text);
- the unread count pill and the new-mail dot;
- focus rings (`ring-2 ring-accent`) and the checked state of a control (a checkbox, a selected
row's avatar tick, a pressed filter chip);
- links in the composer;
- the search-hit wash;
- live drop targets during a drag;
- the unread bar at Strong emphasis.
It is never a large filled area. The accent is also a **user setting**: each theme seeds its own,
AppearanceRenderer writes the user's choice inline on `<html>`, and a template that uses the clay
hex breaks the moment someone picks another colour.
**As text, the accent is derived.** `text-accent` does not paint the accent itself but
{colors.accent-text}: the accent mixed toward black in oklab until its lightness is at most 0.47 on
a light scheme, or toward white until it is at least 0.70 on a dark one. That keeps the hue, and an
accent that already reads is left alone. The bounds are the loosest that hold 4.5:1 for every
accent a user can pick, on the surface, the page, a hovered row and the accent's own 10–15% washes
(`AccentTextContrastTest` sweeps the RGB cube). Paper's clay reads as {colors.accent-text} and Dark's
deep green as `#88A79A`. Fills, rings and the checked state keep the accent itself: a fill has to be
deep enough for its ink, and text has to be light enough for its surface, and in a dark theme no
one colour is both. A rule that sets a colour itself writes `var(--accent-text)`, never
`rgb(var(--rgb-accent))`. The message sheet always takes the light-scheme text.
**Status colours** come in small doses: {colors.danger} danger, {colors.warning} warning,
{colors.success} success, and {colors.info} info. Info is a slate, not a link blue. They are for an
icon, a badge (`badge-*`, a tint at 8%) or an alert box (`alert-danger` / `alert-info`: an 8% fill
with a 20–25% border). A destructive control stays quiet until hover, when it turns danger on
`danger-soft`.
They are text as often as they are colour, so they meet the ink's 4.5:1 on the surface, on the page
and on a wash of themselves up to 12% (the onboarding pills; badges and alerts wash at 8%). That is
why no light theme keeps the Tailwind 600s it started with: each is the old colour mixed toward
black in oklab (toward white on a dark theme), keeping its hue, only as far as the floor needs. The
wash is measured as a browser paints it, eight-bit alpha and whole steps, which is a shade heavier
than the exact sum, and Chromium's software raster can land one step heavier again.
`ThemeInkContrastTest` holds every theme to all of this, and holds every ink step to 4.5:1 on the
page as well as the surface.
**Inverse** {colors.inverse} with {colors.inverse-ink} is the tooltip bubble: the one thing that
flips the page.
**The message sheet** {colors.sheet} is always light. Mail is written against a white page: senders
set dark text and assume white behind it, so a message rendered on a dark surface is both harsh and
a misrepresentation of what was sent. In a dark theme the reading pane is still light; which light
is up to the theme. The `mail-sheet` utility re-points the channels, so every token utility inside
(`text-ink`, `border-line`, `hover:bg-hover`) resolves to the sheet without knowing it exists. Links
inside a body take {colors.sheet-link}, not the accent, because an accent tuned for a dark theme can
be unreadable on the sheet. Status text takes the sheet's own danger, warning, success and info for
the same reason: a draft, the spam warning, the read-receipt line, "Draft saved" in an inline reply
and the scheduled badge all sit on the sheet, and a dark theme's status colours are lightened for
its near-black surface. `ThemeInkContrastTest` holds the link and all four at 4.5:1 on the sheet,
and the four on a 10% wash of themselves too.
**Palettes that are not roles:**
- A sender's avatar takes one of eight muted tones chosen by address: rust, green, blue, indigo,
olive, plum, teal and ochre, with a white initial. Dark themes use pastel versions with a dark
initial. Muted on purpose: forty saturated circles make a chart, not a mailbox.
- A user label takes one of nine colours (gray, red, orange, amber, green, teal, blue, violet, pink).
It is drawn as an outline plus text, never as a fill.
### Themes
| Role | Paper (default) | Dark |
|:--|:--|:--|
| app-bg | `#F2EFE7` | `#151413` |
| surface | `#F7F5EF` | `#1C1B1A` |
| line | ink at 9% | white at 9% |
| ink | `#232220` | `#D6D1CA` |
| ink-soft | `#524E48` | `#BAB5AE` |
| ink-muted | `#5F5B54` | `#9F9B95` |
| ink-faint | `#716C64` | `#86837E` |
| accent | `#7D6B4F` clay | `#3A6F5C` deep green |
| accent as text | `#685841` | `#88A79A` |
| danger | `#C12020` | `#E28080` |
| sheet | `#F7F5EF` | `#F8FAFC` |
Dark is **a dim room, not a black one**. The floor sits well off pure black, the ink stops well
short of white, the greys are warm (more red than blue), and hairlines rather than lighter panes do
the separating. Pure grey on black makes a dark theme look like a terminal.
- **Every theme block declares every variable.** A theme is `:root` (Light) or
`:root[data-theme="…"]`, and `ThemeVariableCompletenessTest` fails the build when a block drifts
from the inventory, so a new role goes into every block at once. The base seven are System, Light,
Paper, Dark, Nord, Dusk and Solar. The rest, one per logo colourway, came out of a generator that
no longer exists, and are edited by hand like the others.
- **Layout is a second axis.** It is set independently of the theme. **Flat** is the default: the
chrome sits on the page and the main pane is the only box. **Boxed** gives floating panes and
optional glass. Radius, density, text scale, pane opacity and blur, unread emphasis and motion are
per-user knobs written inline on `<html>`. Build with the variables and utilities, and new UI
follows every knob automatically.
- **The Android app** (`pl_mail_android`, `:core:designsystem`) uses the same role vocabulary in
camelCase (`inkSoft`, `accentSoft`, `fieldLine`). Its own Light and Dark are warm neutrals with a
deep-green accent, and it renders the web's `paper` as its Light.
## Typography
There is **one family: the platform's own interface face** (a `system-ui` stack). Every size is set
in it. The app ships no webfont: a self-hosted install behind a firewall must not depend on a font
CDN, and a 400 KB download to change the shape of the sidebar is a poor trade. Users can switch the
interface to a Grotesk, Serif or Monospace stack, so nothing may depend on the metrics of one face.
**Hierarchy comes from weight and ink, not size.** The interface lives at two sizes: 14px
({typography.body-md}) for content and controls, and 12px ({typography.caption}) for metadata. Only
titles leave that band, and they leave it modestly: a pane title is 20px ({typography.headline-md}),
not a hero.
- **Three weights.** 400 for reading, 500 for labels and controls, 600 for emphasis (unread
conversations, titles, overlines). Bold (700) essentially never appears.
- **Unread and read** is the defining pair: the same 14px line is 600 in `ink` when unread and 400
in `ink-muted` once read. Size never changes between them.
- **The overline** ({typography.overline}: 11px, 600, uppercase, 0.05em, `ink-faint`) heads a
section or a group. The sidebar's LABELS and ACCOUNTS use it at 12px, menu groups at 10px. Never
on a button, never on a title.
- **Small text** stops at 11px ({typography.micro}) for words: label chips, filter chips, hints.
10px ({typography.badge}) is for numerals and one-word badges only.
- **Paragraphs** of help or description use {typography.body-reading} (`leading-relaxed`). Tight
stacks use `leading-snug`.
- **Numbers that change** (a countdown, a table of counts) use `tabular-nums`. Addresses, tokens and
codes use `font-mono` ({typography.mono}, Tailwind's own stack), which is separate from the
Monospace *interface* font a user can pick.
- **Everything is rem.** The root is `16px × --app-font-scale` (0.875–1.25), so interface text set
in px escapes the user's text size. The single exception is the composer: its 14px (16px below
768px) is pinned because the size is part of the message being sent, and the sender must see what
the recipient will get.
- **Fields never go below 16px under 768px.** iOS zooms into a smaller focused field and does not
zoom back.
- **Emoji** use the vendored Noto Color Emoji, and only in the emoji picker and the message being
written. Everywhere else the reader's system draws them.
## Layout
The shell is a **top bar over three panes**: sidebar, message list, reading pane. A calendar can dock
at the right. It has three positions (mail, split, calendar), and the split needs 1024px or more.
- **Gutter:** {spacing.gutter} between panes and around the shell. Flat drops the left gutter so the
navigation pills can run to the window edge.
- **One header height:** every pane's header row is {spacing.pane-header} (`--pane-header-h`), so
headers that sit side by side line up.
- **4px grid.** The spacings in real use are 8 (icon to label, `gap-2`), 12 (`px-3`), 16 (pane
padding, `px-4`), 6 (`gap-1.5`) and 24 (sections).
- **Density** has three steps, **Comfortable** (default), **Cosy** and **Compact**, and each surface
(sidebar, list, reading pane) can take its own. Rows read their padding from variables through
`py-row`, `list-row-y`, `reading-y`, `gap-gutter` and `nav-rows`. Never hardcode a row's padding.
At Comfortable, a sidebar row is 36px tall, a nested label row 32px, a mail row has
{spacing.list-row-y} of vertical padding, and a reading block has {spacing.reading-y}.
- **Touch floors:** on a coarse pointer, rows never shrink below their Comfortable height, whatever
the density. The Android app keeps targets at 48dp or more.
- **Below 768px** the sidebar becomes a drawer, compose goes full-screen, and search collapses to an
icon that opens a full-width row under the top bar.
- **Container queries, not viewport queries,** for anything inside a pane. A mail row switches
between three lines, two and one on the *list's* width (`@xl`, 36rem, and `@rowwide`, 75rem),
because a docked calendar can make the list narrower than a phone.
- **Nothing scrolls the page sideways.** Panes scroll their own overflow. The shell uses `dvh` and
honours safe-area insets.
- **Design at German length, at 320px wide, and at text scale 1.25.**
## Elevation & Depth
Depth comes from **tone and hairlines, not shadow**. The page sits a shade below the sheet, a
hairline edges each pane, and a mail row lifts toward white under the pointer.
- **Panes whisper.** The `pane` / `main-pane` shadow is ink at 5% on Paper, kept within the 12px
gutter so the window edge never clips it. In the Flat layout the chrome has no card and no shadow
at all.
- **Only what floats gets a real shadow.** Menus and popovers (`popover`), the compose window,
dialogs and toasts (`pane-flat shadow-float`) sit over content and need to separate from it. A
dropdown has no backdrop to do that for it.
- **Modal scrim:** black at 50% with a 4px blur, for both the modal and the confirm dialog. It is a
decision the app makes, not the user's wallpaper scrim.
- **Glass is opt-in.** The Boxed layout and the opacity and blur settings enable it. Floating
surfaces keep their own higher opacity (`--popover-alpha`, never below 0.5) so words are never read
through other words. `prefers-reduced-transparency` forces everything opaque.
- **`backdrop-filter` traps `position: fixed`.** Anything that must escape a pane, such as the
tooltip or the confirm dialog, is rendered at `<body>` level. The message sheet isolates itself
(`isolation: isolate`) so the blur behind it cannot bleed through.
## Shapes
**Radius belongs to panes, not controls.**
- **`rounded-pane`** ({rounded.pane} by default): every surface the eye reads as a panel. That
means panes, dialogs, menus, the compose window and toasts. This radius is a user setting from 0 to
32px (`--app-radius`), so a hardcoded `rounded-xl` on a panel is wrong at every radius but one.
- **`rounded-lg`** ({rounded.control}, fixed): every control. That means buttons, fields, selects,
filter chips, icon buttons, tooltips and rail tiles. Controls do not grow 32px corners because
someone likes round panes.
- **`rounded-full`**: avatars, count pills, label chips, status dots, and the right-hand end of a
navigation row.
- **`rounded`** ({rounded.sm}): checkboxes.
- **Navigation rows** are a pill that runs off the left edge and rounds fully on the right. The shape
is there at rest; hover and active only change its colour, so a highlight never morphs on the way
out.
## Components
The recipes below are the app's own. Reuse the named utilities (`pane`, `popover`, `btn-quiet`,
`chip-filter`, `select-field`, `badge-*`, `alert-*`, `mail-sheet`) rather than re-spelling them.
### Buttons
- **Primary** is accent-filled and appears at most once per view: Compose, Send, Save.
`h-9 px-4 rounded-lg text-sm font-medium bg-accent hover:bg-accent-strong text-accent-ink`. A
toolbar uses `h-8 px-2.5 text-xs`. Two accent buttons in one form means two primary actions,
which is one too many.
- **Secondary** has the field look: `h-8 px-3.5 rounded-lg text-sm font-medium text-ink-soft
bg-field border border-field hover:bg-hover`. Use it for Cancel, and for a step towards the real
action, such as choosing a file.
- **Quiet** (`btn-quiet`, `h-8 px-2.5 text-xs font-medium`) is a tinted step with no border. Use it
for the everyday verbs in toolbars.
- **Icon** buttons are a 32px square, `rounded-lg`, resting in `ink-faint` and coming up to `ink` on
`hover:bg-hover`. Every icon-only button carries a translated `title`, and the app turns that into
its tooltip.
- **Destructive:** quiet until meant, `hover:text-danger hover:bg-danger-soft`. Anything that cannot
be undone asks first through the app's confirm dialog (`data-turbo-confirm`, or `confirm.js` for
fetch-driven actions), never `window.confirm()`.
### Fields
- **Text input** is a filled well rather than an outlined box. The form theme
(`_layout/form/modal_form_theme.html.twig`) renders it as `block w-full h-9 rounded-lg border
border-field bg-field px-3 text-sm text-ink placeholder:text-ink-faint shadow-sm
shadow-zinc-950/5 focus:outline-none focus:ring-2 focus:ring-accent focus:border-transparent`.
`border-field` is the *fill* colour (Tailwind names it after `--color-field`), so the edge is
carried by the lighter fill and the faint shadow under it. Focus is always the 2px accent ring
and never a browser outline. That edge is 1.05:1 against the surface (1.11:1 on Dark), under the
3:1 WCAG 1.4.11 asks of a control's boundary, and the well is kept on purpose: it was weighed on
2026-09-28 against a 3:1 outline (`#8F897E` on Paper) and against the selects' 1.4:1 edge.
- **Label and help:** a row is `flex flex-col gap-2`, its label `text-[13px] font-medium
leading-none text-ink-soft` (a required field adds a `text-danger` asterisk), and its help
`text-[12px] text-ink-faint leading-snug`. Rows stack `space-y-5`.
- **Select:** `select-field`, which does draw an edge, in {colors.field-border}. Tom Select
progressively replaces it, and its control mirrors `select-field` value for value, so change both
or neither.
- **Checkbox and radio:** `size-4 rounded accent-accent focus:ring-2 focus:ring-accent` (a radio
drops `rounded`). `accent-accent` is what colours a native box. `text-accent` does nothing to one
without the forms plugin, which the app does not load, so a box that carries only `text-accent`
renders in the browser's default blue. The form theme's `checkbox_widget` and `radio_widget` do
this for every Symfony form. File inputs are styled once, globally, in the secondary look.
### Chips, pills and badges
- **Filter chip** (`chip-filter`, pressed via `data-active="true"`): 24px tall, 11px, `ink-muted` on
a hairline. Pressed, it takes the accent's border and an 8% wash, and {colors.accent-text} for
its text.
- **Label chip:** an outlined pill of 11px text with a 40% border, `max-w-28 truncate`. A row shows
at most three, then `+N`. Never filled.
- **Count pill:** the unread number in the sidebar. `text-xs font-semibold px-1.5 py-0.5
rounded-full bg-accent text-accent-ink`. The **new-mail dot** is the same accent at 6px.
- **Badge** (`badge-danger | warning | success | info | neutral`, `text-[10px] font-medium px-2
py-0.5 rounded-full`): a status word in a list, tinted, never solid.
- **Neutral tag:** `text-[11px] font-medium px-2 py-0.5 rounded-full bg-raised text-ink-muted`, for a
count or a word beside a heading. Muted, not faint: faint ink was 4.41:1 on the raised step. **Inline code** (a search operator in help text) is
`bg-sunken px-1 rounded` in a `<code>`.
- **Event chip:** a meeting drawn in the calendar carries its calendar's colour as a bar, over a 10%
wash of it where it is a block. A meeting you answered **Maybe** is striped in that colour with a
dashed edge (`data-tentative`), in every view: it keeps its slot, and the stripes say it is not
settled. An event's own tentative status, set by its organiser, does not stripe it.
### Navigation
A sidebar row rests in `ink-muted` and hovers to `bg-hover`. When active it becomes
`bg-accent-soft text-accent font-medium`. The ancestors of the active label take accent text only:
"you are in here", not a second selection. Icons are 16px, labels `text-sm`, and the count pill
sits at the right. Section heads are overlines with a quiet `+` beside them. Collapsed, the sidebar
is a 56px rail of centred icons, and the new-mail dot moves to the icon's corner.
### The mail row
The row is the product. Keep it quiet.
- A **36px avatar disc** that doubles as the checkbox. It turns into an accent disc with a tick on
hover or when checked.
- **Sender, subject, snippet, chips, time.** In a wide list they share one line, subject and snippet
together. In a middling one sender and subject share the first line and the snippet takes the
second. In a narrow one they stack. Below one line the snippet clamps to the user's 0–2 lines.
- **The row** is `flex items-center gap-3 pl-3 pr-4 list-row-y`. Its padding comes from the density
variable, never `py-*`.
- **Unread:** sender and subject `font-semibold text-ink`, plus a faint white wash (`row-unread`,
scaled by the emphasis setting). Strong emphasis adds a 3px accent bar on the leading edge.
**Read:** sender `font-normal text-ink-muted`, subject `font-normal text-ink-soft`. The snippet
is `ink-faint` in both, as are the time and counts.
- **Hover lifts the row:** `hover:bg-white/60 dark:hover:bg-white/[0.06]` on the row and
`hover:shadow-sm` on the `<li>`, while the row's actions appear at its right edge. They are icon
buttons like any other (`text-ink-faint hover:text-ink hover:bg-hover`), and Delete and Delete
forever take the destructive hover (`hover:text-danger hover:bg-danger-soft`). Hairlines
divide the rows. Search hits get an accent wash of 22% and step up to full ink, with no padding,
so the highlight is never sliced by `truncate`.
### Menus, tooltips, dialogs, toasts
- **Menu:** `popover py-1 min-w-44`, placed `top-full mt-1`. Rows are `w-full flex items-center
gap-2.5 px-3 py-2 text-sm text-ink-muted hover:bg-hover hover:text-ink`, with a 16px centred icon.
Group headers are 10px overlines.
- **Tooltip:** one bubble for the whole app, on `<body>`, in inverse colours. It is 12.5px/500 with
an 8px radius and a caret aimed at the trigger. Give an element a `title` and it gets the tooltip.
- **Confirm dialog:** a `<dialog>`, `min(28rem, 100vw - 2rem)`, `pane-flat shadow-float`, `p-6`.
It has a {typography.title} heading, the question in `text-sm leading-relaxed text-ink-soft`, and
Cancel (secondary) and Continue at the bottom right. **Continue is accent, not red**, even though
every caller is destructive: the red belonged to the control that opened the dialog, and two red
things beside a quiet Cancel stop being read. The safety is that Cancel holds the focus.
- **Toast:** `pane-flat shadow-float min-w-[280px] max-w-sm border-l-4`, `px-4 py-3.5 text-sm
font-medium`. The tone lives only in the 4px leading edge and the icon (`border-success` with
`fa-circle-check`, `border-danger` with `fa-circle-exclamation`, `border-info` with
`fa-circle-info`); the surface and the text stay neutral. Undo, where there is one (undo send),
is an underlined accent link inside the toast.
- **Alert** (`alert-danger`, `alert-info`): `rounded-lg px-3.5 py-3 text-sm` with a leading icon, for
a problem that belongs to a form or a pane rather than to the moment.
### The reading pane and composer
The reading pane is `mail-sheet`, light in every theme. A message body renders in a sandboxed
iframe that carries the sheet across: the theme's sheet colours, 14px/1.55 system type for mail that
sets none, and the sheet's link blue. The sender's HTML is sanitised, never restyled. Message
headers are sticky, and an open header menu raises its own header above the next one.
The composer docks at the bottom right as a floating card on desktop and becomes full-screen below
768px. Its editor keeps its own pinned type, and its links are accent and underlined.
### Live state
- **The stream dot** beside the wordmark is 6px. It is neutral until the first state arrives, then
green (connected), amber (connecting) or red (offline). It is sized to be findable rather than
noticeable.
- **A model thinking** shows three dots breathing out of phase. Under reduced motion they stay still
at a readable opacity. Never a progress bar for work whose progress is unknown.
## Iconography
- **Font Awesome Free 7**, vendored through the importmap. `fa-solid` is the default. `fa-regular`
is for the lighter outline glyphs (envelope, calendar, clock) and for the "off" half of a toggle,
such as an unstarred star. Icons are 16px (`w-4`, centred) beside a label, in the label's ink.
Decorative icons get `aria-hidden="true"`.
- **Inline SVG is the exception.** The inbox category tabs draw Heroicons (outline at rest, solid for
the tab you are on), and checkboxes draw their tick and dash in a 10×10 viewBox with
`stroke="currentColor"`.
- **The mark** is "pl", drawn as seven round-capped strokes on a 48-unit grid. It comes in
colourways. **Berry**, a violet-to-rose sweep, is the default, and the mark follows the theme when
linked. Always draw it through `_partials/_logo_mark.html.twig` and never redraw its geometry. It
is the one flourish of colour in the chrome.
## Motion
Quick and purposeful. Movement tells the eye where to look and then gets out of the way, because
past about a quarter second a transition stops reading as "this moved" and starts reading as "I am
waiting for this".
| Token | Value | Used for |
|:--|:--|:--|
| `--motion-fast` | 120ms | exits, which are rare (a modal or a toast leaving) |
| `--motion-base` | 180ms | every entrance (`data-enter`), the dialog scrim |
| `--motion-slow` | 260ms | window-sized entrances (`pop`: the modal); the ceiling |
| `--motion-ease` | `cubic-bezier(0.22, 0.68, 0.32, 1)` | everything: decelerate, never overshoot |
| `--motion-lift` | 6px | how far a `rise` travels |
| `--motion-row-base` | 600ms, 48px, overshooting | a new mail arriving, one of two exceptions |
| `--motion-travel` | 580ms, `cubic-bezier(0.32, 0, 0.18, 1)` | an answered invitation flying to the calendar, the other; zero below Full |
- **Opt in with an attribute**, not a keyframe: `data-enter="fade | rise | pop | slide-down |
slide-right"`, and `data-enter-stagger` on a container. Every timing lives in `motion.css` and the
`MotionLevel` enum. Never invent a duration in a component.
- **Two animations carry information**, so they alone may run past the ceiling. New mail arriving
takes 600ms and overshoots. An invitation answered Yes or Maybe flies from the button to the
meeting's slot in the calendar pane, or onto the calendar button when the pane is closed, in
580ms, and lands without a bounce. Nothing else bounces. A spring reads as playful the first time
and slow the fortieth.
- **Show where something goes before it moves.** Hovering or focusing an answer outlines its slot,
dashed, in the calendar's colour. The outline moves nothing, so it is there at every motion level,
and without the flight it is the whole of the feedback.
- **Exits are rare.** An exit animation is time between asking for something and getting it, so
archiving a mail plays none. Only a surface you were looking at, such as a modal or a toast,
animates out.
- **Motion levels.** At Full things travel and fade. At Minimal they only fade, faster, and nothing
flies. At None every duration is zero. `prefers-reduced-motion` overrules the setting in both directions without
asking.
## Do's and Don'ts
- **Do** use a role utility for every colour. **Don't** write a hex, `rgb()` or a Tailwind palette
colour (`text-zinc-400`, `bg-blue-600`) in a template. It is right in one theme out of forty.
- **Do** keep the accent scarce: one primary button per view, plus the few markers listed under
*Colors*. **Don't** fill a banner, a header or a large area with it.
- **Do** build hierarchy from weight and the ink ramp. **Don't** reach for a bigger size or a new
colour to make something important.
- **Do** separate with a hairline or a surface step. **Don't** put a shadow on anything that doesn't
float, or nest boxes inside boxes.
- **Do** round panels with `rounded-pane` and controls with `rounded-lg`. **Don't** give a panel a
fixed radius, or a button the pane radius.
- **Do** keep every text pair at WCAG AA (4.5:1) in both a light and a dark theme, and on the app
background as well as on the surface. Check it; don't trust your eye.
- **Do** render message bodies on the light `mail-sheet` in every theme. **Don't** restyle a
sender's HTML: the frame only supplies defaults for mail that sets none.
- **Do** put every visible string in the translation files, in English, German (informal *du*) and
the pirate locale. **Don't** hardcode English, even in a `title` or an `aria-label`.
- **Do** read density, radius, text scale and motion from their variables. **Don't** hardcode a
row's padding, a panel's radius or an animation's duration.
- **Do** keep a confirm dialog's button accent and let Cancel hold the focus. **Don't** paint it
red: the red belongs to the control that opened it.
- **Don't** add a webfont, a CDN link or any other runtime request to a third party.
- **Don't** give a component a gradient, a glow, a coloured shadow or a bouncing entrance. A
theme's page background is the theme's business; a component's surface is always flat. The
quietest honest version is the right one.