Commit Graph
22 Commits
Author SHA1 Message Date
Paul LütznerandClaude Opus 5 8c2ef7663b feat(ops): a demo deploy that cannot quietly do nothing
Three commands in the handbook, one of which fails silently.

`docker compose pull` can run out of disk part-way through extracting a
layer. `up -d --wait` then brings the stack up on the image that is
ALREADY there and reports every container Healthy — so a deploy that
changed nothing is indistinguishable from one that worked, and the only
evidence is a line buried in the pull output. v0.1.36 was believed
deployed for twenty minutes while the demo went on serving v0.1.35.

Piping made it worse. Through a pipe the exit status belongs to whatever
came after it, so the `pull | tail -2` in the handbook's sequence
reported success no matter what the pull did. Nothing in the script is
piped.

And the disk fills on its own: every deploy leaves its predecessor behind
and nothing collects them. Eleven orphaned images is what filled it, on a
host that also runs Apache, MySQL and ClamAV and never has more than a
few GB spare.

So: check for room, prune if short, pull and STOP if that fails, up,
then clear the images the deploy replaced.

  · `-a` on the prune is load-bearing. A replaced release is a fully
    tagged image nothing references any more, and dangling-only leaves
    every one of them.
  · `--volumes` is never passed. The demo's database is in one.
  · compose.proxy.yaml is included by existence rather than by name: it
    belongs to a host that already has something on port 80 and is not in
    this repository.

Verified on the real demo host, both ways — a normal run reports "image
unchanged" and exits 0, and DEMO_MIN_FREE_MB=999999 refuses, exits 1, and
leaves the running stack alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 11:11:09 +02:00
Paul LütznerandClaude Opus 5 24a5ab3de1 build: mercure 0.8, and a database of PHPUnit's own
MERCURE 0.5.0 / symfony/mercure 0.8.0, kept out of the earlier sweep
because it carries the live-update transport and should not ride along
with eighteen patch bumps. It earned that: HubInterface gained
getProtocolVersion() and getCookieName(), so the two hub doubles in
CalendarSyncFinishedNotificationTest stopped satisfying it — a fatal
error, not a failing test. Both implement them now, returning what a real
hub would rather than pretending to configuration these tests do not
have. Verified where it matters: the full browser suite, including the
specs that stop and restart the hub to prove the stream recovers.

A DATABASE OF ITS OWN for the unit suite.

Both suites run APP_ENV=test, and doctrine.yaml's
`dbname_suffix: '_test%env(default::TEST_TOKEN)%'` therefore pointed both
at app_test. One database, two suites — and one test in here truncates
real tables, because exercising a data reset is its whole purpose.

Sequentially that is safe; Postgres rolls a TRUNCATE back like anything
else. Concurrently it is not: TRUNCATE takes an ACCESS EXCLUSIVE lock, so
live queries from the browser suite block until the unit test's
transaction ends, and specs with no connection to any of this report
timeouts. It fits the transaction abort seen here once and never
reproduced.

TEST_TOKEN is Symfony's own mechanism for exactly this, so the fix uses
it rather than inventing a second DATABASE_URL. bin/test-unit.sh sets it
and does the create/migrate/seed a fresh database needs.

The seeding is the part worth guarding: PageRendersTest, ListFragmentTest
and others SKIP THEMSELVES when the admin user is missing, so without it a
green run would quietly be ~50 tests smaller than it looks — a worse
failure than a red one. Checked rather than assumed: 4047 tests either
way, no skipped or incomplete markers.

The assertion count moved, 11540 → 11526, and settled there across runs.
That is the point of the change showing up: the unit suite was reading
rows the browser stack had left behind, and now it reads only its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 15:08:17 +02:00
Paul LütznerandClaude Opus 5 f6aeb02e0d Give the landing page a way off itself on a phone
You were right, and I had it backwards. I left the burger off the landing
page on the grounds that it has no sidebar to open — but .topbar-links are
hidden at that width too, so the front page offered no navigation at all.
Somebody arriving from a link had a page and no way anywhere else. It
needs the drawer MORE than a handbook page does, not less.

