15 Commits
Author SHA1 Message Date
cdd0cd4fd8 The installed app's icons follow the chosen logo, drawn by the browser (#229)
An app installed from the browser or added to an iPhone home screen kept the wax seal. The manifest and apple-touch-icon now name the chosen logo; the browser draws the PNGs on a canvas and uploads them per user, so the image needs no librsvg. Builds on #92. Closes #141.

Co-authored-by: mani <15911342+Flouss@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 18:16:55 +02:00
jo2904andClaude Opus 5.5 5550727888 AppLocale : proposer le français
Ajoute 'fr' à AppLocale et à framework.enabled_locales, qui doivent
rester synchronisés (le commentaire de translation.yaml le dit). Le
sélecteur de langue des réglages et celui de l'installation lisent
AppLocale::cases() : « 🇫🇷 Français » y apparaît sans autre
modification.

Deux réglages suivent la langue et devaient connaître le français :
- ClockFormat::forLocale() : le français écrit 14 h 00, donc 24 heures
  par défaut comme l'allemand. Sans ça, un utilisateur francophone qui
  n'a rien choisi verrait « 2 PM ». Testé dans ClockFormatResolverTest.
- Le sélecteur d'emoji charge ses noms et mots-clés par langue (la
  recherche « cœur » ne trouve rien dans les données anglaises).
  assets/emoji/fr/data.json vient de emoji-picker-element-data, comme
  en/ et de/. J'ai vérifié que ces deux fichiers sont identiques octet
  pour octet à ceux publiés par le paquet, donc fr/ vient bien de la
  même source. Le gabarit choisit maintenant de, fr ou en d'après la
  langue au lieu du seul test « starts with 'de' ».

Laissé tel quel : DeterministicDateDetector, qui lit les dates dans le
contenu des mails selon leur langue, et non la langue de l'interface.
Apprendre les dates françaises (« le 4 août ») est un autre chantier.

Docs : README, features/appearance.md (et sa traduction allemande, avec
le hash mis à jour), et une entrée de CHANGELOG.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018nYWPc4bWacb79hm4ytfni
2026-10-13 23:33:38 +02:00
Walid ManiandClaude Opus 5.5 99d2da3da2 Show each inbox tab's unread count, with a setting to switch it off (#54)
The sidebar's Inbox badge could read 1 while Primary, the tab that opens, had nothing unread: the conversation was in Promotions, and the only hint on the strip was a colour on that tab's icon. Each tab now shows how many conversations in it hold unread mail. It is the Inbox badge split by category, so the tabs add up to it, and it is patched in place on sync.

This reverses the documented rule that the "3 new" pill is the only number on a tab. The badge and the bold rows say that there is unread mail and cannot say where.

On for everyone, and a per-user appearance setting switches it off, beside the account corner. One column on the user table; the migration is reversible.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:49:58 +02:00
Paul LütznerandClaude Opus 5.5 a3336fbf6a fix(branding): the tab icon draws what its URL names; the top bar's logo stands bare
The tab icon was drawn from the session, and the appearance pane points
the tab at a new choice the moment it is clicked, before the save lands.
So it drew the choice from before the click, and the browser kept that
drawing under the new URL for a week: every tab icon was the previous
pick, except Classic, whose URL the page had already loaded correctly.
/branding/favicon/{motif}/{paint}.svg now draws what its path says, like
the icon tiles, and is public and cached the same way.

The top bar's logo is the glyph without its tile, from
/branding/logo/{motif}/{paint}.svg: the tile is the phone's app icon,
and in a web page's corner it read as one. The @-horn keeps its tile,
because its cream @ is nothing without the field behind it. Every
top-bar logo, the pl mark included, sits in one 36px box, up from 32.

