feat(mobile): reader surfaces (Home · /documents · /briefwechsel · /persons) pass mobile-first bar at 375 px #318
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?
Context
The stated target audience for the archive — the younger generation of the family — will mostly consume letters on phones. The transcriber persona (family members 60+ with Kurrent literacy) works on laptops/tablets, so the authoring surface is allowed to stay desktop-first. This issue scopes the mobile-first bar for the reader/consumer surface only.
Current state, captured in a 375×812 Playwright sweep on 2026-04-24: every reader-facing surface shows a visible horizontal scrollbar at 375 px and clips content off the right edge.
/documents/briefwechsel/personsEvidence screenshots at
/tmp/fa-audit/D1-…D4-*.png(captured during Phase B2 UX audit). The common root cause is a fixed desktop grid / wide paddings withoutmd:/lg:breakpoint narrowing.Sister issue #95 addressed the save-bar button wrap on the authoring edit page at 320 px — narrow, one-page fix. This issue is the systemic counterpart for the reader path: every route a reader visits must pass a mobile-first bar defined below.
Non-goals
/documents/new,/documents/[id]/edit, the Transcribe panel on/documents/[id], and every/admin/*surface. Their narrow-viewport issues are separate (or already addressed by #95).Mobile-first bar (the shared contract this issue enforces)
A reader surface passes iff, at a viewport of
375 × 812:document.documentElement.scrollWidth <= 375— no horizontal overflow.overflow: hiddeninside a cropped container that has no alternative way to see the clipped information.In-scope routes and the known-bad spots
/(Home) — reader parts onlyReader-facing on Home: the
Resume Strip("Continue where you left off"), theComments & Activityfeed, andWhat Needs Attentioncounts. The transcriber queues (Mark Text · Transcribe Text · Ready to Read) are allowed to collapse behind a "Transcriber Tools" disclosure onsm:screens.Known bad at 375 px: Resume hero card is a two-column grid; the right column (metadata + progress ring + CTA) forces its own fixed width, pushing the left column (thumbnail + first-line preview) off-screen.
/documents— listmax-w-none— should bew-fullinside a container that honours the viewport.Depends on #315/#316 (pagination) — do not re-regress: whatever this issue ships must keep the paginated-list-first contract.
/documents/[id]— detail (read mode)Details / Transcribe / Editactions overflows. Transcribe and Edit are authoring actions; onsm:they collapse behind a single kebab menu so onlyDetailsremains visible as a reader action.object-contain+max-w-full+ respect safe area; zoom controls (already present) stay visible at the bottom./briefwechsel— Letters/persons— listp-4→p-3onsm:), alias collapses to a second line instead of a third column./persons/[id]— detailsm:with correspondents as a horizontally scrollable chip rail (opt-in horizontal scroll is fine; involuntary overflow is not).Out of scope (explicit)
Implementation plan
1. Tailwind breakpoint discipline
md:/lg:.flex-row/grid-cols-{n≥2}/w-[≥500px]without a breakpoint prefix on files underroutes/+page.svelte,routes/documents/*,routes/persons/*,routes/briefwechsel/*. Warn-on-PR is enough.2. Route-by-route fixes (one commit per route)
Each commit touches listed files AND adds/updates a matching spec in
frontend/e2e/responsive/*.spec.ts.frontend/src/routes/+page.svelte; split hero intosm:grid-cols-1 md:grid-cols-[auto_1fr]; thumbnail shrinks atsm:.frontend/src/routes/documents/+page.svelte; filter barflex-col sm:flex-row.frontend/src/routes/documents/[id]/+page.svelte; action cluster collapses into a newKebabMenu.svelteatsm:.frontend/src/routes/briefwechsel/+page.svelte; confirm with a snapshot.frontend/src/routes/persons/+page.svelte+PersonCard.svelte(verify name).frontend/src/routes/persons/[id]/+page.svelte; correspondents row becomesoverflow-x-auto snap-x snap-mandatoryonsm:.3. Global layout
frontend/src/routes/+layout.svelte— verifyMenü öffnentoggle carriesaria-expanded; drawer closes on route change.sm:shows: logo + burger + upload icon; locale, dark-mode, notifications, and avatar move inside the drawer.4. i18n
No new user-visible strings expected. If any are added (e.g. a kebab "More actions" label), add keys to
frontend/messages/{de,en,es}.json.Tests
Frontend e2e (Playwright)
New folder
frontend/e2e/responsive/with one spec per route:home.responsive.spec.tsdocuments-list.responsive.spec.tsdocument-detail.responsive.spec.tsbriefwechsel-entry.responsive.spec.tspersons-list.responsive.spec.tsperson-detail.responsive.spec.tsEach spec:
page.setViewportSize({ width: 375, height: 812 }).await page.evaluate(() => document.documentElement.scrollWidth) <= 375.a, buttonmatched bypage.locator('a, button'): assertboundingBox().height >= 44andwidth >= 44(with opt-out list for decorative icons).e2e/snapshots/responsive/— fails if pixel delta > threshold. Re-enable #124 for this.Add a shared helper
frontend/e2e/responsive/mobile.helper.tsexporting viewport setup + the three standard assertions.axe-core
Extend existing axe sweeps to run each route at 375 px in both light and dark mode; fail on any new violations (baseline snapshot, then diff).
Verification
cd frontend && npm run test— unit tests pass.cd frontend && npx playwright test e2e/responsive— all six specs pass.D*.pngshows no scrollbar.Acceptance criteria
flex-row/ multi-column grid without breakpoint prefix inside reader routesOut-of-band dependencies
descoped).Critical files
🏛️ Markus Keller — Application Architect
Observations
KebabMenu.sveltecomponent is a sound extraction: theDocumentTopBar.sveltealready has amobileMenuOpenstate variable (line 65) with partialmd:flex/md:block/md:hiddenclasses in place. The issue is formalising what half-exists already.flex-row/grid-cols-{n≥2}without breakpoint prefix in reader route files) is the right forcing function. However, as specified it will fire false positives onflex-rowitems that only appear insidemd:flexwrappers — the rule needs to be scope-aware or it will be ignored from day one.frontend/e2e/responsive/is the right organisational choice. The existingbriefwechsel-rows.visual.spec.tsalready establishes the pattern of gating visual snapshots onVISUAL=1; the new specs should follow the same convention.test.skip(!VISUAL, ...)is already in place inbriefwechsel-rows.visual.spec.ts), then the same pattern already works — re-enabling #124 means adding snapshots and baseline images, not changing the test infrastructure.CLAUDE.mdroute table do not need updating (no new routes). The newKebabMenu.sveltecomponent is not a new route — no doc update required per the architect documentation table.Recommendations
// @mobile-first-onlyat the top of each in-scope file, or as a Svelte plugin that only fires when the parent element has no breakpoint wrapper. A warn-on-PR eslint rule covering onlyroutes/+page.svelte,routes/documents/*,routes/persons/*,routes/briefwechsel/*is sufficient — add it as its own atomic commit so it can be reverted without touching the layout fixes.KebabMenuboundary explicit.DocumentTopBar.sveltealready managesmobileMenuOpenstate. Extract the kebab menu dropdown (lines 247–270 of the current TopBar) intoKebabMenu.sveltewith a prop interface ofitems: { label: string; icon?: ...; action: () => void }[]. The TopBar becomes an orchestrator. This is one visual region → one component, which is the correct boundary.👨💻 Felix Brandt — Fullstack Developer
Observations
class="relative h-[252px] w-[180px] flex-shrink-0 ..."— the thumbnail isw-[180px] flex-shrink-0inside aflex gap-4container. At 375 px viewport the remaining flex child (metadata + progress ring + CTA) gets pushed off screen. This is the exact symptom described in the issue. The fix:w-[120px] sm:w-[180px]on the thumbnail, or wrap the whole strip in aflex-col sm:flex-rowso thumbnail and metadata stack on narrow screens./persons/+page.svelterendersgrid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4— thesm:grid-cols-2means at 375 px (which is below thesmbreakpoint at 640 px) it collapses to single column. This one is already correct. However, the header rowflex flex-wrap items-end justify-betweenwith aw-56search input inside will still overflow when the "New person" CTA is also present —flex-wraphelps but the right-side group hasflex items-center gap-3with no wrapping of its own.mobileMenuOpen). TheKebabMenu.sveltereferenced in the issue is mostly already there (lines 247–270 in DocumentTopBar). The issue is that the edit button still hashidden sm:inlineon the label (line 235) and the Transcribe button usesmd:flex(line 78). On exactly 375 px the Transcribe and Edit buttons are hidden and the three-dot mobile menu shows — this part is functional. The gap is that the mobile-menu dropdown has noaria-labeland its trigger button has noaria-labeleither (line 253:onclick={() => (mobileMenuOpen = !mobileMenuOpen)}— check for missing accessible label).flex items-center gap-4. At 375 px this puts the search input, sort dropdown, filter toggle, and a "New document" link in one horizontal flex row. There is noflex-wraporsm:flex-rowvariant. The flex children will overflow.lg:grid lg:grid-cols-[35%_65%]—lg:gridmeans it isblock(single column) below thelgbreakpoint (1024 px). At 375 px this is already single-column. No fix needed for layout; the correspondents rail check is valid.e2e/responsive/folder exists yet. The issue spec correctly identifies this as new. The existingbriefwechsel-rows.visual.spec.tsandbriefwechsel-a11y.spec.tsalready demonstrate thepage.setViewportSizepattern. The newmobile.helper.tsshould export exactly three assertions:scrollWidth <= 375,boundingBox().height >= 44,boundingBox().width >= 44.Recommendations
w-[180px] flex-shrink-0tow-[120px] flex-shrink-0 sm:w-[180px]and addflex-col sm:flex-rowto the strip container. Update the specDashboardResumeStrip.svelte.spec.tsto assert the strip does not overflow at 375 px.flex-wrapto SearchFilterBar row 1 and stack:flex flex-wrap gap-3withflex-col sm:flex-rowso search fills the first line on narrow and the sort+filter controls wrap below. Move "New document" to float at the end of row 1 or use absolute-positioned FAB atsm:hidden.KebabMenu.svelteas a new file, check whether extracting the existing dropdown fromDocumentTopBaris sufficient. The menu logic is already implemented — the issue needs a clean boundary, not a rewrite.{#each}blocks in the new spec fixtures with(doc.id)— the spec'spage.locator('a, button')loop must not rely on position.scrollWidth <= 375will be red immediately for every route listed in the issue, giving you a mechanical green gate.data-touch-exemptattribute on known decorative SVGs so the assertion can skip them withpage.locator('a, button').filter({ hasNot: page.locator('[data-touch-exempt]') }).🚀 Tobias Wendt — DevOps & Platform Engineer
Observations
frontend/e2e/responsive/folder with six specs. The existingplaywright.config.tsruns all tests under./e2ewithtestDir: './e2e', so the new subfolder is picked up automatically — no CI config change needed.workers: 1(sequential) andfullyParallel: falsebecause tests share auth state. Six additional responsive specs will run sequentially after the existing 35+ specs. The issue does not address CI time impact.briefwechsel-rows.visual.spec.tsgates snapshot tests behindVISUAL=1. The issue says "Re-enable #124" and "Snapshot intoe2e/snapshots/responsive/— fails if pixel delta > threshold." This implies the new responsive specs will always run snapshot assertions (not gated), which differs from the existing convention. That inconsistency needs resolving before the first CI run.playwright.config.tshas nomobileproject — there is nodevices['Pixel 5']or equivalent. All tests run withdevices['Desktop Chrome']andsetViewportSizeis called per-test. This is fine but means Safari and Firefox at mobile widths are untested. The issue's acceptance criteria do include manual smoke on "iPhone Safari, Android Chrome" — this is appropriately scoped as manual given the single-worker CI constraint.npx playwright install chromiumis in the workflow before adding specs that would fail on a fresh runner without the browser binary.Recommendations
VISUAL=1— match the convention inbriefwechsel-rows.visual.spec.ts. This keeps CI green on the first run without requiring pre-captured baselines, and the--update-snapshotsworkflow is already documented in that file.workers: 1this is the true cost. Acceptable, but worth logging so the next person doesn't wonder why CI got slower.npx playwright install --with-deps chromiumis in the CI workflow before this PR merges. If the CI workflow already has it (implied by the 35+ existing E2E specs), no action needed — just verify.mobilePlaywright project for this issue. ThesetViewportSizeper-test approach is sufficient and avoids doubling the CI matrix. A proper mobile browser project (Safari via WebKit) belongs in a future dedicated accessibility milestone.e2e/responsive/subfolder is self-contained. If the responsive specs become flaky after a UI change, they can be quarantined as a group by excluding the subfolder in CI via--ignore-snapshotsor atest.skipflag — this isolation is a benefit of the new folder structure.📋 Elicit — Requirements Engineer
Observations
scrollWidth <= 375is a machine-testable assertion, and the six Playwright specs map 1:1 to the six in-scope routes. No ambiguity on what "passes" means.overflow: hiddeninside a cropped container that has no alternative way to see the clipped information") has no automated test defined for it. The spec only checksscrollWidth. A hidden overflow on an inner container (e.g. a truncated tag name with no tooltip) would pass the scrollWidth check but fail criterion 2. The issue does not explain how this is tested.Recommendations
overflow: hiddenand assert that the element'sscrollWidth <= clientWidth(i.e. it is not actually clipping visible text that has no other access path). Add this as a fourth assertion inmobile.helper.tsalongside the three already specified.page.getByRole('link', { name: /lesen|read/i })and asserttoBeVisible()at 375 px — not hidden inside a menu.more_actionstomessages/{de,en,es}.jsonas part of the first commit touchingDocumentTopBar. This avoids a last-minute i18n gap discovered in review.🔒 Nora "NullX" Steiner — Security Engineer
Observations
KebabMenu.sveltecomponent collapses authoring actions (Transcribe, Edit) behind a dropdown on mobile. This has a security-adjacent implication: if the "Edit" action is hidden inside a kebab menu on mobile, but the underlying route (/documents/[id]/edit) is still reachable by direct URL navigation, the access control is backend-enforced (@RequirePermission(Permission.WRITE_ALL)on the edit endpoint) — the visual hiding is not a security boundary. This is correct architecture. Confirm theDocumentTopBardoes not accidentally remove the permission check from the visible desktop button while adding the mobile menu.overflow-x-auto snap-x snap-mandatorychip rail proposed for/persons/[id]correspondents is a UI pattern with no security implications. The opt-in horizontal scroll is contained within a named element and does not expose data that wasn't already visible.AppNav.svelte) already implementsaria-expanded={mobileNavOpen}andaria-controls="mobile-nav"correctly (lines 98–99). No regression risk there.localStorageusage inbriefwechsel/+page.svelte(forkorrespondenz_recent_persons) is pre-existing and unaffected.Recommendations
DocumentTopBarmobile menu (lines 267–270) closes onclickOutsidebut there is noEscapekey handler and no focus trap. Addonkeydownhandling forEscapeto close the menu, and ensure focus returns to the trigger button on close. This is both a WCAG 2.1 criterion (4.1.2) and good UX for keyboard-first senior users.aria-labelto the three-dot mobile trigger button inDocumentTopBar(line 253: the button currently has no label — screen readers will announce "button"). Use the samem.more_actions()i18n key recommended by Elicit.pointer-events: noneas a security boundary. The issue does not propose this, but verify during implementation that any "authoring-only" button hidden on mobile is hidden via CSS (hidden md:flex) notpointer-events: none— the latter is invisible to assistive technology but does not actually prevent interaction via keyboard or accessibility APIs.flex-row/multi-column grid without breakpoint) is a development guardrail, not a security control. No security review needed for it.🧪 Sara Holt — QA Engineer
Observations
scrollWidth <= 375,height >= 44,width >= 44) extracted into a sharedmobile.helper.ts. This is the correct approach — shared helper, route-specific specs, not one mega-spec.briefwechsel-a11y.spec.tsalready runs at{ name: 'mobile', width: 375, height: 812 }(line 15) and thebriefwechsel-rows.visual.spec.tsdoes the same. The new folder reuses this pattern, which is good for consistency. The issue should call out that these two specs are not being moved — they stay in place.a, buttonelements will be flaky without a well-defined opt-out list. The issue mentions "with opt-out list for decorative icons" but does not define what that list is or how it is maintained. In the existing codebase there are several icon-only buttons and SVG elements (e.g. the three-dot trigger inDocumentTopBar, the upload icon in the header) that may not meet 44×44 until the fix is applied. Without the opt-out list defined upfront, the spec will fail on elements not targeted by this issue, creating noise.mobile.helper.tswill contain three assertion functions. These are plain TypeScript functions — they should have at least a minimal test that they throw whenscrollWidth > 375.accessibility.spec.ts, or to each new responsive spec, or to the existingbriefwechsel-a11y.spec.ts? The lack of specificity means this is likely to be deferred or done inconsistently.mobileproject — all tests run on Desktop Chrome with manualsetViewportSize. This means the 375 px tests do not simulate actual mobile browser rendering (no touch events, no device pixel ratio). For this issue's scope (overflow detection, tap target size) this is acceptable. Flag it as a known gap in the PR description.retries: 2in CI, worst case is 6 × 45s = 270s. This is acceptable given the existing suite size.Recommendations
data-attribute before writing any spec. Recommenddata-touch-exempton known decorative/small elements. The helper assertion becomes:page.locator('a:visible, button:visible').filter({ hasNot: page.locator('[data-touch-exempt]') }). Document in the PR which elements are marked exempt and why.checkA11ycalls at 375 px to the existingaccessibility.spec.tsfor the six in-scope routes, in both light and dark mode. Do not create a seventh spec file for axe — fold it into the existing accessibility spec to keep the test surface coherent.mobile.helper.tsfirst, test it, then use it. The helper is the keystone of the whole test strategy. IfassertNoHorizontalOverflowhas a bug (e.g. it checksscrollWidthbefore the page finishes layout), every spec silently passes. Give it a unit test in Vitest that confirms it throws on a synthetic overflow fixture.test.describe.configure({ mode: 'serial' })to each responsive spec — since the specs share an auth session and run at an unusual viewport, serial execution within each file prevents viewport state from leaking between tests.🎨 Leonie Voss — UX Designer & Accessibility Strategist
Observations
scrollWidth <= 375, 44×44 tap targets, 16px minimum body text, primary action one tap away — this is WCAG 2.5.5 compliant and appropriate for the dual audience (60+ seniors on phones).w-[180px] flex-shrink-0thumbnail creates the exact layout failure described. At 375 px, the 180 px thumbnail plus gap plus metadata column exceeds the viewport. The fix must respect the thumbnail's aspect ratio (the image isobject-cover object-top) — shrinking the width tow-[100px] sm:w-[180px]will also change the height proportionally given the fixedh-[252px]. Use a ratio-preserving container:aspect-[180/252]or simplyh-autoon the image withmax-h-[252px].DashboardResumeStripempty state (rounded-sm border border-line bg-surface p-8 text-center) is already mobile-safe — it is a single-column centered block with no fixed widths.flex items-center gap-4) with search + sort + filter + "New document" horizontally overflows. The issue's proposed fix (stack: search on its own line, sort + filter, "+ New document" floating or moved) is correct UX. The senior audience benefits from the search input being full-width and clearly labeled — ensure thearia-labelon the search input (m.docs_search_placeholder()) matches the visible placeholder text.sm:, and "Details" (viewer action) stays visible. This matches the dual-audience split: readers see "Details"; transcribers see the full bar on their larger screens. Make sure the kebab trigger button hasaria-label={m.more_actions()}andaria-haspopup="menu"to meet WCAG 4.1.2.overflow-x-auto snap-x snap-mandatory) on Person detail is the correct pattern for opt-in horizontal scroll. This is not involuntary overflow — the user consciously scrolls. Applyscroll-snap-type: x mandatorywithsnap-starton each chip so the scroll feels intentional, not accidental.font-size: text-sm(14px) appears on person card secondary text and document metadata lines. These must be checked. Tailwind'stext-smis 0.875rem = 14px — below the 16px iOS Safari auto-zoom threshold. Any<input>adjacent totext-smlabels will trigger iOS auto-zoom on focus. Fix: raise all input labels totext-base(16px) or set the input font-size to 16px explicitly.header.spec.tstests dark mode for the header. The issue requires extending this to all six routes at 375 px. The senior audience (who may use high-contrast or dark mode for readability) depends on this passing.Recommendations
aspect-[9/13]on the resume thumbnail container instead of fixedh-[252px] w-[180px]. This givesw-full sm:w-[180px]a proportional height on mobile without needing both dimensions to be fixed. Themax-w-[180px]prevents it from growing too large on desktop.font-size: 16pxon all<input>elements in reader routes via a global CSS rule inlayout.css. This prevents iOS Safari auto-zoom universally — aligns with criterion 4 of the mobile-first bar without per-input changes. Use:input, select, textarea { font-size: max(16px, 1rem); }.DocumentTopBaris styled asrelative md:hiddenwith no explicit min-height. Addmin-h-[44px] min-w-[44px]to the trigger button.brand-mint on white = ~2.8:1which fails AA for normal text. A quick grep fortext-accentin the six affected route files will surface any violations before they reach the PR.bg-primary text-primary-fg) to maintain brand consistency — do not use a generic FAB pattern that diverges from the existing design system.Open Decisions
sm:" but does not specify whether the two-column strip becomes single-column (thumbnail above, metadata below) or stays side-by-side with a smaller thumbnail. Side-by-side with a smaller thumbnail preserves the visual context of the document preview; single-column gives more metadata width. For a 375 px screen with a 60+ year-old user, side-by-side with a smaller thumbnail (~100px) is the better choice — the thumbnail gives orientation cues. But this is a product judgment about information hierarchy on the home screen.Decision Queue
One genuine tradeoff was raised that needs a product call before implementation starts.
D1 — Resume strip layout at 375 px: side-by-side (smaller thumbnail) vs. single-column stack
Raised by: Leonie Voss
Context: The issue says "thumbnail shrinks at
sm:" but does not specify the exact layout change. Two approaches are viable:Option A — Side-by-side, thumbnail shrinks to ~100 px
flex-rowbut thumbnail changes fromw-[180px]tow-[100px] sm:w-[180px]Option B — Single-column stack (thumbnail above, metadata below)
flex-col sm:flex-rowRecommendation from Leonie: Option A (side-by-side, ~100 px thumbnail) — the orientation cue matters more than image fidelity at this stage; the reader is not reading the document in the strip, they are recognising it.
Who decides: Marcel (product owner) — this is an information hierarchy judgment, not a technical constraint.