Files
EventSnap/FOLLOWUPS.md
fabi e79e020566 feat(eventsnap): UX review batch-3 — feed virtualization, a11y/UX fixes, shared primitives
Frontend
- Feed: DOM-windowing via @tanstack/svelte-virtual (new VirtualFeed). The window
  virtualizer keeps the sticky header, pull-to-refresh, infinite-scroll sentinel and
  bottom nav working. List = dynamic measured heights keyed by upload id +
  anchorTo:start so an SSE prepend doesn't jump a scrolled reader; grid = measured
  square rows. Drops the content-visibility band-aid and the old FeedGrid.
  measureElement(null) on row unmount prevents ResizeObserver retention; stable
  option callbacks + guarded setOptions avoid O(n) re-measure on like/comment patches.
- Global: viewport-fit=cover (activates safe-area insets), lang=de, PWA manifest +
  maskable icon + apple-touch metas, prefers-reduced-motion, safe-area-top headers.
- Feed reactivity: like/comment SSE patch the single card in place; upload-processed
  debounced in-place merge; IntersectionObserver leak fixed; shared 60s clock.
- CLS: list cards reserve the skeleton aspect box.
- Destructive actions (promote/demote/unban, gallery release) routed through
  ConfirmSheet (host + admin).
- Export OOM: streamed download via single-use ticket instead of res.blob().
- Shared primitives: IconButton (44px hit area), scrollLock action; UploadSheet a11y.
- Polish: api timeout + non-JSON guard, focus-trap offsetParent fix, pull-to-refresh
  passive:false + guards, diashow keyboard a11y, Toaster assertive errors, dark-mode
  gaps, aria-pressed on like/chip state, CameraCapture mic-on-video + retry,
  ConfirmSheet spinner, upload beforeunload warning, emoji->SVG icons.

Backend
- social.rs: like/comment SSE broadcasts now carry the fresh count so feed clients
  patch one card in place instead of refetching page 1.
- admin.rs / main.rs: supporting changes for the above.

Verified: svelte-check 0 errors, frontend build clean, /feed SSR 200. Runtime feed
scroll-feel validation still owed (documented in FOLLOWUPS.md).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 19:16:48 +02:00

6.7 KiB

Follow-ups

Tracked work that was deferred during the multi-round UI/UX review pass. Each item has a clear acceptance criterion so a future pass can land it without re-deriving the context.

A11y — assistive-tech containment inside modals

Problem. Open modals (LightboxModal, ConfirmSheet, Modal, OnboardingGuide, join PIN modal, account data-mode sheet, export HTML guide) trap keyboard Tab via focusTrap, but VoiceOver rotor / TalkBack arrow-key navigation can still escape into the page content behind the dialog. Screen-reader users hear the wrong context.

Why deferred. A naive sibling-walk that sets inert on direct children of the modal's parent silences the global <Toaster> (aria-live="polite" region mounted in +layout.svelte) and the <BottomNav> while a modal is open — breaking toast announcements and the visible nav state. SvelteKit has no built-in portal mechanism, so dialogs render inside the route tree alongside the Toaster.

Acceptance criterion. With any modal open:

  • VoiceOver rotor (iOS Safari) and TalkBack swipe navigation (Android Chrome) cannot leave the dialog subtree.
  • Toasts that fire while a modal is open are still announced.
  • Nested modals (e.g. ConfirmSheet opened from inside ContextSheet) maintain correct containment when the inner closes.

Sketch of an approach. One of:

  1. Portal pattern. Render dialogs into a dedicated <div id="modal-root"> that's a sibling of the main app root in app.html. focusTrap then sets inert on the main root, leaving the modal root and the toast region (also moved to its own portal root) untouched.
  2. Opt-out marker. Walk siblings and inert them, but skip any node carrying a data-modal-passthrough attribute. Mark <Toaster> with it. Document the contract.
  3. Stack-aware containment. Maintain a module-level stack of open dialog nodes; the topmost owns the inert state, popped dialogs restore the previous layer. Avoids the nested-modal restoration bug.

Approach 1 is the cleanest long-term but the highest blast radius. Approach 2 is the smallest patch.

Files to touch.

Feed — DOM-windowing virtualization (IMPLEMENTED — residual validation owed)

Status. Implemented in frontend/src/lib/components/VirtualFeed.svelte using @tanstack/svelte-virtual's createWindowVirtualizer. Both the list view (dynamic measureElement heights, keyed by upload id with anchorTo:'start' so an SSE prepend doesn't yank a scrolled-down reader) and the grid view (three measured square tiles per row) now keep only the on-screen window (+overscan) in the DOM instead of one node per upload. The window virtualizer scrolls the document, so the sticky header, pull-to-refresh, the infinite-scroll sentinel and the bottom nav are untouched. The earlier content-visibility band-aid was removed from FeedListCard (it interferes with real-height measurement), and the old FeedGrid.svelte was deleted (its sole consumer migrated to VirtualFeed).

Verified. svelte-check 0 errors, production build clean. The integration follows the library's documented window-virtualizer contract (confirmed against virtual-core source: item start includes scrollMargin, getTotalSize() excludes it; the SSR path is guarded by getScrollElement() returning null).

Residual validation owed (needs the running app — could not be done headless).

  • Manual scroll testing on a ~1000-item event: confirm no jank, correct scrollbar size, and that an SSE new-upload / feed-delta prepend while scrolled down does not jump the viewport (the anchorTo:'start' + id-key path).
  • scrollMargin re-measure when grid filter chips change the header height (handled reactively via the uploads-length-driven effect, but unverified visually).
  • A new e2e spec that scrolls far down, likes an item via SSE, and asserts scroll position is retained — the existing suite only asserts a single card is visible, so it cannot catch a scroll regression.

Files.

Feed — per-image exact CLS reservation

Problem. FeedListCard now reserves a default aspect-[4/5] box for photos so the card doesn't collapse to height 0 and reflow as images stream in (matching Skeleton). But no image dimensions are stored anywhere (not on FeedUpload, the upload table, or any migration), so the box is a uniform guess that crops to fit — the original is one tap away in the lightbox.

Acceptance criterion. Extract image width/height during the compression worker, store them on upload, expose them on FeedUpload, and have the card reserve the true aspect ratio (no crop, zero shift).

Files to touch.

Smaller nits, optional

  • Auto-submit on retried 4th digit. recover/+page.svelte, join/+page.svelte — after a wrong PIN, deleting one digit and retyping triggers an immediate submit. Backend's 3-attempts/15-min lockout makes this safe; could feel hair-trigger after a typo. Consider gating the second auto-submit per input session behind an explicit button press.
  • Onboarding pip tap target on the vertical axis. OnboardingGuide.svelte — current p-2.5 yields ~26 px height, meets WCAG 2.2 AA (≥24 px) but below iOS HIG / Material's 44 / 48 dp recommendation. Bumping to p-3 is the easy improvement; further increases start crowding the row.
  • Migrate bespoke focus-trapped dialogs to <Modal>. Join PIN modal, OnboardingGuide, LightboxModal, HTML guide, account data-mode sheet — all currently roll their own shell with focusTrap. They're correct, just not using the canonical primitive. Migrate when <Modal> gains features (e.g. the inert work above) you'd want everywhere.