On a dark bar only the parts that would vanish change: the pl mark and
the horn read the colourway's dark-chrome strokes, and the mailbox post
turns grey. The page renders a light and a dark image and the stylesheet
shows one, so a theme that follows the system is right as well.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 12:23:27 +02:00
Paul LütznerandClaude Opus 5.5 2f25d00544 docs: the logo's two steps, and logoMotif/logoPaint for clients; hashes
The appearance page describes choosing an icon and then its colour, with
Original as the motif's default, what a colourway leaves alone, and two new
traps (Follow the theme on a classic theme; picking a motif resets to
Original). The client guide documents the two read-only JMAP properties,
logoMotifs, and the paint table behind app:branding:export-paints. German
prose follows both; translated-from hashes recomputed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 22:53:47 +02:00
Paul LütznerandClaude Opus 5.5 881b7026b3 feat: a running clock, in the topbar, under the name or in the sidebar
Settings → General → Clock gains "Show the time":

- On the Happening Soon button (default): the button shows the time on
  every page, falling back to a clock icon when nothing is coming up,
  and the panel it opens is headed by the time and today's date.
- Under the plMail name: weekday and time as a second line.
- At the bottom of the sidebar: a larger time, the date and the next
  thing coming up, which opens Happening Soon.
- Nowhere: the topbar as before.

The server prints the first reading in the user's zone and format;
ui--clock advances it on each minute boundary and when the tab becomes
visible, and rewrites date text only when the day changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 14:06:43 +02:00
Paul LütznerandClaude Opus 5 6c33316f33 feat(settings): a second opacity slider, for windows and menus
One slider drove every translucent surface, and translucency multiplies: a
composer at 0.7 over a pane at 0.7 left about a tenth of the wallpaper landing
in the middle of the text being read.

So the knob splits. Structural surfaces — sidebar, top bar, main and calendar
panes — keep --pane-alpha. Floating ones — the compose window, modals, confirm
dialogs, toasts and twelve menus — take --popover-alpha, and both floating
utilities re-point --pane-alpha to it for their own subtree, so an inset strip
inside the composer belongs to the composer rather than to the sidebar behind
it.

The guarantee is a floor, not just a default: 0.5, so a popover can never be
made as see-through as a pane. Boxed spends the gap at 0.9 over its panes'
0.7 — about 3% of the background survives both layers instead of 10%.

The old stored value is deliberately NOT copied across. Twelve of the sixteen
floating surfaces never honoured it: `popover` has been hardcoded opaque since
it was written, for exactly the illegibility reason being reported. Backfilling
0.7 would have introduced the fault in twelve places to preserve it in four.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 00:38:02 +02:00
Paul LütznerandClaude Opus 5 14b8da9801 feat(motion): the list stops sliding and starts cascading
list   slide-down, 30ms, 48px, staggered 16ms

Only the message list. The new-mail row keeps its 600ms drop and the gap
still opens over 200ms with a 200ms handoff; nothing else moved.

Thirty milliseconds is under two frames and that is the whole idea. At
this duration the travel is not a slide — each row spends longer PARKED
at its start state, held there by the backwards fill through its share of
the stagger, than it spends covering the distance. What the eye follows
is the order rather than any row's journey, so the list unrolls instead
of fifty things sliding.

Which makes listStagger the animation rather than a garnish on one, and
it gets its own number for the first time: 22ms is a grace note on
something you can already see, 16ms is the thing you see. The eight-row
cap earns its keep here more than anywhere — fifty rows would otherwise
take four fifths of a second, capped they take about 150ms whatever the
length.

Minimal no longer scales from base(): Full is now shorter than the
ordinary duration, and a Minimal tier that took LONGER than Full would be
a tier that means nothing. It keeps the 30ms and drops the travel and the
cascade, which is what Minimal means everywhere else.

── The direction change cost something, and it is not only a test ──────

Going from slide-right to slide-down broke a spec that passed at 600ms,
and the reason is worth writing down. A row waiting its turn is parked
48px above its place, and a parked row is a STILL row — so hover() is
happy to aim at it. A moment later the row drops and the pointer is over
its neighbour: group-hover stops matching and the action the hover was
meant to reveal never appears.

