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>
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:
- Portal pattern. Render dialogs into a dedicated
<div id="modal-root">that's a sibling of the main app root inapp.html.focusTrapthen setsinerton the main root, leaving the modal root and the toast region (also moved to its own portal root) untouched. - Opt-out marker. Walk siblings and inert them, but skip any node carrying
a
data-modal-passthroughattribute. Mark<Toaster>with it. Document the contract. - 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.
- frontend/src/lib/actions/focus-trap.ts — add inert logic
- frontend/src/lib/components/Toaster.svelte — add passthrough marker (if approach 2) or move to a portal (if approach 1)
- frontend/src/app.html — add
<div id="modal-root">(if approach 1)
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-deltaprepend while scrolled down does not jump the viewport (theanchorTo:'start'+ id-key path). scrollMarginre-measure when grid filter chips change the header height (handled reactively via theuploads-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.
- frontend/src/lib/components/VirtualFeed.svelte — new windowing component (list + grid)
- frontend/src/routes/feed/+page.svelte — renders
VirtualFeedfor both views - frontend/src/lib/components/FeedListCard.svelte —
content-visibilityremoved
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.
- backend:
services/compression.rs,models/upload.rs,handlers/feed.rs, a migration - frontend/src/lib/types.ts, FeedListCard.svelte
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.5yields ~26 px height, meets WCAG 2.2 AA (≥24 px) but below iOS HIG / Material's 44 / 48 dp recommendation. Bumping top-3is 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 withfocusTrap. They're correct, just not using the canonical primitive. Migrate when<Modal>gains features (e.g. the inert work above) you'd want everywhere.