The drawer is on every page now, carrying the handbook navigation plus the
bar's own links, and it is hidden above the breakpoint where the sidebar
would have no business sitting beside a hero.

That hiding is scoped to a min-width query rather than left to source
order. Written as a plain rule it sat after the narrow block, matched at
every width and won on order — so the first version of this put a burger
in the phone's markup that nothing could click, which is exactly the bug
being fixed, one layer down.

On the horizontal scrolling: the document itself does not pan, at 320,
390 or 393. What moves is a <pre>, which scrolls inside its own box by
design — a wrapped shell command is a command somebody pastes wrong. But
`overflow-x` was `visible`, so anything that overflowed in future WOULD
take the page with it. It is `clip` now, not `hidden`: hidden would make
the element a scroll container and silently break the sticky top bar,
which is the thing the fix was protecting.

And the image build skips documentation. A handbook page, a translation
or a change to the site tooling cannot alter the image, and rebuilding it
for two architectures to publish identical bytes is the most expensive way
to do nothing. CHANGELOG.md is deliberately not in the ignore list — a
release commit always touches it, so the release path can never be
filtered out by a rule aimed at prose — and workflow_dispatch is there for
the case that reasoning misses.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 08:40:56 +02:00
Paul LütznerandClaude Opus 5 5bda9e9db8 Stop the landing page being 1331 pixels wide on a phone
Two causes, and the first is the kind of bug that hides behind a
reasonable-looking line.

`.layout` carries `align-items: flex-start`, which is right in the row
layout — it stops the sidebar stretching to the article's height. On a
phone the same container becomes a column, and there `flex-start` no
longer means "top": it means "as wide as your content". So <main> sized
itself to its widest unbreakable line and came out 1331px wide inside a
393px viewport. The handbook did not show it because .prose caps at 46rem
there, so it was merely wrong by less — which is why the phone tests
passed while the front page was unusable.

The second was a long URL inside inline code. It has nowhere to go and
pushed a 320px phone over by seven pixels. Inline code may break now;
code inside a <pre> deliberately may not, because that block scrolls and
a wrapped shell command is a command somebody pastes wrong.

The overflow check now runs over the landing page, the handbook index, a
long install page and a German provider page rather than one page that
happened to be fine, and reports how many pixels over it went. Both
failures were silent, and a page that scrolls sideways looks broken before
a single word of it is read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 08:32:50 +02:00
Paul LütznerandClaude Opus 5 5d83c4d5e9 Make the site usable on a phone
Three things, and the middle one is the reason the other two matter.

**The sidebar was a wall.** Below the breakpoint it became a full-width
block ABOVE the content, so reaching the first paragraph of any page meant
scrolling past thirty-four links. It is an off-canvas drawer now, opened
by a burger, closed by the burger, the scrim, Escape, or following a link
— which navigates, and closing first stops the old page's drawer
animating away over the new one.

**Both pickers moved into the foot of the drawer.** Four controls do not
fit across a phone: with all of them in the bar the search box was
squeezed to twenty-four pixels, which is not a search box. They are moved
rather than duplicated, because two copies means two elements sharing an
id and neither picker working, and moved by script because no stylesheet
can reparent a node. The bar now holds a burger, a wordmark and a search
box.

**Zoom.** The viewport carries user-scalable=no as asked, and it is worth
knowing that is half a measure: iOS Safari has ignored it since iOS 10,
and where it IS honoured it is a WCAG 1.4.4 failure. What actually stops a
phone zooming unbidden is the 16px minimum on inputs — Safari zooms the
page to focus anything smaller — so that is the part carrying the weight.

Covered by tests/e2e/site.spec.ts, still opt-in and still not in CI. Two
things worth recording from writing them: `devices["iPhone SE"]` is the
FIRST-generation SE at 320×568, so a tap coordinate written as 350 was
off-screen and silently hit nothing; and the scrim covers the viewport, so
its centre is under the open drawer and a locator click is refused. The
tap is derived from the drawer's own edge now.

