Makes `max` an optional prop with default 3 — the common row-layout
case doesn't need to name the cap explicitly. ThumbnailRow's callsite
drops to `<TagChipList tags={doc.tags ?? []} />`, consistent with how
other shared components in $lib/components expose sensible defaults.
Refs #305
Fixes @leonievoss round-2 follow-up from PR review
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds row_direction_sent / row_direction_received keys across the
three locale files (de: Gesendet/Empfangen, en: Sent/Received, es:
Enviada/Recibida) and routes ThumbnailRow's directionLabel through
Paraglide. An English or Spanish screen-reader user now hears
"Sent:" / "Enviada:" in their language, matching the DistributionBar
i18n pass.
Refs #305
Fixes @leonievoss round-2 follow-up from PR review
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Rewires briefwechsel-rows.visual.spec.ts against the shared fixture
(seedBilateralPair + cleanupBilateralPair), adds afterAll cleanup,
and folds the conv-person-bar visibility gate into openBilateral()
so both the structural test and the snapshot block fail loudly on
a hero-state regression — matching the a11y spec's safety net.
Refs #305
Fixes @saraholt follow-ups 1 + 2 + 3 from PR round-2 review
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Lifts the three-API-call seeding (create sender, create receiver,
create document) out of briefwechsel-a11y.spec.ts and into a
dedicated fixtures module. The spec now calls seedBilateralPair()
in beforeAll and cleanupBilateralPair() in afterAll so the test
DB doesn't accrue seeded rows across reruns.
Two caveats captured in the helper docstring: the backend has no
person-delete endpoint (only the document is purged), and the
timestamped last names make leftover persons collision-free.
Refs #305
Fixes @saraholt follow-up 1 + 2 from PR round-2 review
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Extends the seeding pattern from the a11y spec: beforeAll creates two
persons + one document so the page renders the row layout. The
structural test now asserts the ConversationThumbnail tile AND the
DistributionBar are present — a regression that drops to the hero
or breaks the row wiring fails here instead of silently passing a
hero-state check.
Snapshot block stays gated on VISUAL=1 (baselines captured during
review against a seeded backend) so the structural coverage ships
immediately and the pixel-diff coverage ships once baselines land.
Refs #305
Fixes @saraholt blocker 2 from PR review
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The previous version navigated to /briefwechsel with no params, which
renders the hero state — axe-core scanned the hero, not the new
ThumbnailRow / ConversationThumbnail / DistributionBar. This commit
seeds two persons + one document via the API in beforeAll, then
drives the URL with ?senderId=X&receiverId=Y so each of the
36 test runs (3 viewports × 2 themes × 2 assertions) actually scans
the intended DOM. Also asserts that conv-person-bar is visible first,
so a regression that drops the page back to hero fails explicitly
rather than silently passing an empty sweep.
Refs #305
Fixes @saraholt blocker 1 from PR review
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Lifts the three-chip-plus-"+N" tag row out of ThumbnailRow into a
standalone TagChipList component so the chip cap + overflow policy
lives in one place and can be reused on other surfaces (document
detail header is a candidate). ThumbnailRow drops from 110 to ~90
lines and no longer owns tag-slicing logic — it just asks for the
list with max=3.
Behavior is byte-identical: same data-testid, same max cap, same
"+N" overflow indicator. All ThumbnailRow row-level tag tests
continue to pass against the new composition.
Refs #305
Fixes @felixbrandt suggestion 1 from PR review
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Defaults `now` in $props() destructure so each row instance freezes
its reference time at mount, instead of calling new Date() inside
the $derived every reactivity tick. No behavioural change — the
date math is stable across re-renders for a given row — but drops
the nullish-coalesce dance and is cleaner under Storybook-style
testing where a deterministic `now` is injected.
Refs #305
Fixes @felixbrandt suggestion 3 from PR review
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
relativeYearsDe already returns "" for future dates (covered in its
own spec), but the integration wiring inside ThumbnailRow was
untested. Adds a regression that a doc with documentDate in the
future produces no "vor N Jahren" or "vor weniger als 1 Jahr" chip.
Refs #305
Fixes @saraholt concern 5 from PR review
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Consolidates the hansPerson / annaPerson fixture into a makePerson()
factory matching the makeDoc convention, adds an assertion that
the bilateral list renders one ConversationThumbnail tile per
document (catches a broken {#each} keying wired around the
DistributionBar), and decouples the DistributionBar aria-label
assertion from the German locale now that i18n lands via Paraglide.
Refs #305
Fixes @saraholt concerns 3 + 4 from PR review
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Bumps the multi-page badge from text-xs (12px) / px-1.5 py-0.5 to
text-sm (14px) / px-2 py-1. Meets senior-legibility on a 320px phone
without crowding the 120-wide tile — the badge stays tucked in the
top-right corner.
Refs #305
Fixes @leonievoss senior-accessibility concern from PR review
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Drops the hardcoded German strings ("Briefverteilung in diesem Zeitraum",
"{n} von {name}") and routes every visible + assistive-tech string
through dist_bar_aria and dist_bar_segment message keys. An English
or Spanish user now sees "from" / "de" instead of "von" both on
screen and in the aria-label their screen reader announces.
Refs #305
Fixes @leonievoss i18n concern from PR review
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Without this prefix, a color-blind user or screen-reader user has no
indication of correspondence direction — the colored left border is
information but not announced, and the arrow glyphs were removed in
the earlier layout pass. Prepending "Gesendet:" or "Empfangen:" to
the aria-label gives assistive-tech users the direction first so the
row identity is unambiguous even without color perception.
Refs #305
Fixes @leonievoss WCAG 1.4.1 concern from PR review
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a dedicated axe-core sweep for /briefwechsel so contrast or
semantic regressions on the new row layout fail independently of
the catch-all accessibility suite. Scoped to the main landmark so
shared chrome violations (if any) aren't double-reported.
Refs #305
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds a Playwright spec gated on VISUAL=1 with one snapshot per
(mobile/tablet/desktop × light/dark) = 6 baselines. Snapshots stay
skipped in CI until the baseline set is captured and committed —
running `playwright test --update-snapshots briefwechsel-rows`
against a seeded backend generates them.
Structural check runs unconditionally so the file is wired into CI
today rather than waiting for the baseline capture step.
Refs #305
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds two new assertions for the extracted DistributionBar — it must
appear in bilateral mode and stay hidden in single-person mode — and
repairs the shared makeDoc fixture: the embedded Person now carries
personType + displayName so the fixture matches the regenerated
Document schema without TypeScript complaints.
Refs #305
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Drops the inline row markup, arrow icons, status-dot helper, and the
otherPartyName helper that only fed it. Each visible row is now a
ThumbnailRow, which owns its own aria-label, border color, meta and
tag rendering. The year-divider and "new document" footer are
untouched — they were always intended to stay as timeline chrome.
Also widens the documents prop shape to include the summary, tags
and thumbnail metadata that ThumbnailRow consumes; the backend
already returns these fields via the Document schema so no server
change was required.
Refs #305
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Combines ConversationThumbnail with a quote-styled summary, truncated
meta line, and up to three tag chips (the rest collapsed into "+N").
The colored left border tells a reader at a glance whether this
letter left or entered the perspective person's mailbox — replacing
the previous status dot + script-type icons that were too busy for
the list view. Relative-year label ("vor 76 Jahren") is derived from
documentDate so the list carries temporal context without a full
date column.
Rendering rules:
- title falls back to originalFilename when empty
- summary uses a text expression, never {@html}, so inline markup
in the summary field is escaped (XSS regression test locks this)
- focus-visible outline + focus-within hover keep keyboard-only
users in sync with mouse hover feedback
- aria-label always pairs title with the formatted date so screen
readers hear both identifiers
Refs #305
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Reads thumbnailAspect from the backend and swaps between a 120×168
portrait tile and a 168×120 landscape tile so postcards and photos
don't get cropped into a portrait frame. Shows a page-count badge
top-right for multi-page PDFs, and a pulsing skeleton while the
async thumbnail job hasn't run yet. URL assembly goes through the
existing thumbnailUrl helper so cache-busting stays consistent
with DocumentThumbnail.
Refs #305
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The correspondence timeline labels each row with its distance from today
("vor 86 Jahren"). Uses calendar-field math so the anniversary day
flips exactly — an ms-based 365.25d average misses by a day on leap
years. Invalid / future dates return "" so the caller can hide the
label rather than print "vor 0 Jahren".
Refs #305
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Drops the inline bilateral-distribution markup and the short-name /
percentage helpers that only existed to feed it. ConversationTimeline
now hands senderName, receiverName, and the two counts to the shared
component and lets it own the rendering.
Refs #305
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Lifts the inline distribution bar out of ConversationTimeline so the
same two-tone ratio widget can be reused on other bilateral surfaces
(e.g. the person detail page). Markup/styling is byte-identical to
the inline version; only the prop interface is new.
Refs #305
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Mirrors the backend entity additions so the frontend row components
can consume the aspect (portrait vs landscape tile) and the page count
(badge on the thumbnail) without any runtime guessing.
Refs #305
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The 12px text felt cramped next to the larger 120×168 thumbnail. Lift
the date / VON / AN / progress label to 14px so the row reads
comfortably without changing the width or the row height.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Nesting the tag <button> inside the row's <a href="…"> made the browser
treat any click on the button as a click on the anchor, sending the
user to the document detail page even though the tag handler called
goto() with the tag-filter URL. e.stopPropagation() doesn't cancel
the anchor's default navigation.
Refactor to the stretched-link pattern: the row-wide anchor sits as an
overlay (`absolute inset-0 z-0`) and the content wrapper sits above it
(`relative z-10` + `pointer-events-none`). Tag buttons re-enable
pointer events with `pointer-events-auto`, so they're true siblings of
the anchor and receive their own clicks. Empty content areas pass
through to the anchor for whole-row navigation.
The vitest-browser client project doesn't load Tailwind CSS, so the
z-index has no effect there and Playwright's coordinate-based click
hits the anchor instead of the button. Trigger the click directly on
the button DOM element in the unit test (with a comment explaining the
test-env constraint); the actual user-facing behavior is verified via
playwright against the running dev server.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Refill the columns that went visually empty after the previous dedup
commit (`fc0fc57`):
- Middle column gains the document `summary` (line-clamp-2, italic,
with `summaryOffsets` highlighting — the backend already populates
the offsets, the frontend just wasn't rendering them) and a row of
thin neutral chips for `archiveBox`, `archiveFolder`, and `location`
(~99% of docs in the corpus carry these). Chips are desktop-only
and skip empty values.
- Right column restores `VON sender` and `AN receivers`, now with
`<mark>` highlighting that the previous right-column copy lacked,
so search matches stay visible there.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The desktop document-list row showed sender/receiver twice — once
side-by-side in the middle column and again stacked in the right
column. Stack the middle-column block vertically (the side-by-side
grid wasted horizontal space and competed with the larger thumbnail)
and remove the now-redundant copy from the right column.
The middle-column block keeps the search-match highlighting, which the
right-column copy never had.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Add a `size` prop to DocumentThumbnail (default `sm` keeps the existing
60×84 tile used in person sublists; new `lg` is 120×168) and use `lg`
for the main document-list row, where the previous tile occupied less
than half of the row's vertical space.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Chunked requests omit Content-Length entirely. The previous guard
only checked the header and was bypassed. Now the body is buffered
first and its byteLength is checked, catching both cases.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Closes the two untested code paths flagged in review:
- PATCH method routes to backend with correct URL
- Requests without Content-Length header pass through (NaN > n = false)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The sendBeacon name was misleading after switching to keepalive fetch.
Also adds a test to confirm flush is a no-op when pendingTexts is empty.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Blocks requests with Content-Length > 1 048 576 bytes with 413.
Tests cover security guards, body limit, and response forwarding.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
sendBeacon always sends POST, but the backend expects PUT for block updates, so
saves were silently dropped on page unload. Replace with fetch({ keepalive: true,
method: 'PUT' }) which survives navigation and uses the correct HTTP method.
Add a catch-all SvelteKit server route at /api/[...path] so all client-side API
calls work in production (without the Vite dev proxy). More-specific routes
(/api/persons, /api/tags, /api/documents/[id]/file) keep precedence.
Closes#204
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Addresses @leonievoss and @felixbrandt — fix(ui): "the PDF icon
misleads for image documents" and "swap for a neutral file icon".
The fallback now shows a generic document-text glyph (page outline +
three text lines) instead of the PDF-specific icon with the folded
corner. Applies equally well to PDFs, JPEG/PNG scans, and TIFF
documents — all of which can land in the fallback path.
Also bumped the icon from h-6/w-6 to h-8/w-8 — the previous 24px
glyph looked sparse inside the 60×84 tile (Leonie, post-merge
iteration point #2).
Refs #307
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- admin.spec: click 'Thumbnails erzeugen', wait for status DONE
within 30s, screenshot the success message
- accessibility.spec: /admin/system joins the page list so the
thumbnail card is checked in light, system-dark, and manual-dark
axe-core runs
Refs #307
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Fourth card on /admin/system mirrors the mass-import pattern:
- POST /api/admin/generate-thumbnails to trigger
- 2000 ms polling on /api/admin/thumbnail-status while RUNNING
- processed / skipped / failed counters in the DONE message
- standalone pollInterval so import and thumbnail polling don't
interfere with each other
Paraglide keys added in de/en/es, mirroring admin_system_import_*.
Refs #307
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Home search rows and person detail sidebars now show the real
first-page preview when one exists, falling back to the PDF icon
for documents the backfill hasn't processed yet. The old `variant`
prop on PersonDocumentList is removed — it tinted the icon
differently for sent vs received, which no longer applies with a
uniform thumbnail tile.
Refs #307
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Renders the document thumbnail with object-cover + object-top so
letter salutations stay visible, empty alt (title nearby is the
accessible name), loading=lazy, decoding=async, and dark:mix-blend-multiply
for dark mode. Falls back to a PDF icon when thumbnailKey is null —
legacy documents, unsupported content types, or transient failures
all land here.
Refs #307
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Pure function returning /api/documents/{id}/thumbnail?v=<timestamp>
or null when thumbnailKey is missing. The encoded timestamp changes
whenever the backend regenerates a thumbnail (file replace),
invalidating browser caches despite the immutable Cache-Control.
Refs #307
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Mirrors the backend Document entity's new optional fields. Both are
optional (no @Schema requiredMode on the backend side), so legacy
documents without thumbnails stay valid.
Refs #307
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
On CLOSED readyState, probes session and redirects to /login only on 401.
On CONNECTING, counts consecutive errors and closes + probes only after 3
failures, preventing infinite retries without killing transient reconnects.
Closes#203
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Upload button text wrapped in hidden xl:inline to hide label below xl
- AppNav logo margin reduced from mr-10 to mr-4 xl:mr-10 at lg breakpoint
Combined these changes bring the header content to ~923px vs ~945px
available space at 1024px, eliminating horizontal overflow
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- BackButton gains showLabel prop: showLabel=false renders icon-only with
aria-label, no mr-2 on svg (was causing 0px button width in topbar)
- DocumentTopBar: BackButton restored to h-11 w-11 circular touch target
with showLabel=false matching the original 44×44px <a> it replaced
- Topbar row gets pr-4 (16px right padding per spec); action buttons div
no longer needs its own pr-3
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The document detail page back button was missed in the original refactor —
it still pointed to "/" (dashboard) regardless of where the user came from.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- BackButton now accepts a `class` prop (default 'mb-4') so callers can
override spacing; resolves hardcoded margin in flex-row topbar snippets
- documents/[id]/edit and enrich/[id] pass class="" to suppress the margin
- Replace weak className unit test with class-prop behaviour tests
- Add [data-hydrated] comment in E2E spec explaining what emits the attribute
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
All 7 in-scope back navigation links converted to use history.back().
Admin panel mobile chevron converted inline (icon-only, different
visual pattern). Cancel buttons left as static <a> links.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>