Deleting a pond already had a strict prompt — typing the pond name, stricter
than a confirm dialog. That was never the gap. The gap is that the person who
deletes it loses access the moment they do: the pond leaves their view, only a
Site Admin can bring it back, and the export is no longer reachable for them.
So the archive is offered INSIDE the deletion flow, before the button.
What it contains, and why it is not the existing export:
- Every page the requester may read, as Markdown, as before.
- **Every attachment of the pond**, not only the embedded ones. An
attachment nobody put on a page would otherwise vanish unnoticed — which
is the whole reason this issue exists.
- `manifest.json`: pond settings (EFFECTIVE, defaults filled in — a
preservation format must not require its reader to know Dorfteich's
defaults), labels, the page hierarchy and sort keys, comments, and
attachment metadata including the #199 hash so a reader can verify bytes.
It extends the #210 manifest rather than adding a second descriptor, and
carries an explicit `formatVersion`.
- `README.txt`, because the manifest is for machines: whoever unpacks a
folder of Markdown a year from now must not believe they hold a one-click
restore.
Decisions worth naming:
- **"Complete" describes the RESULT, not the route.** A pond admin who may
read every page gets `complete: true`; only an archive that actually
leaves pages out is incomplete. The Site-Admin route skips the read filter
(an archive taken before an irreversible purge must not depend on which
ponds the operator happens to be a member of) — those are two different
questions and the first version of this conflated them.
- **The omission is named before the download**, with its number, in the UI
and in the manifest. An archive silently missing content is worse than no
archive, because it ends the search.
- **Not downloading stays allowed.** A pond of test pages should not require
one, and the server cannot tell whether a file arrived anyway — so the
finality is stated in text instead of enforced.
- **A plain link, not fetch-into-a-blob.** The api streams the ZIP; buffering
a whole pond in the tab to draw a progress bar would trade memory for
cosmetics. The browser reports progress and completion; what it cannot say
— that the archive is being BUILT — is announced in a live region.
- Read trail unchanged in kind (ADR 0023): one `export` event per classified
page before any classified byte enters the stream. Attachments never travel
without their page, so the same events cover them.
- New audit action `pond.archived` (catalogue v1.7) with page and attachment
counts, omitted pages, and completeness.
Format documented in `docs/architecture/pond-archive-format.md`, including
what is deliberately NOT in it (history, permissions, trash).
Verified by hand, not only asserted: a real pond's archive downloaded and
unpacked — README, manifest, three page files, the media file; the manifest's
effective settings, per-page classification, the VS-NfD frontmatter and
marking preserved in the classified page's Markdown, and the attachment's
sha256 present. Plus six api tests (including that an unembedded attachment
travels and that a Site Admin gets a complete archive without membership) and
the a11y pack 11/11 in both schemes, which now also scans the pond settings
screen.
Not done, because there is nothing to attach it to: the Site Admin's purge
dialog (#193) exists only as an api endpoint — there is no pond-trash UI in
the web app. The api half is here and tested, so it becomes a link when that
screen is built.
The backend from #303 could store an operator's font but nothing could
choose one: no list endpoint outside the Site-Admin routes, no @font-face
rules for a family that only exists at runtime, and no management UI.
Found while wiring it up — a real defect in #303, invisible to its tests:
`fontStack` cannot tell an uploaded family from a deleted one, so the PDF
exporter embedded the face and then never named it. Every export of a pond
using an operator font rendered in the system font while the job reported
success. Both `fontStack` call sites now take the uploaded families
(`buildPdfHtml`, `pondFontVariables`); `pdf-html.test.ts` pins the
regression from both sides. Verified against a real Gotenberg: with the
families the PDF embeds PlayfairDisplay-Bold, without them NotoSans-Bold —
that was the whole bug, in one diff of two PDFs.
- `GET /fonts/custom` is readable by any signed-in user, not Site Admins
only: the pickers, the licence page and the injected `@font-face` rules
all need it, and gating it would have forced a second, admin-only UI.
- Bundled and uploaded families are told apart by their `<optgroup>`, not
by a badge — the grouping is then part of the control's semantics, so a
screen reader announces it and the native mobile select keeps it. Within
each source the catalog's category grouping is preserved.
- The delete confirmation names how many ponds use the family and what
happens to them; focus moves to it and back on cancel. Deletion stays
unblocked (the api's decision, #303) — the ponds degrade, they do not
break.
- The licence page grew a second table. That is what makes an attribution
obligation satisfiable: a commercial licence that requires naming the
foundry needs a page to name it on.
Verified in the browser end to end (upload two weights → listed and
rendered in its own font → chosen in a pond → page renders in it → deleted
→ pond falls back): api suite for fonts/export 77 passed, a11y pack 11/11
locally in both schemes, lint/typecheck/i18n:check green.
CI still reports 737 while the local stack is now clean, and the box list
was capped at 15 entries — all of them nav links clipped by their own
scroller. Whatever pushes the page in CI sits past that cap.
The list is now sorted by reach, marks each entry as either clipped by a
fitting scroller or actually pushing the page, and shows 40.
Two rounds now reported no element past the viewport edge while the
document still claimed 417px of overflow — a combination that rules out
every hypothesis I had, including my own filter.
So stop inferring. The guard now prints the html/body metrics, every
element whose own content is wider than its box (with its overflow-x, so
the intentional scrollers are distinguishable), and every box reaching
past the edge with no filtering at all. Diagnostics ride in the assertion
message, not the compared value, so they show up even when they match.
The previous run came back with an empty offender list and an unchanged
417px overflow: the filter treated everything under a scroll container as
innocent, including the container that was itself too wide. A scroller
only absolves its children when the scroller fits.
It now reports the chain from body down to the widest offender with each
box's width, so the first element wider than the viewport is visible
instead of inferred.
The first attempt fixed plausible suspects. CI measured the actual page
and named something else: six `.settings-nav__link` buttons, 417px of
page-level overflow at 320px.
`.settings-nav` already had `overflow-x: auto`, but as a flex child it
also had the default `min-width: auto` — the min-content width of the
whole jump strip. That forced the column wider than the viewport, so its
own overflow rule never had anything to scroll. `min-width: 0` is exactly
the case CLAUDE.md warns about under Reflow.
The guard now ignores elements that sit inside a scroll container. Such
content is *meant* to be wider than the viewport — reporting it buried
the one finding that mattered under twelve lines of noise, and the cap
truncated the list before it could show anything else.
The table wrapper and the wrapping settings rows from the first commit
stay. Neither was the cause here, but a table cannot shrink below its
min-content width and those rows cannot wrap on their own, so both are
hardening that holds regardless of content.
WCAG 2.1 SC 1.4.10 asks for no two-dimensional scrolling down to 320px,
which is also what 400% zoom on a 1280px screen produces. The layout
skeleton was already hardened for this in #165; the overflow came from
content inside the sections.
- The sessions table cannot shrink below its min-content width — four
columns, one of them the full user-agent string. It now scrolls inside
its own container rather than pushing the page. The container is
focusable with a role and a name, because a scroll area that only a
mouse can reach trades one barrier for another.
- `.settings-checkbox` rows may wrap. The accent swatches have a fixed
size and cannot shrink, so an unwrappable row set a floor for the whole
page width.
Adds a reflow guard to the a11y pack. axe does not cover 1.4.10 — the
criterion is not derivable from the DOM — so this is a separate check,
and it names the overflowing elements when it trips instead of only
reporting that something overflows.
ClassificationBanner renders the fixed ADR-0022 wording above and below
the content in reading view, editor and public page view; unclassified
pages show nothing. Announced to assistive tech via a localized hidden
prefix (de+en); styled from the plain text token only, so contrast holds
in both themes and under every accent with no new color pair. Public
content endpoint now carries the classification. New seed fixture
classified-note; a11y pack asserts banner top+bottom and axe-clean in
light and dark.
Co-Authored-By: Claude Fable 5 (1M context) <noreply@anthropic.com>
theme-contrast.test.ts parses tokens.css and asserts every real UI colour
pairing (4.5:1 text, 3:1 UI) for BOTH palettes, so palette drift fails
unit tests instead of review. theme.test.ts covers resolve/apply logic
(Node >= 22 ships a shadowing undefined localStorage global — the test
brings its own in-memory storage). The a11y pack now runs its four scans
in light AND dark via emulateMedia; the new theme pack exercises the
three-way switch end to end (instant apply, reload persistence, live OS
follow in system mode, override beats OS). ADR 0018 records the theming
model broadly: modes now, accent themes by derivation later.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QRtCnB3uLdQtFmvp9HXcRX
Die Zeilen-Aktionen der Personenverwaltung sind jetzt IconButtons
(lucide): MailCheck (Bestätigung erneut senden, nur bei Ausstehend),
ShieldPlus/ShieldMinus (Zum Admin machen / Admin entfernen),
UserX/UserCheck (Deaktivieren/Aktivieren), Trash2 (Löschen) — in dieser
Reihenfolge. Das zweistufige Löschen bleibt: die Bestätigung ist
weiterhin ein roter Text-Button und erhält beim Umschalten den Fokus
(kein Fokusverlust, ADR 0017). Lokalisierte Namen kommen unverändert
aus users.json via IconButton (aria-label+title), Icons aria-hidden.
Der Admin-Bereich ist neu im a11y-CI-Pack (axe WCAG A/AA auf /admin).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@axe-core/playwright als devDependency (exakt +2 Lockfile-Pakete,
axe-core hat null Runtime-Dependencies; Freigabe durch Stefan im Chat).
Neuer e2e-Pack a11y.spec.ts scannt Login, Lesemodus, aktiven Editor und
Nutzer-Einstellungen gegen WCAG 2.1 A/AA — jede neue Verletzung bricht
den Build (Allowlist bewusst leer, nur mit Begründung erweiterbar);
Best-Practice-Regeln bleiben außen vor. In ci.yml als eigener Schritt
mit Rate-Limit-Reset nach dem Muster der übrigen Packs verdrahtet.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AGM8jo3hwoV9wsCVGfy8iq