One self-inflicted wound worth naming: rewriting the narrow-screen block
by slicing from the media query to end-of-file also deleted the picker and
fallback-notice rules that had been appended after it, which put "Theme"
and "Language" on screen as visible text. Found by looking at a
screenshot.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 08:26:26 +02:00
Paul LütznerandClaude Opus 5 e6d0e9ca5d Keep the reader in the language they chose, and publish faster
**The picker was not the bug; the navigation was.** Every sidebar link on
a German page was built against the English path, so choosing German
lasted exactly one click and then put you back in English. The sidebar
and the Handbook link now follow the page's own language.

**And the choice survives arriving somewhere else.** It is remembered, so
a bookmark, a link from somebody else or a fresh visit lands in the
language you picked rather than resetting to English every time. The
redirect cannot loop — it lands where the page's language already equals
the remembered one — and it cannot strand anybody, because choosing
English is what sets the memory to English. The landing page is exempt:
following the memory there turned every visit to the site into a redirect
into the handbook, which is a redirect away from the page somebody asked
for rather than a language preference.

tests/e2e/site.spec.ts covers all of it — the two bugs you found by
clicking, plus the three ways the fix could go wrong. It is opt-in behind
E2E_SITE (`npm run test:e2e:site`) and does not run in CI: it needs a PHP
build and eight browser pages, and what it guards is a static site whose
failure is visible the moment anybody opens it.

**Publishing is faster.** The Pages job ran composer.json's auto-scripts
on every docs change — cache:clear, assets:install and importmap:install,
which is a Symfony kernel boot and a set of CDN downloads for JavaScript,
to render Markdown. --no-scripts, plus a download cache. The wiki job
needs no Composer at all and never did: bin/mirror-wiki.php has no
dependencies, which is now said out loud so nobody adds one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 22:54:41 +02:00
Paul LütznerandClaude Opus 5 4d3c1266ad Send the wiki and the language picker where they meant to go
Two things German broke, both mine.

**The wiki build.** bin/mirror-wiki.php resolved a translated page's links
against its own directory, where bin/build-site.php and
DocumentationCoverageTest both resolve against the English tree. It was
the odd one out, and nothing noticed until there was a translation to be
odd about: from docs/de/features/, `../screenshots/inbox.png` is
docs/de/screenshots/ and does not exist, and from docs/de/install/,
`../../CONTRIBUTING.md` lands one directory short of the repository root.
Twelve links, and the check refused to publish — which is the check doing
its job, so this is a fix to the script and not to the gate.

Having resolved through the English tree, a link now lands on the
reader's own language wherever that page exists. Otherwise every link in
the German handbook would have walked them back into English.

**The language picker did nothing on the landing page.** It rewrites the
/docs/ segment of the path, and the landing page has none — so the switch
matched nothing, the browser went to the identical URL, and it looked
like a reload. The landing page is README.md and has no translation, so
there is no German counterpart to send anybody to; it now goes to the
handbook's index in that language, which is where somebody changing the
language on a front page was headed anyway.

Both were found by looking rather than by the suite: the wiki failed
loudly in CI, and the picker was reported by the person clicking it.
Neither is covered by a test, which is worth saying — the render check
catches a dangling link but not a link that resolves somewhere useless,
and nothing at all exercises the picker.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 22:46:31 +02:00
Paul LütznerandClaude Opus 5 629d937d97 Translate the handbook into German
All 34 pages, under docs/de/. The site serves them at /docs/de/, the
language picker switches between them, and a page that ever falls behind
can simply be deleted — the English falls back with a banner saying so.

The handbook duzt. plMail is something a person runs for themselves on
their own machine, and a manual that siezt is describing a different
product. That correction arrived after four of the six translations were
drafted, and those pages were rewritten rather than patched: a handbook
that switches register halfway is worse than either choice consistently.

Two defects in the coverage test, both found by translators hitting them:

  The link check resolved a translated page against the English tree by
  replacing '/docs/de/' inside the DIRECTORY — which never fires for a
  page sitting directly in docs/de/, whose directory has no trailing
  slash. Every page one level down passed and only the language's own
  index failed, so the bug was invisible in exactly the files anybody
  writes first. Derived from the relative path now.

  The traps-section exemption was keyed to literal English paths, so
  docs/de/CLIENT_DEVELOPMENT.md was required to carry a section its own
  source is excused from. A translator dutifully invented one, which is
  the failure mode worth naming: a test that demands nonsense usually
  gets it. The exemption follows a page into its translations now, and
  the invented section is gone.

