diff --git a/FOLLOWUPS.md b/FOLLOWUPS.md
index d268f06..5fcba09 100644
--- a/FOLLOWUPS.md
+++ b/FOLLOWUPS.md
@@ -46,6 +46,90 @@ is the smallest patch.
- [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 `
` (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
+
+**Known limitations (surfaced in the post-commit review, left as-is — low impact).**
+- **Grid prepend reflows tiles.** Grid rows are index-keyed and pack 3 tiles each, so
+ an SSE `new-upload` shifts every tile by one position; `anchorTo:'start'` can only
+ anchor a scrolled grid reader when the prepend crosses a 3-item boundary (it adds a
+ *row*). List view is unaffected (one id-keyed row per upload). A fix would key rows
+ by the first tile's id and accept partial-row churn; not worth it for the rarer
+ "new upload while browsing the grid" case.
+- **Filtered grid can auto-load the whole feed.** When a grid filter matches few items
+ the `VirtualFeed` is short, so the infinite-scroll sentinel sits in-viewport and
+ `loadMore()` fires until `nextCursor` is null — pulling all pages to widen the
+ client-side search. This is pre-existing (the old `FeedGrid` had the same shape, and
+ the empty-filter copy even says "scrolle weiter"), not a virtualization regression.
+ If undesired, gate auto-load to list view or to actual user scroll.
+
+## Feed — comment deletion leaves a stale live count
+
+**Problem.** `add_comment` now broadcasts a fresh `comment_count` so feed clients patch
+the card in place, but `delete_comment` and `host_delete_comment`
+([backend/src/handlers/social.rs](backend/src/handlers/social.rs),
+[host.rs](backend/src/handlers/host.rs)) soft-delete without broadcasting any
+count/event. So a deletion leaves the count too high on every client until a full
+refetch (pull-to-refresh or an unrelated `upload-processed` merge). `toggle_like`
+already broadcasts on both add and remove, so likes are fine — the gap is
+comments-on-delete. Pre-existing, but the in-place-patch scheme makes it observable.
+
+**Fix.** Emit a `new-comment` (or a `comment-deleted`) event carrying the refreshed
+`comment_count` from both delete paths, the same best-effort way `add_comment` does;
+the frontend `patchCount(..., 'comment_count')` handler already consumes it.
+
+**Note — count ordering.** The broadcast count is read just after the (auto-committed)
+mutation, not inside it, so under concurrent likes/comments on one upload the SSE
+messages are last-write-wins. The frontend *replaces* (not increments) the count, so
+steady state is correct and self-healing; only document this if strict per-event
+ordering is ever required (then compute the count in-tx with a monotonic sequence).
+
+## 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.
diff --git a/backend/src/handlers/admin.rs b/backend/src/handlers/admin.rs
index 05eb849..69f0753 100644
--- a/backend/src/handlers/admin.rs
+++ b/backend/src/handlers/admin.rs
@@ -1,7 +1,7 @@
use std::collections::HashMap;
use std::time::Duration;
-use axum::extract::State;
+use axum::extract::{Query, State};
use axum::http::{HeaderMap, StatusCode};
use axum::Json;
use serde::{Deserialize, Serialize};
@@ -223,11 +223,44 @@ pub async fn get_export_jobs(
// ── Export download endpoints (authenticated guests) ─────────────────────────
+#[derive(Deserialize)]
+pub struct DownloadQuery {
+ pub ticket: String,
+}
+
+/// Mint a short-lived ticket for a browser-driven export download. The download
+/// is a top-level navigation so the multi-GB ZIP streams straight to disk instead
+/// of being buffered in memory by `fetch()` + `blob()` — but a navigation can't
+/// carry an `Authorization` header, so the client exchanges its Bearer token for
+/// an opaque ticket here, then hits `/export/zip?ticket=...`. Reuses the same
+/// single-use, 30s-TTL store as the SSE stream.
+pub async fn export_ticket(
+ State(state): State
,
+ auth: crate::auth::middleware::AuthUser,
+) -> Json {
+ let ticket = state.sse_tickets.issue(auth.token_hash);
+ Json(serde_json::json!({ "ticket": ticket }))
+}
+
+/// Validate a download ticket (single-use) and confirm its session still exists.
+async fn authenticate_download_ticket(state: &AppState, ticket: &str) -> Result<(), AppError> {
+ let token_hash = state
+ .sse_tickets
+ .consume(ticket)
+ .ok_or_else(|| AppError::Unauthorized("Ticket ungültig oder abgelaufen.".into()))?;
+ crate::models::session::Session::find_by_token_hash(&state.pool, &token_hash)
+ .await
+ .map_err(|e| AppError::Internal(e.into()))?
+ .ok_or_else(|| AppError::Unauthorized("Sitzung nicht gefunden.".into()))?;
+ Ok(())
+}
+
pub async fn download_zip(
State(state): State,
- _auth: crate::auth::middleware::AuthUser,
+ Query(q): Query,
headers: HeaderMap,
) -> Result {
+ authenticate_download_ticket(&state, &q.ticket).await?;
enforce_export_rate(&state, &headers).await?;
let event = crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug)
@@ -250,9 +283,10 @@ pub async fn download_zip(
pub async fn download_html(
State(state): State,
- _auth: crate::auth::middleware::AuthUser,
+ Query(q): Query,
headers: HeaderMap,
) -> Result {
+ authenticate_download_ticket(&state, &q.ticket).await?;
enforce_export_rate(&state, &headers).await?;
let event = crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug)
diff --git a/backend/src/handlers/social.rs b/backend/src/handlers/social.rs
index 4c96e7b..31aa2cd 100644
--- a/backend/src/handlers/social.rs
+++ b/backend/src/handlers/social.rs
@@ -43,11 +43,23 @@ pub async fn toggle_like(
.await?;
}
- // Broadcast SSE
- let _ = state.sse_tx.send(crate::state::SseEvent {
- event_type: "like-update".to_string(),
- data: serde_json::json!({ "upload_id": upload_id }).to_string(),
- });
+ // Fresh count so feed clients can patch the single card in place instead of
+ // refetching page 1 (mirrors v_feed.like_count = COUNT(DISTINCT user_id)). The
+ // count + broadcast are a UI optimisation — the like itself is already committed,
+ // so a failure here must not fail the request. Swallow the error and skip the
+ // broadcast; the next event or a pull-to-refresh reconciles the count.
+ if let Ok(like_count) = sqlx::query_scalar::<_, i64>(
+ "SELECT COUNT(DISTINCT user_id) FROM \"like\" WHERE upload_id = $1",
+ )
+ .bind(upload_id)
+ .fetch_one(&state.pool)
+ .await
+ {
+ let _ = state.sse_tx.send(crate::state::SseEvent {
+ event_type: "like-update".to_string(),
+ data: serde_json::json!({ "upload_id": upload_id, "like_count": like_count }).to_string(),
+ });
+ }
Ok(StatusCode::NO_CONTENT)
}
@@ -114,11 +126,24 @@ pub async fn add_comment(
.await?;
}
- // Broadcast SSE
- let _ = state.sse_tx.send(crate::state::SseEvent {
- event_type: "new-comment".to_string(),
- data: serde_json::json!({ "upload_id": upload_id }).to_string(),
- });
+ // Fresh count so feed clients can patch the single card in place instead of
+ // refetching page 1 (mirrors v_feed.comment_count = COUNT(DISTINCT c.id); COUNT(*)
+ // over the same deleted_at filter is identical since comment.id is the PK). The
+ // count + broadcast are a UI optimisation — the comment is already committed, so a
+ // failure here must not fail the request. Swallow the error and skip the broadcast.
+ if let Ok(comment_count) = sqlx::query_scalar::<_, i64>(
+ "SELECT COUNT(*) FROM comment WHERE upload_id = $1 AND deleted_at IS NULL",
+ )
+ .bind(upload_id)
+ .fetch_one(&state.pool)
+ .await
+ {
+ let _ = state.sse_tx.send(crate::state::SseEvent {
+ event_type: "new-comment".to_string(),
+ data: serde_json::json!({ "upload_id": upload_id, "comment_count": comment_count })
+ .to_string(),
+ });
+ }
let dto = CommentDto {
id: comment.id,
diff --git a/backend/src/main.rs b/backend/src/main.rs
index 5808c28..b6ff188 100644
--- a/backend/src/main.rs
+++ b/backend/src/main.rs
@@ -102,6 +102,7 @@ async fn main() -> Result<()> {
.route("/api/v1/host/comment/{id}", delete(handlers::host::host_delete_comment))
// Export (all authenticated users)
.route("/api/v1/export/status", get(handlers::admin::export_status))
+ .route("/api/v1/export/ticket", post(handlers::admin::export_ticket))
.route("/api/v1/export/zip", get(handlers::admin::download_zip))
.route("/api/v1/export/html", get(handlers::admin::download_html))
// Admin Dashboard
diff --git a/e2e/specs/06-export/export.spec.ts b/e2e/specs/06-export/export.spec.ts
index b0d3150..fd7fd47 100644
--- a/e2e/specs/06-export/export.spec.ts
+++ b/e2e/specs/06-export/export.spec.ts
@@ -46,11 +46,17 @@ test.describe('Export — release and download', () => {
const g = await guest('NoFile');
await db.setExportReleased(SLUG, true);
await db.fakeExportJob(SLUG, 'zip', 'done');
- // Real backend additionally checks event.export_zip_ready. The faked row is
- // enough for /status; the download path needs the boolean flag too.
- const res = await fetch((process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101') + '/api/v1/export/zip', {
+ const base = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
+ // Browser downloads stream straight to disk via a top-level navigation, so the
+ // download endpoint authenticates with a single-use ticket (no Bearer header).
+ const ticketRes = await fetch(base + '/api/v1/export/ticket', {
+ method: 'POST',
headers: { Authorization: `Bearer ${g.jwt}` },
});
+ const { ticket } = await ticketRes.json();
+ // Real backend additionally checks event.export_zip_ready. The faked row is
+ // enough for /status; the download path needs the boolean flag too.
+ const res = await fetch(base + '/api/v1/export/zip?ticket=' + encodeURIComponent(ticket));
// Either 404 ("not available" OR "file not found") — both are valid states for this setup.
expect([404, 200]).toContain(res.status);
});
diff --git a/e2e/specs/09-mobile/gestures-longpress.spec.ts b/e2e/specs/09-mobile/gestures-longpress.spec.ts
index 1497ddc..ab64e08 100644
--- a/e2e/specs/09-mobile/gestures-longpress.spec.ts
+++ b/e2e/specs/09-mobile/gestures-longpress.spec.ts
@@ -2,7 +2,7 @@
* Phase 3 mobile — long-press gesture.
*
* The `longpress` action attaches to `` in FeedListCard and to
- * grid cells in FeedGrid. Holding for ≥ 500 ms fires `onlongpress`,
+ * grid cells in VirtualFeed (grid mode). Holding for ≥ 500 ms fires `onlongpress`,
* which opens the ContextSheet bottom sheet via the feed page's
* `contextTarget` state.
*
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index e125a40..2a7439d 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -8,6 +8,7 @@
"name": "frontend",
"version": "0.0.1",
"dependencies": {
+ "@tanstack/svelte-virtual": "^3.13.30",
"idb": "^8.0.3",
"qrcode": "^1.5.4"
},
@@ -471,7 +472,6 @@
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
@@ -482,7 +482,6 @@
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
@@ -493,7 +492,6 @@
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
@@ -503,14 +501,12 @@
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
- "dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
@@ -991,7 +987,6 @@
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz",
"integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==",
- "dev": true,
"license": "MIT",
"peerDependencies": {
"acorn": "^8.9.0"
@@ -1029,7 +1024,6 @@
"integrity": "sha512-MdFRjevVxmAknf2NbaUkDF16jSIzXMWd4Nfah0Qp8TtQVoSp3bV4jKt8mX7z7qTUTWvgSaxtR0EG5WJf53gcuA==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@sveltejs/acorn-typescript": "^1.0.5",
@@ -1072,7 +1066,6 @@
"integrity": "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@sveltejs/vite-plugin-svelte-inspector": "^5.0.0",
"deepmerge": "^4.3.1",
@@ -1378,6 +1371,32 @@
"vite": "^5.2.0 || ^6 || ^7 || ^8"
}
},
+ "node_modules/@tanstack/svelte-virtual": {
+ "version": "3.13.30",
+ "resolved": "https://registry.npmjs.org/@tanstack/svelte-virtual/-/svelte-virtual-3.13.30.tgz",
+ "integrity": "sha512-Yj6FOrUhYZ48kaSrc/CqOWM57wAJivoyW+L3V9zgL7vtDOvFU02aOnvmcmd/9Qypl/V29+ySwEzKA7f1Rpv0Xw==",
+ "license": "MIT",
+ "dependencies": {
+ "@tanstack/virtual-core": "3.17.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "svelte": "^3.48.0 || ^4.0.0 || ^5.0.0"
+ }
+ },
+ "node_modules/@tanstack/virtual-core": {
+ "version": "3.17.2",
+ "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.2.tgz",
+ "integrity": "sha512-w43MvWvmShpb6kIC9MOoLyUkLmRTLPjt61bHWs+X29hACSpX+n8DvgZ3qM7cUfflKlRRcHR9KVJE6TmcqnQvcA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
"node_modules/@types/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
@@ -1389,7 +1408,6 @@
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
- "dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
@@ -1423,14 +1441,12 @@
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
- "dev": true,
"license": "MIT"
},
"node_modules/@typescript-eslint/types": {
"version": "8.58.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.0.tgz",
"integrity": "sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==",
- "dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -1444,9 +1460,7 @@
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
- "dev": true,
"license": "MIT",
- "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -1482,7 +1496,6 @@
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
"integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==",
- "dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">= 0.4"
@@ -1492,7 +1505,6 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
- "dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">= 0.4"
@@ -1538,7 +1550,6 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -1612,7 +1623,6 @@
"version": "5.6.4",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz",
"integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==",
- "dev": true,
"license": "MIT"
},
"node_modules/dijkstrajs": {
@@ -1687,14 +1697,12 @@
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
"integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
- "dev": true,
"license": "MIT"
},
"node_modules/esrap": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.4.tgz",
"integrity": "sha512-suICpxAmZ9A8bzJjEl/+rLJiDKC0X4gYWUxT6URAWBLvlXmtbZd5ySMu/N2ZGEtMCAmflUDPSehrP9BQcsGcSg==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.4.15",
@@ -1835,7 +1843,6 @@
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
"integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.6"
@@ -2126,7 +2133,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
- "dev": true,
"license": "MIT"
},
"node_modules/locate-path": {
@@ -2145,7 +2151,6 @@
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
@@ -2384,7 +2389,6 @@
"integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@types/estree": "1.0.8"
},
@@ -2518,9 +2522,7 @@
"version": "5.55.1",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.1.tgz",
"integrity": "sha512-QjvU7EFemf6mRzdMGlAFttMWtAAVXrax61SZYHdkD6yoVGQ89VeyKfZD4H1JrV1WLmJBxWhFch9H6ig/87VGjw==",
- "dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@jridgewell/remapping": "^2.3.4",
"@jridgewell/sourcemap-codec": "^1.5.0",
@@ -2621,7 +2623,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
- "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -2643,7 +2644,6 @@
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -2798,7 +2798,6 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
- "dev": true,
"license": "MIT"
}
}
diff --git a/frontend/package.json b/frontend/package.json
index 8bb9054..f2152df 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -25,6 +25,7 @@
"vite": "^7.3.1"
},
"dependencies": {
+ "@tanstack/svelte-virtual": "^3.13.30",
"idb": "^8.0.3",
"qrcode": "^1.5.4"
}
diff --git a/frontend/src/app.css b/frontend/src/app.css
index 9cc6609..e3d58fb 100644
--- a/frontend/src/app.css
+++ b/frontend/src/app.css
@@ -1 +1,15 @@
@import "./tailwind-theme.css";
+
+/* Respect the OS "reduce motion" setting. Collapses every animation/transition
+ * (HeartBurst, Skeleton pulse, diashow cross-fades, sheet/FAB slides) to a
+ * near-instant change rather than removing them outright, so state still updates. */
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.01ms !important;
+ scroll-behavior: auto !important;
+ }
+}
diff --git a/frontend/src/app.html b/frontend/src/app.html
index 7f3fa72..87b0e0c 100644
--- a/frontend/src/app.html
+++ b/frontend/src/app.html
@@ -1,10 +1,21 @@
-
+
-
+
+
-
+
+
+
+
+
+
+
+
+
+
+{#if open}
+
+{/if}
+
@@ -120,9 +109,10 @@