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>
This commit is contained in:
fabi
2026-06-30 19:16:48 +02:00
parent bbec815854
commit e79e020566
42 changed files with 1245 additions and 328 deletions

View File

@@ -98,6 +98,14 @@
overlayHideTimer = setTimeout(() => (showOverlay = false), 4000);
}
// Manual toggle from the always-visible control button — stays open (no
// auto-hide) so keyboard users can tab through the controls without them
// vanishing mid-interaction.
function toggleOverlay() {
if (overlayHideTimer) clearTimeout(overlayHideTimer);
showOverlay = !showOverlay;
}
function togglePause() {
paused = !paused;
if (paused) {
@@ -166,19 +174,52 @@
<div class="text-white/60">Lade…</div>
{/if}
<!-- Always-visible controls: keyboard-reachable, so the show is never a trap and
the controls are reachable without a pointer. Honours the notch. -->
<div
class="absolute right-0 top-0 z-10 flex gap-2 p-3"
style="padding-top: calc(env(safe-area-inset-top) + 0.75rem)"
>
<button
type="button"
onclick={(e) => { e.stopPropagation(); toggleOverlay(); }}
aria-label="Steuerung anzeigen"
aria-expanded={showOverlay}
class="inline-flex h-11 w-11 items-center justify-center rounded-full bg-white/10 text-white hover:bg-white/20"
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8">
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 6h9.75M10.5 6a1.5 1.5 0 1 1-3 0m3 0a1.5 1.5 0 1 0-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-9.75 0h9.75" />
</svg>
</button>
<button
type="button"
onclick={(e) => { e.stopPropagation(); exit(); }}
aria-label="Diashow beenden"
class="inline-flex h-11 w-11 items-center justify-center rounded-full bg-white/10 text-white hover:bg-white/20"
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
</div>
{#if showOverlay}
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div
class="absolute inset-x-0 bottom-0 flex flex-col gap-3 bg-gradient-to-t from-black/90 via-black/60 to-transparent p-6 pb-10"
onclick={(e) => e.stopPropagation()}
>
<div class="flex flex-wrap items-center gap-2">
<button
type="button"
onclick={togglePause}
class="rounded-full bg-white/10 px-4 py-2 text-sm font-medium hover:bg-white/20"
class="inline-flex items-center gap-1.5 rounded-full bg-white/10 px-4 py-2 text-sm font-medium hover:bg-white/20"
>
{paused ? '▶ Fortsetzen' : '⏸ Pause'}
{#if paused}
<svg class="h-4 w-4" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z" /></svg>
Fortsetzen
{:else}
<svg class="h-4 w-4" fill="currentColor" viewBox="0 0 24 24"><path d="M6 5h4v14H6zM14 5h4v14h-4z" /></svg>
Pause
{/if}
</button>
<label class="flex items-center gap-2 rounded-full bg-white/10 px-3 py-2 text-sm">
@@ -206,9 +247,10 @@
<button
type="button"
onclick={exit}
class="ml-auto rounded-full bg-white/10 px-4 py-2 text-sm font-medium hover:bg-white/20"
class="ml-auto inline-flex items-center gap-1.5 rounded-full bg-white/10 px-4 py-2 text-sm font-medium hover:bg-white/20"
>
✕ Beenden
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" /></svg>
Beenden
</button>
</div>