feature(briefwechsel): thumbnail rows with summary quote and bilateral distribution bar #305
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Redesign the
/briefwechselrow so each entry feels like a letter worth opening, not a database record. Adds a PDF first-page thumbnail on the left (portrait for letters, landscape for postcards), renders the summary as a quote next to it, and restores the bilateral distribution bar above the list when both sender and receiver are selected. Removes the status-lifecycle chip and script-type indicator — both are unreliable / slated for removal.Spec
Final HTML spec (scaled wireframes × 3 viewports + impl-ref tables with exact Tailwind classes and pixel values + WCAG contrast verification):
docs/specs/briefwechsel-thumbnail-rows-spec.htmlExploration context (how we got here — 5 concepts):
docs/specs/briefwechsel-fill/index.htmlWhat changes visually
ConversationTimeline)Removed from earlier drafts
Breakdown
Phase 1 — UI without thumbnails
ThumbnailRow.sveltecomponent (replaces row markup inConversationTimeline.svelte)DistributionBar.sveltecomponent (lift out ofConversationTimeline.svelte— also needed by the person-dashboard issue)YearDivider.sveltewrapper (or keep inline — see spec)conv_direction_out,conv_direction_in,doc_kind_postcard,doc_pages_count,rel_years_ago)prefers-reduced-motiondisables the hover liftPhase 2 — wire real thumbnails (depends on thumbnail-generation issue)
DocumentwiththumbnailUrl,thumbnailAspect(PORTRAIT|LANDSCAPE),pageCount/api/documents/conversationreturns the new fieldsDocumentThumbnail.sveltewithloading="lazy", intersection-observer, skeleton → real image swappageCount > 1Phase 3 — observe + tune
Accessibility contract
All contrast ratios verified in the spec — light and dark mode both pass WCAG AA, most pass AAA. Row is a native
<a href>, keyboard-reachable, focus-visible ring, distribution bar hasrole="img"+ descriptivearia-label, direction uses arrow shape + colour (redundant cue), no text below 12 px, 128 px row >> 44 px WCAG minimum.Related
DistributionBar.sveltecomponent with #PERSON-DASHBOARD (linked below once opened)🏛️ Markus Keller — Senior Application Architect
Observations
ThumbnailService,ThumbnailBackfillService,ThumbnailAsyncRunner,AdminController#generateThumbnails,Document.thumbnailKey/thumbnailGeneratedAt, andGET /api/documents/{id}/thumbnailare all already shipped. Phase 2 is not blocked — it needs to extend the existing service, not wait for it.thumbnailUrl,thumbnailAspect,pageCountas "new fields from the thumbnail service". Two of them (thumbnailAspect,pageCount) need schema changes.thumbnailUrlis a client-side derivation fromthumbnailKey+thumbnailGeneratedAt— it should not be added to the API surface.GET /api/documents/conversationcurrently returnsList<Document>— the entity is the API contract. This has been tolerable because eager-loaded collections (tags, receivers, sender) are already small, but the list can be 851 rows for one person. Adding two more fields is fine; the shape hasn't changed.ThumbnailServicewrites JPEG at a fixed 240-px width. That is a spec/implementation inconsistency that will surface the moment someone tries to follow the spec literally.DistributionBaris shared withperson-dashboard-spec.html. Same props, same aria-label pattern. Extraction location matters for reuse.Recommendations
thumbnailUrlfrom the backend contract. Derive the URL in$lib/thumbnailsfromthumbnailKey+thumbnailGeneratedAt— this is already howDocumentThumbnail.svelteworks today. Sending a URL field muddies the API and invites cache-key drift.thumbnailAspect(enumPORTRAIT | LANDSCAPE) andpageCount(integer) onDocumentin Phase 2. Compute both insideThumbnailService#generate:thumbnailAspect=source.getWidth() / (float) source.getHeight() > 1.1f ? LANDSCAPE : PORTRAIT, persisted alongsidethumbnailKeyinpersistThumbnailMetadata.pageCount=pdf.getNumberOfPages()from the already-loadedPDDocumentinrenderPdfFirstPage(return it up togenerate). For image uploads:pageCount = 1.ThumbnailBackfillServicegets this data populated the next time it runs — no separate backfill job needed. Trigger the admin backfill once after migration.DistributionBar.sveltein$lib/components/, not under$lib/components/conversation/or inside thebriefwechsel/route. It's genuinely shared between two domains (conversation + person dashboard); cross-domain components belong at the library root so neither route directory "owns" it.thumbnailAspect/pageCountinside the existingThumbnailService(vs. a separate metadata-extraction service). One paragraph. Context, decision, alternatives, consequences.Open Decisions
👨💻 Felix Brandt — Senior Fullstack Developer
Observations
ConversationTimeline.svelteis currently 202 lines doing three jobs: the bilateral distribution bar (lines 85–115), the year divider (lines 120–128), and the row markup (lines 130–173). Each is a nameable visual region — classic split trigger. The file already uses{#each ... (doc.id)}correctly.DocumentThumbnail.sveltealready exists atsrc/lib/components/DocumentThumbnail.svelte. It is not a match for what the spec needs:h-[84px] w-[60px]forsm,h-[168px] w-[120px]forlg). The spec needs portrait (82×106) and landscape (104×72) centered in a 104×120 cell.object-cover object-top(crops). Spec implies the real aspect is shown — no cropping.thumbnailKey+thumbnailGeneratedAtthrough$lib/thumbnailsand derives the URL client-side. That's the right pattern; keep it.summaryonDocumentis aTEXTcolumn. Spec says omit the element when empty. TodayConversationTimelinerenders nothing for summary at all — new territory.canWriteis already passed intoConversationTimelineand used for the "new doc" link at the bottom. The row itself has no write actions, so the newThumbnailRowdoesn't need it.Recommendations
DistributionBar.sveltewith props{ outCount, inCount, outLabel, inLabel }and a snapshot/behavior test — assertrole="img", the composedaria-label, segment widths from counts (not client percentages — Markus' point). No rendering change toConversationTimelineyet; just introduce the component with a failing test that it renders the three elements (two labels + one bar). Green: move the existing JSX into the new component. Keep the oldConversationTimelineunchanged.ConversationTimelinethat it uses<DistributionBar>when bilateral. Green: swap the inline markup for the component.ThumbnailRow.sveltereceiving{ doc, senderId, receiverId }, asserting title, summary (when non-empty), meta line, date, relative age,aria-labelon the<a>. Green: write the row. No thumbnail yet — cell is a static skeleton div.doc.summaryis null/empty. Green:{#if doc.summary?.trim()}.ConversationTimeline.ConversationTimelineonce every test is green on the new components.DocumentThumbnail.svelte— write a newConversationThumbnail.svelte(or similar name). The existing component is used on/documentslist pages with fixed aspect crop. Adding variable-aspect, postcard kind chips, and multi-page badges to it risks regressions on the document list. Two components is cheaper than one doing two visual jobs. The currentDocumentThumbnail.sveltestays exactly as is.thumbnailUrlas a data field — derive it in$lib/thumbnailsthe same wayDocumentThumbnailalready does. The spec's "Data contract" table is wrong on this point.$derived(doc.summary?.trim() || null)then{#if computedSummary}. This handles bothnulland empty-after-trim.YearDivider.svelte, the{#each}key must still be(doc.id)— the divider is rendered conditionally inside the loop, not as a sibling array. The spec's "YearDivider.svelte wrapper" is unnecessary — the current inline<div>at lines 120–128 is 8 lines, nameable, and couples tightly toenrichedDocuments. Leaving it inline is the cleaner call; don't extract for the sake of the spec.$lib/relativeTime.tsalready exists in the codebase. Use it — don't compute inline.Open Decisions
🔒 Nora Steiner — Application Security Engineer
Observations
GET /api/documents/{id}/thumbnailalready sits behind the project's@RequirePermission/ session auth — no new endpoint is introduced by this issue's Phase 2.summaryandlocationare user-controlled free text onDocument. Rendered via standard Svelte interpolation ({doc.summary},{doc.location}), Svelte HTML-escapes by default — safe.thumbnailUrlas proposed in the spec does not exist as a backend field and should remain derived client-side (same-origin API path with an internal cache buster). No external URL ever lands in an<img src>.role="img"+ aria-label — the aria-label is composed from counts (integers) and names (already-escaped text). No XSS risk.@GetMapping("/conversation")onDocumentController— inspected: returnsList<Document>viaDocumentService#getConversationFiltered. The query is parameterized. No SSRF, no injection surface added by this redesign.Recommendations
summaryrendering path as part ofThumbnailRow.sveltetests:Cache-Controlon/api/documents/{id}/thumbnailmatches the spec's 30-day immutable claim. Looking atDocumentController.java:98–112, this is already wired with?v=<thumbnailGeneratedAt>as a cache-buster — good. If the spec's 30-day value isn't currently set, add it; otherwise the rolling cache across users is a CWE-525 concern when a thumbnail is replaced.summaryororiginalFilenamein the new row-rendering path or in the thumbnail service's pageCount/aspect extraction. Filenames can contain user-supplied data. Use SLF4J parameterized form if any logging is added:log.debug("Rendered row for doc={}", doc.getId())— IDs only.thumbnailAspectpersistence, validate the PDFBox / ImageIO output width/height are positive integers before dividing. A corrupt image withheight=0would cause a/0that bubbles up — guard with a sanity check inThumbnailService#persistThumbnailMetadata:Open Decisions
🧪 Sara Holt — QA Engineer & Test Strategist
Observations
frontend/src/routes/briefwechsel/page.svelte.spec.tsfrontend/src/routes/briefwechsel/page.server.spec.ts/briefwechsel(checkfrontend/e2e/— the bilateral flow is user-critical).statusDotClass()helper (lines 61–70 ofConversationTimeline.svelte) goes away. Any existing test that assertsbg-brand-mint,bg-brand-navyetc. on rows needs updating, not just deleting.Recommendations
DistributionBar.svelte.spec.ts(new)outCount / total * 100on the client); empty state (0 / 0) doesn't divide by zeroThumbnailRow.svelte.spec.ts(new)originalFilename; summary omitted when null/empty/whitespace; meta line tags cap at 2 (desktop) / 1 (tablet) / 0 (mobile) — test the visible-tag slicing logic;aria-labelon the<a>carries date + title; border-l color matchesisOutYearDivider.svelte.spec.ts(only if extracted)Briefecountpage.svelte.spec.ts(existing)<DistributionBar>present when bilateral, absent otherwise; no longer asserts status dot classpage.server.spec.ts(existing)frontend/e2e/briefwechsel-rows.visual.spec.ts(new)frontend/e2e/briefwechsel-a11y.spec.ts(new or extended)role="img"on bar, aria-label on rows, 44-px touch target on rowsthumbnailAspect+pageCount):ThumbnailServiceTestextensionpersistThumbnailMetadataMigrationTest(if not already present)thumbnailAspectandpage_countcolumns exist with correct types; no data lossDocumentControllerTest#getConversationthumbnailAspectandpageCountfieldsThumbnailRow(update)pageCount > 1; not rendered whenpageCount ∈ {null, 1}; kind chip rendered whenthumbnailAspect === 'LANDSCAPE'; landscape and portrait thumbnails use different CSS classes/sizes/api/documents/conversationto return a fixed list in the E2E visual test so snapshots are reproducible across runs and machines.conv-new-doc-linkflow (tested viadata-testid="conv-new-doc-link"— line 180). The newThumbnailRowdoesn't host it, butConversationTimelinemust still render the canWrite footer link at the bottom of the list.@Disabled,.skip, orit.onlythat sneaks in during the loop. Cycle-review catches them.Open Decisions
maxDiffPixelsthreshold of ~100 per snapshot (common in this project) or make snapshots strict and accept occasional flake-fixes. Options & cost:🛠️ Tobias Wendt — DevOps & Platform Engineer
Observations
archive-documentsMinIO bucket under thethumbnails/prefix (seeThumbnailService.THUMBNAIL_KEY_PREFIX). No new bucket, no new service, no new docker-compose additions.thumbnails— it isn't; they live as a prefix inside the existing archive bucket. This is actually fine for the current volume; don't split buckets without a reason.ThumbnailBackfillServiceis already wired, already has an admin endpoint (POST /api/admin/generate-thumbnails), already uses a dedicatedthumbnailExecutorpool. When Phase 2 addsthumbnailAspect+pageCount, the backfill rerun gives us the data on existing documents — no migration-time compute needed.Cache-Control: public, max-age=2592000(30 days) on the thumbnail redirect. Verify this is actually set on the 302 response fromDocumentController#getThumbnail— if it's only on the presigned MinIO URL, browsers won't cache the redirect itself.Recommendations
thumbnailAspectandpageCountacross two migrations. Both come from the same place (ThumbnailService#generate); ship them together:ThumbnailServicepopulating them on new uploads.POST /api/admin/generate-thumbnailsto backfill existing rows. Thumbnails regenerate idempotently (same S3 key, PutObject overwrite).PORTRAIT+ no page badge — handled by the spec's fallback contract.pdfbox→TwelveMonkeysor similar); don't smuggle it into a UI redesign.frontend/e2e/should already usepage.waitForLoadState('networkidle')or an equivalent font-ready assertion beforetoHaveScreenshot. Flag for Sara to verify during Phase 1.ThumbnailBackfillServicealready logs counts. If you want, add a Grafana panel after this ships: "documents with thumbnail_aspect NULL" as a backfill-completeness gauge — but that's a nice-to-have, not a gate.Open Decisions
🎨 Leonie Voss — UI/UX & Accessibility
Observations
<a>focus-visible ring,role="img"on distribution bar,prefers-reduced-motionon hover lift, redundant cues (arrow colour + glyph for direction). Contrast ratios verified for both modes in the spec's Section 05.text-[10px]). The project's own style guide (CLAUDE.md: "Font sizes below 12 px" "DON'T") and my own floor say 12 px minimum for any visible text. Uppercase + 700 weight doesn't exempt it — small caps are harder for the senior audience, not easier.text-ink-3onbg-surface. In dark mode, ink-3 is a muted gray — needs a concrete contrast measurement. The spec lists light-mode ratios but I want to see the dark-mode number for this specific token combination before signing off.border-l-primary(out) vsborder-l-accent(in): redundant with the direction arrow + label in the meta line, so color-alone is not a WCAG violation. But the 3-px left border needs contrast ≥ 3:1 against the row background for non-text UI components (WCAG 1.4.11).brand-mintonbg-surfaceis close to that line; the spec claims AAA but 1.4.11 at 3 px accent is often where well-intentioned specs fail.Recommendations
text-[11px]minimum, prefertext-xs(12 px). Tailwind'stext-[10px]is explicitly called out as a "don't" in the project's CLAUDE.md. Uppercase + bold doesn't earn you a smaller size — it earns you a same-size label that reads as structural.line-clamp-2with a fade or ellipsis. 3-sentence summaries break the 128-px row cadence and destroy the "quote" visual rhythm:→ Berlin · Verlag(direction glyph, location, one tag max). The spec's current 320-px frame shows direction arrow + name + short date on one line — tight but OK. Just verifyoverflow-hidden+truncateon the counterpart name so a long name doesn't push the date off-screen.text-ink-3contrast in dark mode specifically. The spec's dark-mode verification table should explicitly listtext-ink-3onbg-surface(regular) and onbg-muted(year divider background). If any combination is <4.5:1 for normal text, promote it totext-ink-2for the "vor N Jahren" line.focus-within:ring on the row, not justfocus-visible:. The row is a single<a>, sofocus-visible:on the<a>is sufficient today — but if a tag chip becomes interactive later (click to filter), you'll want the outer row ring to activate on any child focus. Write this in now; it's free:prefers-reduced-motion: the spec specifies this for hover lift but the 1.4-second shimmer on the skeleton also needs the same guard. Add:motion-safe:animate-pulse.alt, which the current setup already does), and is a "nice flourish" that can ship after the core rows work. Split it into its own issue; don't let it block the main redesign.Open Decisions
🗳️ Decision Queue — Action Required
2 decisions need your input before implementation starts.
UX / Content
Testing
maxDiffPixels: 100. Strict catches every pixel regression but forces a re-baseline of 9 snapshots on any font tweak or Tailwind bump. Threshold 100 is stable but can hide ~10 px label shifts (mitigated by axe-playwright catching WCAG cases). Sara recommends 100 px threshold. (Raised by: Sara)Cross-cutting observations (not decisions — noted for implementation)
Multiple personas independently flagged the same items; recording them here so they don't get lost between review and plan:
ThumbnailService,ThumbnailBackfillService,/api/documents/{id}/thumbnail,thumbnailKey/thumbnailGeneratedAt) is already shipped. Phase 2 extends it, not waits for it. (Markus, Felix, Tobias)thumbnailUrlfrom the backend data contract. Derive it client-side via$lib/thumbnailsasDocumentThumbnail.sveltealready does. OnlythumbnailAspectandpageCountare genuinely new backend fields. (Markus, Felix)DocumentThumbnail.svelteis fixed-aspect and portrait-only. Write a new component for the conversation row — don't extend the old one and risk regressions on/documentslist pages. (Felix)text-[10px]violate the project's own 12 px minimum. Bump totext-xs. (Leonie)Summary clamp: 2 lines it is
Visuall regression tolerance: maxDiffPixels: 100
🎯 Discussion Resolutions
Walked through every point from the six persona reviews with the user. These resolutions are the authoritative design for implementation.
Theme 1 — Scope
main. Phase 1 (UI refactor) and Phase 2 (backend fields) ship together.Theme 2 — Data model & API
thumbnailUrlfrom the backend contract. The client derives it via$lib/thumbnailsfromthumbnailKey+thumbnailGeneratedAt(already howDocumentThumbnail.svelteworks).Document:thumbnailAspect— enumPORTRAIT | LANDSCAPE, thresholdw/h > 1.1 → LANDSCAPEpageCount— integerThumbnailService#generate:pageCountfrom the already-loadedPDDocument#getNumberOfPages()(1 for image uploads);thumbnailAspectfrom the BufferedImage dimensions. Persisted alongsidethumbnailKeyinpersistThumbnailMetadata.Outcome.FAILED./api/documents/conversationstaysList<Document>. Adding two fields doesn't justify introducing a DTO.Theme 3 — Component split (Phase 1)
DistributionBar.sveltetosrc/lib/components/(shared with the person dashboard issue).ThumbnailRow.sveltefor the row markup.ConversationThumbnail.svelte. Do not touch the existingDocumentThumbnail.svelte— it's fixed-aspect portrait, used on/documents, has different requirements. Two components is cheaper than one doing two jobs.YearDividerstays inline (reject the spec's "new wrapper" — the 8-line block is clean as-is).$derived(doc.summary?.trim() || null)then{#if computedSummary}.$lib/relativeTime.tsfor "vor N Jahren".Theme 4 — UI details & a11y
line-clamp-2.text-[10px]totext-xs(12 px). The project's own floor is 12 px; uppercase + bold doesn't earn a smaller size.text-ink-3onbg-surfaceandbg-muted. If either is below 4.5:1, promote "vor N Jahren" totext-ink-2.truncateon the counterpart name so long names don't push the date off-screen.focus-within:ring-*alongsidefocus-visible:ring-*on the row — free future-proofing for when child elements become interactive.prefers-reduced-motion(motion-safe:animate-pulseor equivalent). The spec covered this for hover lift only.Theme 5 — Testing
maxDiffPixels: 100.DistributionBar.svelte.spec.ts, newThumbnailRow.svelte.spec.ts, updatedpage.svelte.spec.tsbriefwechsel-rows.visual.spec.ts— 320/768/1440 × light + dark = 6 snapshotsThumbnailServiceTestextended — PORTRAIT/LANDSCAPE threshold, pageCount for N-page PDF and image, persistenceMigrationTestapplies cleanly, new columns with correct types;DocumentControllerTest#getConversationresponse-shape check/api/documents/conversationin visual E2E with a fixed ~6-doc fixture.conv-new-doc-link— the canWrite footer link must still render fromConversationTimeline.statusDotClassassertions — they become border-l colour assertions matchingisOut.Theme 6 — Security hardening
ThumbnailRow.svelte.spec.ts.Cache-Control: public, max-age=2592000on the 302 response fromDocumentController#getThumbnail. If it's missing or lives only on the presigned URL, add it to the redirect itself.summaryororiginalFilenamein new code paths. SLF4J parameterized form, IDs only.Theme 7 — Deployment, backfill, ADR
POST /api/admin/generate-thumbnailsto backfillthumbnailAspect+pageCounton existing rows → deploy frontend. Until backfill finishes, frontend falls back to PORTRAIT + no page badge.docs/adr/: one paragraph documenting the decision to compute aspect/pageCount inside the existingThumbnailServicevs. introducing a separate metadata-extraction service.Open / Skipped
The
implementskill will read this comment alongside the original issue.