As a user I want the dashboard resume strip to show the actual document thumbnail so I recognize what I was working on at a glance #309

Closed
opened 2026-04-23 07:50:10 +02:00 by marcel · 8 comments
Owner

Context

The "resume" strip on the dashboard (the big card pointing the user back to the document they last touched) currently renders a generic parchment-style placeholder SVG instead of the real document thumbnail.

  • Component: frontend/src/lib/components/DashboardResumeStrip.svelte:46-67 — inline <svg> with a gradient <rect> and a few horizontal lines.
  • The rest of the app (document list rows, person document list) already uses the real thumbnail via frontend/src/lib/components/DocumentThumbnail.svelte, served by /api/documents/{id}/thumbnail?v=….

Wire is half-stubbed already

  • DashboardResumeDTO.java:17 already declares @Nullable String thumbnailUrl, and the generated TS type frontend/src/lib/generated/api.ts:1954 carries thumbnailUrl?: string.
  • But DashboardService.getResume() (backend/src/main/java/org/raddatz/familienarchiv/dashboard/DashboardService.java:84-85) passes null for that field, and DashboardResumeStrip.svelte never reads it.

Acceptance criteria

  • DashboardService.getResume() populates thumbnailUrl from the Document (use the same shape as frontend/src/lib/thumbnails.ts: /api/documents/{id}/thumbnail?v={thumbnailGeneratedAt} when thumbnailKey is present, otherwise null).
  • DashboardResumeStrip.svelte renders the real thumbnail when resumeDoc.thumbnailUrl is present, with a tasteful fallback (the existing parchment SVG, or the document-icon fallback DocumentThumbnail already uses) when it isn't.
  • Image sizing keeps the current visual footprint (~180×246 in the strip) — object-cover object-top so letter salutations stay visible, like DocumentThumbnail.
  • Dark-mode handling matches DocumentThumbnail (mix-blend-multiply so paper scans don't glare).
  • loading="lazy" + decoding="async" on the <img>.
  • Backend test covers the new field being populated when the document has a thumbnail and being null when it doesn't.
  • Frontend component test covers both branches (thumbnail present → <img>; absent → fallback).

Out of scope

  • Generalizing DocumentThumbnail to support arbitrary sizes — the resume strip is the only larger consumer for now, so an inline <img> driven by resumeDoc.thumbnailUrl is fine. If a second large-thumbnail surface appears, extract then.
## Context The "resume" strip on the dashboard (the big card pointing the user back to the document they last touched) currently renders a generic parchment-style placeholder SVG instead of the real document thumbnail. - Component: `frontend/src/lib/components/DashboardResumeStrip.svelte:46-67` — inline `<svg>` with a gradient `<rect>` and a few horizontal lines. - The rest of the app (document list rows, person document list) already uses the real thumbnail via `frontend/src/lib/components/DocumentThumbnail.svelte`, served by `/api/documents/{id}/thumbnail?v=…`. ## Wire is half-stubbed already - `DashboardResumeDTO.java:17` already declares `@Nullable String thumbnailUrl`, and the generated TS type `frontend/src/lib/generated/api.ts:1954` carries `thumbnailUrl?: string`. - But `DashboardService.getResume()` (`backend/src/main/java/org/raddatz/familienarchiv/dashboard/DashboardService.java:84-85`) passes `null` for that field, and `DashboardResumeStrip.svelte` never reads it. ## Acceptance criteria - [ ] `DashboardService.getResume()` populates `thumbnailUrl` from the `Document` (use the same shape as `frontend/src/lib/thumbnails.ts`: `/api/documents/{id}/thumbnail?v={thumbnailGeneratedAt}` when `thumbnailKey` is present, otherwise `null`). - [ ] `DashboardResumeStrip.svelte` renders the real thumbnail when `resumeDoc.thumbnailUrl` is present, with a tasteful fallback (the existing parchment SVG, or the document-icon fallback `DocumentThumbnail` already uses) when it isn't. - [ ] Image sizing keeps the current visual footprint (~180×246 in the strip) — `object-cover object-top` so letter salutations stay visible, like `DocumentThumbnail`. - [ ] Dark-mode handling matches `DocumentThumbnail` (`mix-blend-multiply` so paper scans don't glare). - [ ] `loading="lazy"` + `decoding="async"` on the `<img>`. - [ ] Backend test covers the new field being populated when the document has a thumbnail and being `null` when it doesn't. - [ ] Frontend component test covers both branches (thumbnail present → `<img>`; absent → fallback). ## Out of scope - Generalizing `DocumentThumbnail` to support arbitrary sizes — the resume strip is the only larger consumer for now, so an inline `<img>` driven by `resumeDoc.thumbnailUrl` is fine. If a second large-thumbnail surface appears, extract then.
marcel added the featureui labels 2026-04-23 07:50:14 +02:00
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Observations

  • The DTO already exposes thumbnailUrl as a fully-formed URL string, but elsewhere in the codebase we keep thumbnailKey + thumbnailGeneratedAt separate and compose the URL client-side via frontend/src/lib/thumbnails.ts. Populating the DTO now means the URL shape lives in two places that must stay in sync (see DashboardService.java:84-85 vs thumbnails.ts:15-17).
  • DashboardResumeStrip.svelte:46-67 currently uses a unique parchment SVG fallback. DocumentThumbnail.svelte:44-55 uses a document-text icon. Two different "no thumbnail" vocabularies on the same app.
  • TDD path is clean: two backend tests (with/without thumbnailKey, with/without thumbnailGeneratedAt) and two frontend tests (img present / img absent) cover everything.

Recommendations

  • TDD order, red/green for every step. Backend first:
    1. getResume_populates_thumbnailUrl_with_cacheBuster_when_generatedAt_present
    2. getResume_populates_thumbnailUrl_without_cacheBuster_when_generatedAt_null
    3. getResume_thumbnailUrl_isNull_when_thumbnailKey_null
      Then frontend:
    4. renders <img> with correct src, alt="", loading="lazy", decoding="async" when thumbnailUrl is set
    5. renders document-icon fallback when thumbnailUrl is null
  • Fallback should be the DocumentThumbnail icon, not the parchment SVG. Keep the visual vocabulary consistent — if users see the document-text icon on every other "no thumbnail" surface, seeing something else here is friction, not flavor. The parchment is only needed in the fully-empty state (resume-strip-empty, DashboardResumeStrip.svelte:16-44), which isn't changing.
  • alt="" on the <img>, matching DocumentThumbnail.svelte:34. The title and caption already carry the semantics; the image is decorative.
  • Extract the URL-building helper on the backend if we keep the pre-composed URL. Something like DocumentUrls.thumbnail(Document) next to the model, so the next DTO that needs it doesn't rewrite the same concat.
  • Guard clause for the happy path. In getResume, one line: String thumbnailUrl = doc.getThumbnailKey() != null ? buildThumbnailUrl(doc) : null; — clearer than an inline ternary in the constructor call.

Open Decisions

  • Pre-composed URL vs. raw key+timestamp on the DTO. The issue chose URL. Keeping URL: lower friction (generated types already have the field, Svelte binding is one property). Switching to key+timestamp and calling thumbnailUrl(resumeDoc) in the component: DRY with the rest of the app, but changes the generated types and diverges from the issue's ACs. I lean keep the URL as specified and add a small helper on the backend to centralize URL shape — but the cleaner long-term structure is key+timestamp.
## 👨‍💻 Felix Brandt — Senior Fullstack Developer ### Observations - The DTO already exposes `thumbnailUrl` as a fully-formed URL string, but elsewhere in the codebase we keep `thumbnailKey` + `thumbnailGeneratedAt` separate and compose the URL client-side via `frontend/src/lib/thumbnails.ts`. Populating the DTO now means the URL shape lives in **two** places that must stay in sync (see `DashboardService.java:84-85` vs `thumbnails.ts:15-17`). - `DashboardResumeStrip.svelte:46-67` currently uses a unique parchment SVG fallback. `DocumentThumbnail.svelte:44-55` uses a document-text icon. Two different "no thumbnail" vocabularies on the same app. - TDD path is clean: two backend tests (with/without `thumbnailKey`, with/without `thumbnailGeneratedAt`) and two frontend tests (img present / img absent) cover everything. ### Recommendations - **TDD order, red/green for every step.** Backend first: 1. `getResume_populates_thumbnailUrl_with_cacheBuster_when_generatedAt_present` 2. `getResume_populates_thumbnailUrl_without_cacheBuster_when_generatedAt_null` 3. `getResume_thumbnailUrl_isNull_when_thumbnailKey_null` Then frontend: 4. `renders <img> with correct src, alt="", loading="lazy", decoding="async" when thumbnailUrl is set` 5. `renders document-icon fallback when thumbnailUrl is null` - **Fallback should be the `DocumentThumbnail` icon, not the parchment SVG.** Keep the visual vocabulary consistent — if users see the document-text icon on every other "no thumbnail" surface, seeing something else here is friction, not flavor. The parchment is only needed in the fully-empty state (`resume-strip-empty`, `DashboardResumeStrip.svelte:16-44`), which isn't changing. - **`alt=""` on the `<img>`**, matching `DocumentThumbnail.svelte:34`. The title and caption already carry the semantics; the image is decorative. - **Extract the URL-building helper on the backend** if we keep the pre-composed URL. Something like `DocumentUrls.thumbnail(Document)` next to the model, so the next DTO that needs it doesn't rewrite the same concat. - **Guard clause for the happy path.** In `getResume`, one line: `String thumbnailUrl = doc.getThumbnailKey() != null ? buildThumbnailUrl(doc) : null;` — clearer than an inline ternary in the constructor call. ### Open Decisions - **Pre-composed URL vs. raw key+timestamp on the DTO.** The issue chose URL. Keeping URL: lower friction (generated types already have the field, Svelte binding is one property). Switching to key+timestamp and calling `thumbnailUrl(resumeDoc)` in the component: DRY with the rest of the app, but changes the generated types and diverges from the issue's ACs. I lean **keep the URL as specified** and add a small helper on the backend to centralize URL shape — but the cleaner long-term structure is key+timestamp.
Author
Owner

🏛️ Markus Keller — Senior Application Architect

Observations

  • Narrow, localized change confined to the dashboard feature package — no boundary crossings, no new repository dependencies, no new infrastructure. This is exactly the shape a well-scoped UI polish issue should have.
  • DashboardService already goes through DocumentService.getDocumentById(docId) for Document data (DashboardService.java:46). The new field populates from the already-loaded Document — zero extra queries, no N+1 risk.
  • The DTO already declares @Nullable String thumbnailUrl with no @Schema(requiredMode = REQUIRED) (DashboardResumeDTO.java:17). That matches the data reality (thumbnails are async-generated after upload) and the existing TS codegen is already optional. Good.
  • Minor concern: URL construction for thumbnails will now live in two places — thumbnails.ts on the frontend and DashboardService on the backend. Two places to keep in sync for one path convention.

Recommendations

  • Keep the scope as the issue defined it. Do not generalize DocumentThumbnail to a third size — the "extract when a second large-thumbnail surface appears" rule in the issue's Out-of-Scope is correct. Two callsites don't justify the abstraction.
  • Centralize the thumbnail-URL convention on the backend. A tiny static helper DocumentUrls.thumbnail(Document doc) or an instance method on Document (getThumbnailPublicUrl()) keeps the URL shape in one spot. The next DTO that needs it (activity feed rows? search results?) reuses the helper instead of hand-concat.
  • No ADR needed. This is one field on one DTO. ADRs are for structural decisions, not plumbing.
  • No new feature package. Stays inside dashboard/.

Open Decisions

  • (none — the architectural shape is correct as specified)
## 🏛️ Markus Keller — Senior Application Architect ### Observations - Narrow, localized change confined to the `dashboard` feature package — no boundary crossings, no new repository dependencies, no new infrastructure. This is exactly the shape a well-scoped UI polish issue should have. - `DashboardService` already goes through `DocumentService.getDocumentById(docId)` for `Document` data (`DashboardService.java:46`). The new field populates from the already-loaded `Document` — zero extra queries, no N+1 risk. - The DTO already declares `@Nullable String thumbnailUrl` with no `@Schema(requiredMode = REQUIRED)` (`DashboardResumeDTO.java:17`). That matches the data reality (thumbnails are async-generated after upload) and the existing TS codegen is already optional. Good. - Minor concern: URL construction for thumbnails will now live in **two** places — `thumbnails.ts` on the frontend and `DashboardService` on the backend. Two places to keep in sync for one path convention. ### Recommendations - **Keep the scope as the issue defined it.** Do not generalize `DocumentThumbnail` to a third size — the "extract when a second large-thumbnail surface appears" rule in the issue's Out-of-Scope is correct. Two callsites don't justify the abstraction. - **Centralize the thumbnail-URL convention on the backend.** A tiny static helper `DocumentUrls.thumbnail(Document doc)` or an instance method on `Document` (`getThumbnailPublicUrl()`) keeps the URL shape in one spot. The next DTO that needs it (activity feed rows? search results?) reuses the helper instead of hand-concat. - **No ADR needed.** This is one field on one DTO. ADRs are for structural decisions, not plumbing. - **No new feature package.** Stays inside `dashboard/`. ### Open Decisions - _(none — the architectural shape is correct as specified)_
Author
Owner

🔒 Nora Steiner — Application Security Engineer

Observations

  • The thumbnail endpoint (DocumentController.java:98-120) serves through the standard application auth chain. Embedding a relative URL like /api/documents/{id}/thumbnail?v=… on the resume DTO doesn't bypass any permission — the browser will carry the user's cookie on the follow-up GET, and the endpoint goes through documentService.getDocumentById(id) which enforces visibility at the service layer.
  • Cache-Control: private, max-age=31536000, immutable on the thumbnail response (DocumentController.java:114) is correct: private blocks shared caches from cross-user leaks (CWE-525), and immutable is safe only because of the ?v={thumbnailGeneratedAt} cache-buster. If the cache-buster were ever dropped from the DTO URL, immutable would become dangerous — worth a code comment.
  • The resume endpoint itself is per-user. The documentId is already in the response (DashboardResumeDTO.java:11), so the thumbnail URL does not leak any new identifier to the rendered HTML.
  • No SSRF vector: the URL is composed from known database columns (thumbnailKey set by the backend's own thumbnail worker), not from any user-controlled string. No ZIP slip equivalent. No path traversal — the id is a UUID and the path is fully server-generated.

Recommendations

  • Include the cache-buster. If thumbnailGeneratedAt is null but thumbnailKey is set, omitting ?v= is acceptable — but only because immutable still has the document ID in the URL. When the thumbnail is regenerated, the thumbnailGeneratedAt flips, so the URL should always change once that field is populated. A test proving "URL changes when thumbnailGeneratedAt changes" locks this invariant.
  • Match the backend URL exactly with thumbnails.ts. encodeURIComponent is used frontend-side — backend-side URLEncoder.encode(timestamp, UTF_8) (or equivalent) ensures identity across the two code paths. For an ISO-8601 LocalDateTime this is mostly a no-op (: stays %3A in either case), but the test should assert on the exact serialized form to catch the divergence.
  • Regression test for the permission boundary. Add an MVC test: user A fetches /api/dashboard/resume — the returned thumbnailUrl points to a document that user A can read. There's no "cross-user thumbnail URL in response" path, but codifying it with a test prevents future regressions.

Open Decisions

  • (none — no novel security surface in this change)
## 🔒 Nora Steiner — Application Security Engineer ### Observations - The thumbnail endpoint (`DocumentController.java:98-120`) serves through the standard application auth chain. Embedding a relative URL like `/api/documents/{id}/thumbnail?v=…` on the resume DTO doesn't bypass any permission — the browser will carry the user's cookie on the follow-up GET, and the endpoint goes through `documentService.getDocumentById(id)` which enforces visibility at the service layer. - `Cache-Control: private, max-age=31536000, immutable` on the thumbnail response (`DocumentController.java:114`) is correct: `private` blocks shared caches from cross-user leaks (CWE-525), and `immutable` is safe only because of the `?v={thumbnailGeneratedAt}` cache-buster. If the cache-buster were ever dropped from the DTO URL, `immutable` would become dangerous — worth a code comment. - The `resume` endpoint itself is per-user. The `documentId` is **already** in the response (`DashboardResumeDTO.java:11`), so the thumbnail URL does not leak any new identifier to the rendered HTML. - No SSRF vector: the URL is composed from known database columns (`thumbnailKey` set by the backend's own thumbnail worker), not from any user-controlled string. No ZIP slip equivalent. No path traversal — the `id` is a UUID and the path is fully server-generated. ### Recommendations - **Include the cache-buster.** If `thumbnailGeneratedAt` is null but `thumbnailKey` is set, omitting `?v=` is acceptable — but only because `immutable` still has the document ID in the URL. When the thumbnail is regenerated, the `thumbnailGeneratedAt` flips, so the URL should always change once that field is populated. A test proving "URL changes when `thumbnailGeneratedAt` changes" locks this invariant. - **Match the backend URL exactly with `thumbnails.ts`.** `encodeURIComponent` is used frontend-side — backend-side `URLEncoder.encode(timestamp, UTF_8)` (or equivalent) ensures identity across the two code paths. For an ISO-8601 `LocalDateTime` this is mostly a no-op (`:` stays `%3A` in either case), but the test should assert on the exact serialized form to catch the divergence. - **Regression test for the permission boundary.** Add an MVC test: user A fetches `/api/dashboard/resume` — the returned `thumbnailUrl` points to a document that user A can read. There's no "cross-user thumbnail URL in response" path, but codifying it with a test prevents future regressions. ### Open Decisions - _(none — no novel security surface in this change)_
Author
Owner

🧪 Sara Holt — QA Engineer

Observations

  • Good news: both test files already exist (DashboardServiceTest.java:50-81 for the backend, DashboardResumeStrip.svelte.spec.ts:1-55 for the frontend). No new test infrastructure needed — add cases to what's there.
  • The existing frontend spec mock uses a DashboardResumeDTO without thumbnailUrl (DashboardResumeStrip.svelte.spec.ts:14-22) — that's actually exactly the "no thumbnail" fallback case, so one of the new tests is essentially free.
  • No integration test touches this path end-to-end right now. Not a gap worth filling — a unit test on the DTO plus a component test on the render covers the behavior without the Testcontainers overhead.

Recommendations

  • Backend coverage (add to DashboardServiceTest.java):

    1. getResume_thumbnailUrl_includesCacheBuster_whenGeneratedAtPresent — document has thumbnailKey AND thumbnailGeneratedAt → URL is /api/documents/{id}/thumbnail?v={iso-timestamp}.
    2. getResume_thumbnailUrl_omitsCacheBuster_whenGeneratedAtNull — document has thumbnailKey but thumbnailGeneratedAt is null → URL is /api/documents/{id}/thumbnail (no query).
    3. getResume_thumbnailUrl_isNull_whenThumbnailKeyNull — document has no thumbnailKey → URL is null.
    4. One explicit assertion that the URL starts with /api/documents/ and contains the correct document id — locks the format.
  • Frontend coverage (add to DashboardResumeStrip.svelte.spec.ts):

    1. renders <img> with expected src, alt, loading=lazy, decoding=async when thumbnailUrl is setgetByRole('img') or page.locator('img[src*="/api/documents/"]').
    2. renders fallback icon when thumbnailUrl is null — assert the absence of img[src*="/thumbnail"] AND presence of the fallback (testid please, not structural selectors).
    3. Extend existing tests' mock to include thumbnailUrl on paths that need it so assertions are explicit about state.
  • Add data-testid attributes on the thumbnail <img> and the fallback wrapper. Chasing getByRole('img') on a page that might grow more images is fragile; explicit testids survive refactors.

  • Cover the existing empty-state test doesn't accidentally break. The resumeDoc === null branch (DashboardResumeStrip.svelte:16-44) is untouched by this change — add one regression assertion that it still renders, no img, no fallback icon, just the empty card.

Open Decisions

  • (none — the test strategy is straightforward and fits the existing pyramid)
## 🧪 Sara Holt — QA Engineer ### Observations - Good news: both test files already exist (`DashboardServiceTest.java:50-81` for the backend, `DashboardResumeStrip.svelte.spec.ts:1-55` for the frontend). No new test infrastructure needed — add cases to what's there. - The existing frontend spec mock uses a `DashboardResumeDTO` without `thumbnailUrl` (`DashboardResumeStrip.svelte.spec.ts:14-22`) — that's actually exactly the "no thumbnail" fallback case, so one of the new tests is essentially free. - No integration test touches this path end-to-end right now. Not a gap worth filling — a unit test on the DTO plus a component test on the render covers the behavior without the Testcontainers overhead. ### Recommendations - **Backend coverage (add to `DashboardServiceTest.java`):** 1. `getResume_thumbnailUrl_includesCacheBuster_whenGeneratedAtPresent` — document has `thumbnailKey` AND `thumbnailGeneratedAt` → URL is `/api/documents/{id}/thumbnail?v={iso-timestamp}`. 2. `getResume_thumbnailUrl_omitsCacheBuster_whenGeneratedAtNull` — document has `thumbnailKey` but `thumbnailGeneratedAt` is null → URL is `/api/documents/{id}/thumbnail` (no query). 3. `getResume_thumbnailUrl_isNull_whenThumbnailKeyNull` — document has no `thumbnailKey` → URL is null. 4. One explicit assertion that the URL starts with `/api/documents/` and contains the correct document id — locks the format. - **Frontend coverage (add to `DashboardResumeStrip.svelte.spec.ts`):** 1. `renders <img> with expected src, alt, loading=lazy, decoding=async when thumbnailUrl is set` — `getByRole('img')` or `page.locator('img[src*="/api/documents/"]')`. 2. `renders fallback icon when thumbnailUrl is null` — assert the absence of `img[src*="/thumbnail"]` AND presence of the fallback (testid please, not structural selectors). 3. Extend existing tests' mock to include `thumbnailUrl` on paths that need it so assertions are explicit about state. - **Add `data-testid` attributes** on the thumbnail `<img>` and the fallback wrapper. Chasing `getByRole('img')` on a page that might grow more images is fragile; explicit testids survive refactors. - **Cover the existing empty-state test doesn't accidentally break.** The `resumeDoc === null` branch (`DashboardResumeStrip.svelte:16-44`) is untouched by this change — add one regression assertion that it still renders, no img, no fallback icon, just the empty card. ### Open Decisions - _(none — the test strategy is straightforward and fits the existing pyramid)_
Author
Owner

⚙️ Tobias Wendt — DevOps & Platform Engineer

Observations

  • Zero infrastructure changes. No new service, no new port, no new env var, no new volume. The thumbnail endpoint (DocumentController.java:98-120) and the MinIO bucket backing it are already load-bearing — this change just adds one more callsite for them on the dashboard.
  • Traffic impact: one extra thumbnail request per dashboard load. With Cache-Control: private, max-age=31536000, immutable the repeat-visit cost is zero — the browser serves from cache. First-load cost is one MinIO GetObject proxied through the Spring Boot app, same as any other document list render.
  • The dashboard resume card is a once-per-page render, not a feed with N thumbnails, so there's no fan-out risk that would justify pre-signed URLs or a CDN tier.
  • immutable + ?v={thumbnailGeneratedAt} cache-buster is the correct pattern. Works through Caddy without extra config.

Recommendations

  • Ship as specified — no infra work needed. This is an application-only change.
  • Watch /api/documents/{id}/thumbnail p95 as dashboard usage grows. If the endpoint becomes a hot path (it isn't today), the pattern to reach for is pre-signed S3 URLs so MinIO serves the bytes directly instead of proxying through the JVM. Same trade-off we'd make for full-file downloads. Not needed now.
  • CI is unaffected. The existing DashboardServiceTest runs in the unit-test job (Mockito, no Spring context); the frontend component test runs in the Vitest browser-mode job. No new runner, no new container.
  • No Compose change. The minio service already handles thumbnail objects; the archive-documents bucket already exists. The MinIO MC helper bootstrap at the repo root doesn't need touching.

Open Decisions

  • (none — purely application-layer, no infra decisions to make)
## ⚙️ Tobias Wendt — DevOps & Platform Engineer ### Observations - Zero infrastructure changes. No new service, no new port, no new env var, no new volume. The thumbnail endpoint (`DocumentController.java:98-120`) and the MinIO bucket backing it are already load-bearing — this change just adds one more callsite for them on the dashboard. - Traffic impact: one extra thumbnail request per dashboard load. With `Cache-Control: private, max-age=31536000, immutable` the repeat-visit cost is zero — the browser serves from cache. First-load cost is one MinIO `GetObject` proxied through the Spring Boot app, same as any other document list render. - The dashboard resume card is a once-per-page render, not a feed with N thumbnails, so there's no fan-out risk that would justify pre-signed URLs or a CDN tier. - `immutable` + `?v={thumbnailGeneratedAt}` cache-buster is the correct pattern. Works through Caddy without extra config. ### Recommendations - **Ship as specified — no infra work needed.** This is an application-only change. - **Watch `/api/documents/{id}/thumbnail` p95 as dashboard usage grows.** If the endpoint becomes a hot path (it isn't today), the pattern to reach for is pre-signed S3 URLs so MinIO serves the bytes directly instead of proxying through the JVM. Same trade-off we'd make for full-file downloads. Not needed now. - **CI is unaffected.** The existing `DashboardServiceTest` runs in the unit-test job (Mockito, no Spring context); the frontend component test runs in the Vitest browser-mode job. No new runner, no new container. - **No Compose change.** The `minio` service already handles thumbnail objects; the `archive-documents` bucket already exists. The MinIO MC helper bootstrap at the repo root doesn't need touching. ### Open Decisions - _(none — purely application-layer, no infra decisions to make)_
Author
Owner

🎨 Leonie Voss — UX Designer & Accessibility Advocate

Observations

  • This is the single most prominent visual on the dashboard — the whole point of "resume strip" is recognition-at-a-glance. A generic parchment placeholder defeats the purpose; the real thumbnail is a meaningful upgrade.
  • DocumentThumbnail.svelte already encodes our thumbnail pattern: 5:7 aspect (A4 portrait), object-cover object-top so letter salutations stay visible, dark:mix-blend-multiply to prevent paper-scan glare, alt="" because the title carries semantics, and a document-text icon fallback (DocumentThumbnail.svelte:30-59). Every one of these is deliberate — reuse the decisions, don't re-relitigate them.
  • Current strip placeholder is 180×246 (ratio 1:1.367). DocumentThumbnail uses exactly 5:7 (1:1.4). Close, but not the same. Users will notice if the dashboard thumbnail looks slightly squatter than the list/person thumbnails.
  • Reserved space matters: if the <img> loads without intrinsic dimensions, the layout shifts on first paint. The current SVG has width="180" height="246" attributes — the replacement <img> needs equivalent, either as explicit attributes or via wrapping container CSS.

Recommendations

  • Match DocumentThumbnail's visual conventions exactly:
    <div class="relative h-[252px] w-[180px] flex-shrink-0 overflow-hidden rounded-sm border border-line bg-white">
        {#if resumeDoc.thumbnailUrl}
            <img
                src={resumeDoc.thumbnailUrl}
                alt=""
                class="h-full w-full object-cover object-top dark:mix-blend-multiply"
                loading="lazy"
                decoding="async"
            />
        {:else}
            <div class="flex h-full w-full items-center justify-center text-ink-3" aria-hidden="true">
                <!-- same heroicons document-text outline as DocumentThumbnail.svelte:44-55 -->
            </div>
        {/if}
    </div>
    
  • Fallback = document-text icon, not parchment SVG. Consistency beats decoration. Users learn one vocabulary: "icon = no thumbnail yet." Two vocabularies (parchment on dashboard, icon everywhere else) means users re-learn per surface.
  • Change 246 → 252 for the height to match the exact 5:7 A4 ratio DocumentThumbnail uses. Visual consistency across the app.
  • Keep alt="" (decorative). Title (resumeDoc.title) already labels the card; the <h2> sits two lines below, a screen reader that reads the image as "image of document" would duplicate and add noise.
  • Reserve space during load. Explicit container dimensions (h-[252px] w-[180px]) reserve the box before the img decodes, preventing CLS. The bg-white on the wrapper is the loading-state background — no spinner needed for a cacheable 250×180 JPEG.
  • Dark mode: mix-blend-multiply is mandatory for paper scans. Family letter thumbnails on a dark background glow like screens at midnight otherwise. Copy the pattern from DocumentThumbnail.svelte:35.
  • Keep aria-hidden="true" on the fallback icon — decorative-only, no additional landmark info.

Open Decisions

  • Dimensions: 180×246 (keep current footprint) vs. 180×252 (match DocumentThumbnail's exact 5:7 ratio). Cost of 246: minor visual inconsistency with the rest of the app's thumbnail surfaces. Cost of 252: the strip grows by 6px, minor layout tweak. I lean 252 — consistency compounds across a session. The issue says "~180×246" which leaves room.
## 🎨 Leonie Voss — UX Designer & Accessibility Advocate ### Observations - This is the single most prominent visual on the dashboard — the whole point of "resume strip" is recognition-at-a-glance. A generic parchment placeholder defeats the purpose; the real thumbnail is a meaningful upgrade. - `DocumentThumbnail.svelte` already encodes our thumbnail pattern: 5:7 aspect (A4 portrait), `object-cover object-top` so letter salutations stay visible, `dark:mix-blend-multiply` to prevent paper-scan glare, `alt=""` because the title carries semantics, and a document-text icon fallback (`DocumentThumbnail.svelte:30-59`). Every one of these is deliberate — reuse the decisions, don't re-relitigate them. - Current strip placeholder is 180×246 (ratio 1:1.367). `DocumentThumbnail` uses exactly 5:7 (1:1.4). Close, but not the same. Users will notice if the dashboard thumbnail looks slightly squatter than the list/person thumbnails. - Reserved space matters: if the `<img>` loads without intrinsic dimensions, the layout shifts on first paint. The current SVG has `width="180" height="246"` attributes — the replacement `<img>` needs equivalent, either as explicit attributes or via wrapping container CSS. ### Recommendations - **Match `DocumentThumbnail`'s visual conventions exactly:** ```svelte <div class="relative h-[252px] w-[180px] flex-shrink-0 overflow-hidden rounded-sm border border-line bg-white"> {#if resumeDoc.thumbnailUrl} <img src={resumeDoc.thumbnailUrl} alt="" class="h-full w-full object-cover object-top dark:mix-blend-multiply" loading="lazy" decoding="async" /> {:else} <div class="flex h-full w-full items-center justify-center text-ink-3" aria-hidden="true"> <!-- same heroicons document-text outline as DocumentThumbnail.svelte:44-55 --> </div> {/if} </div> ``` - **Fallback = document-text icon, not parchment SVG.** Consistency beats decoration. Users learn one vocabulary: "icon = no thumbnail yet." Two vocabularies (parchment on dashboard, icon everywhere else) means users re-learn per surface. - **Change 246 → 252 for the height** to match the exact 5:7 A4 ratio `DocumentThumbnail` uses. Visual consistency across the app. - **Keep `alt=""` (decorative).** Title (`resumeDoc.title`) already labels the card; the `<h2>` sits two lines below, a screen reader that reads the image as "image of document" would duplicate and add noise. - **Reserve space during load.** Explicit container dimensions (`h-[252px] w-[180px]`) reserve the box before the img decodes, preventing CLS. The `bg-white` on the wrapper is the loading-state background — no spinner needed for a cacheable 250×180 JPEG. - **Dark mode: `mix-blend-multiply` is mandatory for paper scans.** Family letter thumbnails on a dark background glow like screens at midnight otherwise. Copy the pattern from `DocumentThumbnail.svelte:35`. - **Keep `aria-hidden="true"` on the fallback icon** — decorative-only, no additional landmark info. ### Open Decisions - **Dimensions: 180×246 (keep current footprint) vs. 180×252 (match `DocumentThumbnail`'s exact 5:7 ratio).** Cost of 246: minor visual inconsistency with the rest of the app's thumbnail surfaces. Cost of 252: the strip grows by 6px, minor layout tweak. I lean 252 — consistency compounds across a session. The issue says "~180×246" which leaves room.
Author
Owner

🗳️ Decision Queue — Action Required

2 decisions need your input before implementation starts.

Architecture / API

  • Pre-composed thumbnailUrl string on the DTO vs. raw thumbnailKey + thumbnailGeneratedAt pair. The issue specifies the former. Keeping it: lower friction — the field is already declared on DashboardResumeDTO, the TS type is already generated, one Svelte binding. Switching to key+timestamp and calling the existing thumbnailUrl() helper in the component: DRY with the rest of the app (the URL convention stops being duplicated between DashboardService and frontend/src/lib/thumbnails.ts), but changes generated types and diverges from the issue's acceptance criteria. (Raised by: Felix, Markus — related concern)

UX / Layout

  • Thumbnail dimensions: 180×246 (current footprint) vs. 180×252 (exact 5:7 A4, matches DocumentThumbnail). The issue says "~180×246." Cost of 246: slight visual inconsistency against the rest of the app's thumbnails. Cost of 252: the strip grows 6px. Lean: 252 for convention consistency, 246 for "don't touch the existing footprint." (Raised by: Leonie)
## 🗳️ Decision Queue — Action Required _2 decisions need your input before implementation starts._ ### Architecture / API - **Pre-composed `thumbnailUrl` string on the DTO vs. raw `thumbnailKey` + `thumbnailGeneratedAt` pair.** The issue specifies the former. Keeping it: lower friction — the field is already declared on `DashboardResumeDTO`, the TS type is already generated, one Svelte binding. Switching to key+timestamp and calling the existing `thumbnailUrl()` helper in the component: DRY with the rest of the app (the URL convention stops being duplicated between `DashboardService` and `frontend/src/lib/thumbnails.ts`), but changes generated types and diverges from the issue's acceptance criteria. _(Raised by: Felix, Markus — related concern)_ ### UX / Layout - **Thumbnail dimensions: 180×246 (current footprint) vs. 180×252 (exact 5:7 A4, matches `DocumentThumbnail`).** The issue says "~180×246." Cost of 246: slight visual inconsistency against the rest of the app's thumbnails. Cost of 252: the strip grows 6px. Lean: 252 for convention consistency, 246 for "don't touch the existing footprint." _(Raised by: Leonie)_
Author
Owner

🎯 Discussion Resolutions

After walking through every persona point with the user, here are the agreed decisions. These now act as the authoritative design for implementation.

Theme 1 — DTO shape: single source of truth on the backend

  • Decision: Keep the pre-composed thumbnailUrl string on the DTO (matches the issue's ACs), AND retire the frontend URL-builder so there is exactly one place that knows how to shape a thumbnail URL.
  • Rationale: The user explicitly rejected having two builders. The frontend helper (frontend/src/lib/thumbnails.ts) is used in exactly one component, so the migration is cheap; leaving it alive would cement the duplication.

Theme 2 — Backend URL-building helper

  • Decision: Computed getter on the Document entity: @JsonProperty("thumbnailUrl") public String getThumbnailUrl(). Jackson serializes it automatically, so every Document response carries thumbnailUrl without per-controller plumbing.
  • Rationale: Keeps the derivation next to the data. DashboardResumeDTO.thumbnailUrl wraps doc.getThumbnailUrl(). DocumentThumbnail.svelte reads doc.thumbnailUrl directly — no helper, no import. Minor DDD coupling (entity knows URL shape) is an acceptable pragmatic trade for this monolith.

Theme 3 — Fallback visual

  • Decision: Document-text heroicon (same as DocumentThumbnail.svelte:44-55) when the document has no thumbnail yet. The parchment SVG stays alive only for the fully-empty state (resume-strip-empty), which this issue doesn't touch.
  • Rationale: Users learn one "no thumbnail" vocabulary across the app. The parchment keeps its place representing "no resume document at all" — one shape per meaning.

Theme 4 — Thumbnail dimensions

  • Decision: 180×252 (exact 5:7 A4 ratio, matches DocumentThumbnail everywhere else).
  • Rationale: Six pixels of height isn't a layout cost. Ratio inconsistency compounds every time users see two thumbnails side-by-side.

Theme 5 — Accessibility & layout

  • Decision: alt="" on the <img> (decorative — the <h2> title + caption carry the semantics). Wrap in a sized container h-[252px] w-[180px] to reserve space and prevent CLS. dark:mix-blend-multiply on the <img> for paper-scan glare prevention. aria-hidden="true" on the fallback icon wrapper.
  • Rationale: All four mirror DocumentThumbnail.svelte's existing, deliberate decisions. No re-litigation.

Theme 6 — Testing

  • Decision: Backend unit tests on the computed getter: a new small DocumentTest.java covers URL-with-cache-buster, URL-without-cache-buster (timestamp null), and null-when-thumbnailKey-null. One additional wiring test in DashboardServiceTest.java proves getResume populates the DTO field from the Document. Frontend: two new cases in DashboardResumeStrip.svelte.spec.ts<img> renders with correct attributes when URL set; fallback icon renders when URL null. Add data-testid on the thumbnail img and fallback wrapper for stable selectors.
  • Rationale: Coverage follows the logic's location. With the builder unified on Document.getThumbnailUrl(), URL-shape tests belong next to the getter, not scattered across every DTO consumer.

Theme 7 — Scope discipline

  • Decision: Do not generalize DocumentThumbnail to a third size. Two callsites don't justify the abstraction.
  • Rationale: Confirms the issue's original out-of-scope clause.

Theme 8 — Infrastructure / caching

  • Decision: No infra changes. Add a code comment on Document.getThumbnailUrl() documenting that the ?v={thumbnailGeneratedAt} cache-buster is load-bearing for the immutable header's safety.
  • Rationale: The DocumentController.java:110-113 response header comment already explains the immutable/private rationale on the serving side; the matching note on the URL-building side keeps the two halves of the invariant visible together. Protects against a future developer dropping the cache-buster "to clean up" and silently introducing a CWE-525 (cross-user cache poisoning) risk.

Open / Skipped

  • URL encoding equivalence between backend and frontend — obsolete after Theme 1: there is no frontend builder to match.
  • MVC regression test for the resume endpoint's permission boundary — covers pre-existing auth that isn't changing in this issue. Out of scope.
  • Separate test for "URL changes when thumbnailGeneratedAt changes" — already covered by the cache-buster assertion in case 1 (exact-value assertion on ?v=).

These resolutions now act as the authoritative design for implementation. The implement skill will read this comment alongside the original issue.

# 🎯 Discussion Resolutions After walking through every persona point with the user, here are the agreed decisions. These now act as the authoritative design for implementation. ## Theme 1 — DTO shape: single source of truth on the backend - **Decision:** Keep the pre-composed `thumbnailUrl` string on the DTO (matches the issue's ACs), AND retire the frontend URL-builder so there is exactly one place that knows how to shape a thumbnail URL. - **Rationale:** The user explicitly rejected having two builders. The frontend helper (`frontend/src/lib/thumbnails.ts`) is used in exactly one component, so the migration is cheap; leaving it alive would cement the duplication. ## Theme 2 — Backend URL-building helper - **Decision:** Computed getter on the `Document` entity: `@JsonProperty("thumbnailUrl") public String getThumbnailUrl()`. Jackson serializes it automatically, so every `Document` response carries `thumbnailUrl` without per-controller plumbing. - **Rationale:** Keeps the derivation next to the data. `DashboardResumeDTO.thumbnailUrl` wraps `doc.getThumbnailUrl()`. `DocumentThumbnail.svelte` reads `doc.thumbnailUrl` directly — no helper, no import. Minor DDD coupling (entity knows URL shape) is an acceptable pragmatic trade for this monolith. ## Theme 3 — Fallback visual - **Decision:** Document-text heroicon (same as `DocumentThumbnail.svelte:44-55`) when the document has no thumbnail yet. The parchment SVG stays alive only for the fully-empty state (`resume-strip-empty`), which this issue doesn't touch. - **Rationale:** Users learn one "no thumbnail" vocabulary across the app. The parchment keeps its place representing "no resume document at all" — one shape per meaning. ## Theme 4 — Thumbnail dimensions - **Decision:** **180×252** (exact 5:7 A4 ratio, matches `DocumentThumbnail` everywhere else). - **Rationale:** Six pixels of height isn't a layout cost. Ratio inconsistency compounds every time users see two thumbnails side-by-side. ## Theme 5 — Accessibility & layout - **Decision:** `alt=""` on the `<img>` (decorative — the `<h2>` title + caption carry the semantics). Wrap in a sized container `h-[252px] w-[180px]` to reserve space and prevent CLS. `dark:mix-blend-multiply` on the `<img>` for paper-scan glare prevention. `aria-hidden="true"` on the fallback icon wrapper. - **Rationale:** All four mirror `DocumentThumbnail.svelte`'s existing, deliberate decisions. No re-litigation. ## Theme 6 — Testing - **Decision:** Backend unit tests on the computed getter: a new small `DocumentTest.java` covers URL-with-cache-buster, URL-without-cache-buster (timestamp null), and null-when-`thumbnailKey`-null. One additional wiring test in `DashboardServiceTest.java` proves `getResume` populates the DTO field from the Document. Frontend: two new cases in `DashboardResumeStrip.svelte.spec.ts` — `<img>` renders with correct attributes when URL set; fallback icon renders when URL null. Add `data-testid` on the thumbnail img and fallback wrapper for stable selectors. - **Rationale:** Coverage follows the logic's location. With the builder unified on `Document.getThumbnailUrl()`, URL-shape tests belong next to the getter, not scattered across every DTO consumer. ## Theme 7 — Scope discipline - **Decision:** Do **not** generalize `DocumentThumbnail` to a third size. Two callsites don't justify the abstraction. - **Rationale:** Confirms the issue's original out-of-scope clause. ## Theme 8 — Infrastructure / caching - **Decision:** No infra changes. Add a code comment on `Document.getThumbnailUrl()` documenting that the `?v={thumbnailGeneratedAt}` cache-buster is load-bearing for the `immutable` header's safety. - **Rationale:** The `DocumentController.java:110-113` response header comment already explains the `immutable`/`private` rationale on the serving side; the matching note on the URL-building side keeps the two halves of the invariant visible together. Protects against a future developer dropping the cache-buster "to clean up" and silently introducing a CWE-525 (cross-user cache poisoning) risk. ## Open / Skipped - **URL encoding equivalence between backend and frontend** — obsolete after Theme 1: there is no frontend builder to match. - **MVC regression test for the resume endpoint's permission boundary** — covers pre-existing auth that isn't changing in this issue. Out of scope. - **Separate test for "URL changes when `thumbnailGeneratedAt` changes"** — already covered by the cache-buster assertion in case 1 (exact-value assertion on `?v=`). --- These resolutions now act as the authoritative design for implementation. The `implement` skill will read this comment alongside the original issue.
Sign in to join this conversation.
No Label feature ui
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#309