The horizontal entrance could not do that. A row moving sideways keeps
its own band of the screen; a row moving vertically hands its position to
the row above it. So the moving-target window the class comment already
documents is now ~130ms rather than 600ms, and it can land you on the
wrong row rather than on empty list.

Specs that hover immediately after a load wait for stillness, with that
reasoning at the site rather than in a commit nobody will find.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 11:47:59 +02:00
Paul LütznerandClaude Opus 5 c41a68876e feat(motion): a whole list arrives as its rows, one after another
Your configuration from the console, and the promise it forced us to
rewrite.

  list   slide-right, 600ms, 48px, staggered 22ms

The list itself no longer animates. A grey rectangle fading tells you a
rectangle changed; fifty rows coming in from the left tells you what
changed. That needed a third verb, because a row cannot carry both of
its entrances in the markup — the long drop that means ONE new mail, and
the group entrance that means a folder change — and which one it is
doing is decided at runtime, not when the template was written:

  data-enter          animate me
  data-enter-new      animate me the first time you see this id
  data-enter-children animate my children, like this, not me

Whoever gets there first wins. A row reached as part of a list is marked
as spoken for, so its own entrance cannot fire on top.

slide-right did not exist in plMail — the console had it, the app did
not. Added at twice the travel of the vertical entrances: six pixels
sideways reads as a twitch, because there is no rest position the eye
expects it to fall to.

── The promise ─────────────────────────────────────────────────────────

MotionLevel opened by promising that an entrance never gates
interaction — "a row is clickable while it is still fading in". That was
written when nothing travelled more than six pixels. At 48px it is not
true, and three unrelated specs found it before anybody had to: two
clicked a row's select box and hit the sidebar behind it, one measured a
phone layout mid-slide and reported a badge at x=-14.

The promise is withdrawn rather than quietly broken, and what replaces it
says exactly what is exposed: a row keeps its width and its line, so the
middle of it is always safe; the two ends travel. Nothing is made inert
and no click is swallowed.

The bound on it is better than a promise anyway. Noticing a list has
changed and choosing where to click takes longer than the animation, and
for anyone it does bother, Minimal removes every pixel of travel and None
removes the animation. That is what the setting is for: a motion budget
nobody can opt out of has to be timid, and one they can opt out of does
not.

Tests that assert about POSITION now wait for stillness first, through a
shared helper — tests/e2e/support/motion.ts — which documents both ways
this bites so the next one is a lookup rather than an afternoon.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 11:17:08 +02:00
Paul LütznerandClaude Opus 5 4c7752006b feat(motion): new mail drops into a gap the list opens for it
Your configuration from the motion console, made real.

  mail row   slide-down, 600ms, 48px, spring
  room       200ms
  handoff    200ms
  overflow   the departing row fades where it stood

The row is now the only surface in plMail with numbers of its own, and
MotionLevel says why at length: it is the only animation here carrying
INFORMATION rather than reassurance. Everything else says "this is here
now", which the eye needs briefly and then resents. This one says "this
one is new", and it is affordable at that length for two reasons that
both have to hold — it is rare, because nothing in a redraw plays it, and
it gates nothing, because the row is readable and clickable from its
first frame.

The gap is the interesting half. Nothing is ever inserted into this list:
it is refetched and morphed, so by the time anything can be animated the
new row is already in place and every row below it is already one row
lower. There is no gap to open. So it is played backwards — measure where
every row was, and afterwards put the survivors back and release them.
What the eye sees is rows travelling down to make space, which is a true
account of what happened, arrived at from the wrong end.