And one in the site builder, which was breaking English pages too. Links
carrying a fragment were never rewritten from .md to .html — the greedy
target group swallowed the '#', realpath could not resolve what was left,
and the link was emitted untouched. Every cross-page anchored link in the
handbook 404'd while the same link without an anchor worked.

The site's chrome is translated too. A German page framed in an English
search box says the translation was an afterthought.

plMail's own German is now consistent with it: fifteen strings in the
calendar and integration screens siezten while the rest of the catalogue
duzt. They were written today, which is how they diverged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 22:32:41 +02:00
Paul LütznerandClaude Opus 5 af3265a045 Make a translation possible, and default the site to Solar
Three things, all in service of the German handbook that follows.

**A language is a directory.** docs/<locale>/ with the same paths as the
English tree, and the site renders a language from the ENGLISH page list,
falling back to the English page for anything not translated and saying so
in a banner in the reader's own language. Without that fallback a language
is all-or-nothing, which means it is never begun; with it, the first page
translated is the first page somebody reads in German and the rest still
resolve. It also makes deleting a bad translation a safe fix rather than a
hole.

**A translation says what it was made from, and a test checks.** Each
translated file opens with `<!-- translated-from: <path> sha1:<hash> -->`
and DocumentationCoverageTest re-hashes the English. This is the only
check here aimed at truth rather than inventory, and it gets at it
sideways: nobody can test whether a German paragraph still describes the
code, but a translation made from a page that has since changed is a claim
about the past, and that is checkable. A stale translation is worse than a
missing one — the missing page admits it, the stale one is fluent and
wrong.

A content hash, not a git blob hash and not a commit date. Dates mark
every translation stale after a whitespace sweep, and git is unusable from
the test container, which refuses this repository for dubious ownership —
a failure this suite has already had once.

The other checks are now locale-aware: a translated page is not required
to appear in the English index (its position is its path), its traps
section is looked for under the fixed heading its language uses, and its
links resolve against the ENGLISH tree — which is what the site does too,
and what stops a partial translation demanding that every page it links to
is already translated.

**Solar is the site's default theme**, and "System" now means something.
There was a [data-theme="dark"] block and no prefers-color-scheme query at
all, so System was light on every machine — a switcher option that
silently did nothing. The media query is scoped :not([data-theme]) so an
explicit choice still out-ranks the operating system, or Solar would lose
to a dark-mode laptop.

The README now points at the handbook above the fold, phrased as a link to
the handbook rather than to "our website" — the same line is the top of
the landing page that README generates, where "visit the site" would be
addressed to somebody already on it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 22:10:44 +02:00
Paul Lützner 3f9f5e34b9 Make /docs/ a page rather than a 404
The handbook's index rendered as docs/README.html, which is a faithful
translation of the file name and the wrong URL: every "Read the handbook"
link points at the directory, and so does anybody who trims a page's URL
back a level. Both got GitHub's 404.

README.md in the docs root now becomes index.html, and the link rewriter
applies the same rename to whatever pointed at it — otherwise the pages
that link back to the index would all point at the file that no longer
exists.

Found by asking for the URL rather than trusting the deploy's green tick.
2026-08-05 21:56:57 +02:00
Paul LütznerandClaude Opus 5 972a8a9d3f Give plMail a landing page, built from the repository it describes
A wiki is where somebody already inside GitHub looks. It has no way to be
the thing a link to plMail shows a person who has never seen it, so there
is now a site as well: a landing page and the whole handbook, at
karatektus.github.io/pl_mail, deployed by .github/workflows/pages.yml on
every push to main.

It holds no content. The landing page IS README.md and the handbook IS
docs/, rendered into a shell by bin/build-site.php — nothing on the site
can say something the repository does not, because there is nowhere for
it to be written down. Even the hero is lifted rather than written: its
lead is README's own opening paragraph, because a hero composed here
would be the one piece of prose no file backs and the first thing to go
stale. The wiki mirror stays; two outputs of one source cannot disagree
with each other.

