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>
103 lines
6.7 KiB
Markdown
103 lines
6.7 KiB
Markdown
# 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.**
|
|
- [frontend/src/lib/actions/focus-trap.ts](frontend/src/lib/actions/focus-trap.ts) — add inert logic
|
|
- [frontend/src/lib/components/Toaster.svelte](frontend/src/lib/components/Toaster.svelte) — add passthrough marker (if approach 2) or move to a portal (if approach 1)
|
|
- [frontend/src/app.html](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](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.**
|
|
- [frontend/src/lib/components/VirtualFeed.svelte](frontend/src/lib/components/VirtualFeed.svelte) — new windowing component (list + grid)
|
|
- [frontend/src/routes/feed/+page.svelte](frontend/src/routes/feed/+page.svelte) — renders `VirtualFeed` for both views
|
|
- [frontend/src/lib/components/FeedListCard.svelte](frontend/src/lib/components/FeedListCard.svelte) — `content-visibility` removed
|
|
|
|
## 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](frontend/src/lib/types.ts), [FeedListCard.svelte](frontend/src/lib/components/FeedListCard.svelte)
|
|
|
|
## Smaller nits, optional
|
|
|
|
- **Auto-submit on retried 4th digit.** [recover/+page.svelte](frontend/src/routes/recover/+page.svelte), [join/+page.svelte](frontend/src/routes/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](frontend/src/lib/components/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.
|