Two corrections to the morph that shipped this morning, both found by
this work rather than by the tests that were meant to cover them:

  The list was nesting a duplicate <ul> inside itself, one level deeper
  per refresh. Idiomorph treats the element you hand it as CONTENT, so
  passing the fresh <ul> asked for a list containing a list. Every
  assertion still passed — the rows morphed correctly and simply lived a
  level further down. There is an assertion on the shape now.

  Exempting the `hidden` attribute on an open menu was too narrow: the snooze menu also
  hides individual entries and un-hides them on open, so the menu stayed
  standing with its contents blanked. An open menu is now skipped whole.

Minimal does not scale the row down proportionally, it opts out — a
shortened version of a gesture whose whole point is its length is not a
smaller gesture, it is a worse one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 10:42:47 +02:00
Paul LütznerandClaude Opus 5 12d0c10a51 docs: the handbook learns about the motion tier
Both languages, including the part somebody will look for and be confused not
to find: the operating system's reduced-motion request overrules the setting
rather than being merged with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 17:42:49 +02:00
Paul LütznerandClaude Fable 5 c7979c7d85 feat(appearance): the pl mark drops Google's colours and becomes a choice
The topbar monogram wore Google's literal brand hexes, which is why it read
as someone else's product. The mark is redrawn — shared top and bottom
lines, a square-ish bowl, a tighter tail turn, ink centred in its frame —
and its geometry now lives in exactly one place, a Twig macro that the
topbar, the settings tiles and the favicon all draw through.

The paint is a per-user setting: thirty-two colourways in LogoStyle
(single colours, duotones, ink with one coloured flick, and sweeps), picked
under Settings → Appearance → Logo, applied to the topbar live and saved
through the same pipeline as every other appearance knob. Berry — the
product's own sweep — is the default every account starts on.

The favicon follows the person now, so it is a route rather than a file:
/branding/favicon.svg answers in the viewer's colourway, revalidated by an
ETag that IS the style value, and anonymously it serves the product
default. The static icon set (favicon.svg, the PWA PNGs, apple-touch) is
repainted in Berry as the sessionless fallback, and the manifest and
theme-color meta follow its tile.

Two colourways from the design board are deliberately absent: the Google
original, and the four-distinct-hues restatement of it. A test pins both
out, along with the seven-strokes-per-style contract the macro depends on.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 17:49:54 +02:00
Paul LütznerandClaude Opus 5 0fb5bc2dbf docs: the appearance page catches up, and health stops promising a menu item
The handbook was last swept at v0.0.34, so this covers two releases: the
v0.0.35 items that shipped without a docs pass, and everything since.

**Appearance** was the page furthest behind and gains five sections. The
live preview and its resizable card (240–900, remembered per user, revealed
from a button in the card header on a phone and deliberately not remembered
there). The mail-list group: the account corner, preview lines and unread
emphasis. Typography, with the compose editor named as being outside the
scale — that size is per-message formatting that goes out in the sent HTML,
which is the part a reader would otherwise file as a bug. Per-surface
density, and why only density is per-surface. Backgrounds now apply live and
have a fourth kind (a flat colour) that was never documented at all.

Two limits are stated rather than glossed: sender discs cannot be switched
off because that disc IS the row's checkbox, so "off" paints over the
identity and keeps the target; and the preview's 900 is a ceiling, not a
promise — it stops around 374 on a 1280-wide window.

**Account health** documents the behaviour change directly, because someone
who read the old page will go looking for a nav item that is no longer
there on a good day. The page also gains the two push verdicts — lapsed
(renewal never ran, look at the scheduler) versus live-but-silent (the
Pub/Sub leg) — the three dates each card is judged from, and the fact that
"not delivering" is only ever reported from evidence, so silence is not
proof push works.

**Mail** gains the folding sidebar with its distinct-thread unread roll-up,
and corrects undo send: the old text said cancelling is decided before
anything is transmitted, which stopped being true in v0.0.35. A cancel can
now lose, and losing says so rather than handing back an editable copy of
delivered mail.