The tree is preserved rather than flattened, unlike the wiki mirror,
which had to flatten because a wiki has one namespace. So a cross-link
between two pages needs only its extension changed and an image reference
needs nothing, and the rewriting that remains is the small honest kind: a
path that escapes docs/ becomes a link into GitHub, because the site
carries the handbook and not the source.

**The theme picker is generated from the app's own enum.** Theme::swatch()
already carries surface, ink and accent per theme for the appearance
picker, and the rest of a palette is derived from those at build time —
so the site offers exactly the seven plMail offers, and adding an eighth
to the app adds it here. Transcribing them would have agreed until
somebody added one, and then the site would quietly offer six of seven.

The language picker lists a locale as available when docs/<locale>/
exists and as "not translated yet" when it does not, which makes
translating the handbook the act of creating that directory and nothing
else. Today that means English, with German and Pirate shown and
disabled — a switcher that silently omitted them would say the site has
no opinion about German, where this says where the gap is.

Search is the whole handbook as 140KB of JSON, fetched on first focus and
matched in the browser. A hosted index would be faster to write and would
stop the site being a directory of files that works from a checkout, from
a fork's Pages, and behind whatever proxy somebody puts it behind.

league/commonmark is a require-dev dependency: the site is built in CI and
never by the application, so a Markdown parser in the runtime image would
be a package nothing runs. Bumped guzzlehttp/guzzle to 7.15.2 while
composer was open — 7.15.1 carries two advisories, one of which is a
noncanonical host bypassing host-based checks, which is the shape of the
SSRF guard.

Also corrects this file's claim that the non-Docker Playwright variants
start `symfony serve`. They have not since that server was replaced.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 21:54:19 +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
Paul LütznerandClaude Opus 5 36bc791f93 Measure the JavaScript, and cover the three controllers that had none
Playwright collects V8 coverage when E2E_JS_COVERAGE is set, one raw file
per test, aggregated by bin/js-coverage.mjs. Only this application's own
scripts are counted — Stimulus and Turbo are in the same importmap and
would otherwise dominate a number nobody here can act on.

The aggregation is the part worth reading. V8 nests its ranges: a
function that ran reports one span with a count, and the branches inside
it that did not as zero-count spans within it. So the covered set has to
be resolved per test and only then unioned. Pooling the dead ranges
across tests instead reads "this block did not run in test A" as "this
block never runs" — and since the importmap preloads every controller on
every page, every spec that merely loaded a module cancelled the specs
that exercised it. It reported 28.2% and did not move when three specs
were added, which is what gave it away. Corrected: 63.1%.

Three new spec files, for three controllers with no browser test at all:

  search        the operator dropdown and what the parser does with an
                operator it cannot honour — the difference between "no
                results" and "your whole mailbox". 22% to 90%.
  admin-queue   what a worker is holding, and that the filter asks the
                server rather than sifting the page already fetched.
                0% to 79%, with app:test:seed-queue to give the panel
                something to be about.
  filters-scope removing the last condition, and the sentence naming the
                account once one is chosen. 18% to 77%.

One bug, found the way browser tests are supposed to find them: the
search box is an <input type="search">, which Chromium clears natively
on Escape — so "first Escape closes the list, second clears the box" had
never worked. The close branch preventDefaults now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 23:50:37 +02:00
Paul Lützner a947c4aaac theming done for now 2026-07-23 16:09:03 +02:00
Paul Lützner 3b10b52488 theme update 2026-07-23 14:57:04 +02:00
Paul Lützner 7c8d0de52d update 2026-07-23 14:34:56 +02:00
Paul Lützner efad90b3de theme update 2026-07-23 14:28:06 +02:00
Paul Lützner ec29fc7479 new sweep 2026-07-23 14:19:14 +02:00
Paul Lützner 7a1170b643 update theme 2026-07-23 14:17:49 +02:00
Paul Lützner 162fec0944 theme step1 2026-07-23 13:53:37 +02:00
Paul Lützner 55862ccb2f Add webapp packages 2026-06-24 14:53:43 +02:00
Paul Lützner db8b7fbac5 Add initial set of files 2026-06-24 14:51:50 +02:00