**Accounts** gains the fourth push badge state and the widened teardown
(calendar removal and an admin reset now hand registrations back too).

Fixed in passing: docs/features/accounts.md told operators to grant
`gmail-api@system.gserviceaccount.com` the Pub/Sub Publisher role. It is
`gmail-api-push@` — GmailWatchService's docblock records that the wrong one
was followed once and delivered nothing, since it is not an address Google
publishes from. A topic granted exactly what the page asked for never
delivers a single notification, in both trees.

Every German page re-stamped; the two trees are in sync and the eight
documentation-coverage assertions pass. Screenshots are NOT regenerated —
settings.png, admin.png and filters.png predate the shared card style, and
every screenshot shows the sidebar before its headings became disclosures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 13:53:32 +02:00
Paul Lützner 2bf3c38add small fixes that claude couldnt commit because who knows 2026-08-06 16:29:22 +02:00
Paul LütznerandClaude Opus 5 4d103cfbdf Write the handbook, and make it fail the build when it goes stale
README says what plMail is and gets you running; CONTRIBUTING says how to
change it. Nothing covered the part in between — using it, operating it,
and registering the Google and Microsoft applications it talks to. That
is docs/: 34 pages across features, installation, providers and
internals, mirrored to the GitHub wiki.

The wiki is a mirror and never a source. bin/mirror-wiki.php flattens the
tree into the wiki's flat namespace, rewrites every relative link,
generates the sidebar from the index so the two cannot disagree, and
stamps each page with a footer saying where it came from. A browser edit
survives until the next push to main. That cost is deliberate:
documentation not versioned with the code drifts from it, and the drift
is invisible until somebody follows an instruction that stopped being
true.

Which is the whole problem, so the checkable half is checked.
DocumentationCoverageTest fails the build when a command is missing from
the reference or documented and gone, when a variable in .env is absent
from the configuration page, when a relative link dangles, when a page is
unreachable from the index or promised and missing, or when a page has no
"Things that bite" section. It cannot check whether a paragraph is TRUE,
and says so — this is the floor, so review attention goes to the part a
machine cannot have an opinion about. CONTRIBUTING has claimed since it
was written that the command table "is part of the definition of done";
nothing enforced it until now, and it was wrong in three places.

Writing it found things. They are fixed here rather than documented
around, because a handbook describing a bug accurately is still a
handbook nobody can follow.

  compose.yaml            mounted no volume for var/attachments, var/raw
                          or var/uploads. Workers write those during sync
                          and the web container serves them back, so each
                          kept its own copy in its own layer — downloads
                          404, and everything died on a recreate. The
                          override and the TrueNAS file both mount them;
                          the file the README tells an operator to run did
                          not. The same omission cost real data once
                          before. See the CHANGELOG for moving what is
                          currently stranded.

  app:calendar:materialise  new, nightly. Occurrences are drawn when an
                          event is saved and nothing moved that window
                          again, so a weekly standup ran out of dates in
                          two years and its reminders stopped with them —
                          silently. The query it runs had existed since
                          the materialiser did, documented as exactly this
                          sweep, and had never been called.

  Microsoft wizard        asked for three of the nine permissions plMail
                          requests, and a token already issued is not
                          upgraded when the rest are added later.

  GmailWatchService       told you to grant Pub/Sub Publisher to
                          gmail-api@system.gserviceaccount.com. The
                          account is gmail-api-push@; a topic granted
                          exactly what the docblock asked for never
                          delivered a notification.

  IcsExporter             said it wrote no VALARM. It has written them
                          since alerts landed.

  CLIENT_DEVELOPMENT.md   said there was no JMAP calendar API and that
                          SearchSnippet/get was absent. Both shipped.

  CONTRIBUTING            said only app:calendar:push registers a push
                          channel; subscribing has done it since the
                          subscribe flow learned to.

  Three docblocks         named getters removed long ago, or described a
                          method two below the one they sat on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 21:13:25 +02:00