214f9e306256f3baa465eaaff607e3c080318474
10 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
253878e027 |
fix(export): give the keepsake viewer the two-phase preflight it was meant to get
The two-phase preflight from
|
||
|
|
eb0e405562 |
fix: gate uploads on keepsake headroom, and close five unattended-event gaps
The box is 2 vCPU / 4 GB / 40 GB, not the 4 vCPU / 8 GB / 80 GB that the audit, the committed comments and README's sizing section all assumed. That correction is what the first change is about; the rest are the remaining pre-event items. THE ARCHIVE COULD BECOME UNBUILDABLE WHILE UPLOADS KEPT SUCCEEDING `required_free_bytes` is `media × 1.1 × 2` — the ZIP and the HTML viewer are each gallery-sized — and the export preflight also wants DISK_RESERVE_BYTES on top. The upload gate, though, only refused below a FLAT 10 GB reserve. On 40 GB that let uploads run to ~25 GB of media while a release needed `2.2 × 25 + 10` = 65 GB free. Every upload in that band succeeded and the keepsake could then never be built: the product's entire promise, failing silently at the end of the night with nobody there. The gate now enforces the invariant that actually matters — never accept an upload that would make the keepsake unbuildable — sharing `required_free_bytes` with the preflight so the two cannot drift into disagreeing about the same question. Uploads stop at ~8 GB of media on this disk, with a German message naming the cause. Refusing the 1001st photo beats losing all 1000. `media_total.rs` backs it: SUM(user.total_upload_bytes) over ~100 rows, cached 5s, rather than `estimate_export_bytes`'s join across every upload. It counts hidden and banned users' bytes, which the export excludes — skew in the SAFE direction, so the gate closes marginally early rather than late. Fails open on a query error. A test pins the gate against the preflight across the whole gallery-size range, and a second asserts the per-user floor alone would over-commit the volume — i.e. that the global gate is what must bind. THE WATCHDOG ABORTED HEALTHY UPLOADS EVERY TIME A PHONE WAS POCKETED `Date.now()` advances while a backgrounded phone is frozen but `setInterval` does not, so the first tick after a screen lock read the whole sleep as silence and aborted — re-sending a video from byte zero and burning one of five PERMANENT auto-attempts. The interval is now its own suspension detector: a tick that arrives 125s late for a 5s schedule credits that window back, because a period the watchdog could not observe is not evidence of silence. Chosen over a `visibilitychange` listener, which only covers causes that fire that event — a throttled-but-visible tab, a closed lid and an occluded window all freeze timers without one — and which would have needed module state, an SSR guard and a teardown for strictly less coverage. `performance.now()` was rejected because Safari pauses it across system sleep on some paths and Chrome does not. The credit buys one fresh window, not immunity: a socket iOS reaped while backgrounded still aborts ~90s after resume rather than hanging for `xhr.timeout` (5-60 min) with the queue's `processing` latch held. Two latent leaks found while in there: `xhr.abort()` on a request already in readyState DONE emits no `abort` event, so `settle()` never ran and the interval re-aborted every 5s forever while `activeUploads` kept a stale entry (the ✕ button silently stopped working); and a synchronous throw from `xhr.send` — a blob whose backing store the OS purged — leaked the same way. Both closed. OKLCH MADE THE DELETE BUTTON INVISIBLE ON SAMSUNG'S DEFAULT BROWSER red/amber/green were never in the @theme block and fell through to Tailwind v4's `oklch()` defaults, which Safari <15.4, Chrome <111 and Samsung Internet <22 cannot parse: `var(--color-red-600)` is then invalid at computed-value time, `background-color` falls back to transparent, and `.btn-danger` renders white text on nothing. Pinned to Tailwind's own defaults gamut-mapped to sRGB by Lightning CSS — the converter already in this pipeline — so modern browsers render exactly what they render today. Verified against seven hex fallbacks it had already emitted for the /alpha forms. rose and teal (avatar chips) had the same leak. The app CSS goes from 40 oklch declarations to 0. Also fixes `--color-purple-950`, which was simply missing: `dark:bg-purple-950/50` on the host dashboard was rendering default violet on EVERY browser, off-brand. The keepsake viewer only picks this up on a rebuild, so its committed artefact is rebuilt here too — still single-file, still zero external references. A BRICKED BOOT LOOKED LIKE A SPINNER FOREVER With `ssr = false` the page is empty until the bundle mounts, so a chunk 404 after a redeploy or a dead uplink left the guest on the boot spinner with no message, no reload control, and in a standalone PWA no URL bar. A 15s timeout in the existing nonce'd IIFE (no CSP change) swaps in German copy and a reload button. Deliberately a timeout rather than feature detection: a SyntaxError in the bundle is invisible to any capability check. Plus a <noscript>, since there was nothing at all to see without JS. EVERY 4xx WAS INVISIBLE AT ANY LOG LEVEL tower_http counts 4xx as a success, so it logs at DEBUG while production runs at info. If guests spend the evening hitting 429s or 413s, the post-event logs said nothing. Now one WARN per client error; 5xx excluded because Internal already logs its source chain and the pool-exhaustion 503 logs at construction. A DEAD FRONTEND SERVED A BLANK 502 `handle_errors 5xx` with an inline German page (the caddy service mounts only the Caddyfile, so there is no volume to ship a static file through). Verified empirically against this config, not from documentation: an upstream 404 through `reverse_proxy` still arrives as untouched `application/json`, and only a dial failure renders the page. That mattered — the keepsake download navigates a hidden iframe and DEPENDS on a real 404/429 arriving, and swallowing those would have been worse than the blank 502. CONFIG CORRECTIONS FOR THE REAL HARDWARE DATABASE_MAX_CONNECTIONS 30 → 15: sized to 2 vCPU rather than to the guest count. Since migration 024 a feed page costs well under a millisecond, so connections are no longer spent waiting, and 30 backends crowd the db container's 1 GB on a 4 GB host. COMPRESSION_WORKER_CONCURRENCY stays at 2 — the merged heavy-image permit already serialises anything over 150 MiB, so the "two 48 MP photos" worst case that number was sized against is unreachable; dropping to 1 would halve light-path throughput and push more feed tiles onto full-size originals. README's sizing section rewritten for the actual disk. Verified: 149/149 backend tests against a live Postgres, clippy clean, 57/57 vitest, svelte-check 0 errors, eslint clean, vite build, export-viewer rebuild, caddy validate, compose YAML parse. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
57a907eca5 |
feat(theme+comments): runtime colour theme and COMMENTS_ENABLED switch
Colour theme is configurable at runtime from two seed colours (brand + accent); neutrals stay fixed for contrast safety. Tailwind v4 var()-based tokens let a :root:root override recolour everything with no rebuild; the 50->950 ramps are derived via a color-mix ladder. Config lives in the DB config table (admin UI: Config > Farbschema, presets + custom pickers + live preview), served on the public /event endpoint with env defaults (THEME_PRESET/PRIMARY/ACCENT), propagated live via event-updated SSE, and cached in localStorage for a no-flash boot. The keepsake export mirrors the same ladder in Rust so offline archives match the event theme. COMMENTS_ENABLED (env, default true) is a boot-time kill-switch: the backend rejects new comments with 403 and the frontend hides the comment button (feed card) and panel/composer (lightbox). Existing comments stay in the DB, hidden, and return when re-enabled. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
f243bfe89a |
feat(ui): elegant wedding white/silver/gold redesign
Reskin the whole app via design tokens in tailwind-theme.css (remapped color ramps) plus a define-once component layer in lib/styles/components.css (.btn, .card, .input, .chip, .badge, .sheet, …). Buttons are muted/outlined gold rather than flat fills. Self-host Inter + Fraunces (woff2) under the existing font-src 'self' CSP. Restyle the shared components and the account, admin, host, join, recover and upload screens against the new tokens. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
15d338eeb8 |
fix(frontend): render client-side (ssr=false) to close pre-hydration interaction races
EventSnap is a client-only SPA: auth is a localStorage JWT and every page fetches its data in onMount, so SSR only ever produced a logged-out skeleton — including interactive controls that exist in the DOM before hydration attaches their handlers. Interacting in that window silently no-ops: a caption typed on /upload never reaches the reactive state (so cancel() navigates away instead of confirming), and a click on /account's custom role=radio / leave button does nothing. This surfaced as a ~1-4% mobile e2e flake and is a real (if brief) bug for users on slow connections too. Disable SSR app-wide (csr stays on). The server now ships a ~2.5 KB shell with no page controls, so the pre-hydration window is structurally impossible. No SEO is lost (private, QR-gated app). app.html paints a themed boot spinner to cover the JS-load gap (script-free; inline <style> is CSP-safe via style-src 'unsafe-inline'); the root layout's onMount removes it once the app paints. Also fixes the one regression ssr=false exposed: /recover's back chevron used `cameFromApp = from !== null`, which assumes SSR semantics (cold load => from is null). In CSR mode the initial navigation reports a non-null `from`, sending history.back() to about:blank. Key on `type !== 'enter'` instead — SvelteKit's initial-load marker in both SSR and CSR modes. Verified: desktop 210 passed, mobile 22 passed, back-chevron 10/10, frontend gates green (svelte-check 0 errors, eslint, prettier, vitest 46). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
f8cba95e49 |
chore(frontend): add ESLint + Prettier; fix real findings; format the tree
The frontend had no JS/TS linter — only svelte-check. Add flat-config ESLint (typescript-eslint
+ eslint-plugin-svelte) and Prettier (tabs/single-quote, matching the existing style).
Rules encode "catch bugs, not enforce taste":
- svelte/require-each-key KEPT — it is the exact bug class as the feed mis-tap fix. Fixed every
flagged block: keyed activeFilters, filteredUsers, stagedFiles (by previewUrl), captionTags,
admin tabs/jobs/users, the export-viewer suggestions/filters/comments, and the static skeleton
loops.
- svelte/prefer-svelte-reactivity KEPT — inline-disabled only the verified-safe sites (a local
freq Map in a $derived.by, throwaway URLSearchParams query builders), with a reason each.
- svelte/no-navigation-without-resolve OFF — wants resolve() around every goto()/href; taste, not
a bug, and pure churn.
- svelte/no-unused-svelte-ignore OFF — those comments are consumed by svelte-check, which ESLint
can't see, so it wrongly calls them unused; removing them would reintroduce a11y warnings.
- no-explicit-any OFF for *.test.ts only (partial fixtures legitimately use any).
Real code fixes beyond keys: removed a dead jobLabel(), an unused ViewerComment import and unused
catch binding, an unused scroll-lock arg, and replaced an empty interface with a type alias.
Then `prettier --write` (54 files). Formatting only. Verified: eslint clean, svelte-check 0 errors,
vitest 46 passed, vite build succeeds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
a084ba86c5 |
fix(review): CSP nonce for FOUC script + feed_delta truncation signal
Two items surfaced by the branch re-review: CSP regression (blocking): script-src 'self' blocked the template-authored anti-FOUC theme script in app.html — SvelteKit's mode:'auto' only hashes scripts it injects. Added nonce="%sveltekit.nonce%" so it's substituted per request and included in the CSP (survives future edits, unlike a pinned hash). Verified: emitted CSP nonce matches the script tag; no violation, no flash. feed_delta silent truncation: the LIMIT 200 (added earlier to kill the stale-`since` DoS) returned only the newest slice with no signal, so a client that missed 200+ uploads during a reconnect could not tell it should full- refresh — the older missed uploads were dropped and unrecoverable (the next delta advances `since` past them). DeltaResponse now carries `truncated`; the feed's feed-delta handler calls loadFeed(true) to resync from page 1 instead of merging a partial slice. Verified: cargo check clean, svelte-check 0 errors, production build green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
251f9f1469 |
frontend(plumbing): shared theme tokens, cross-cutting stores, gestures, sheets
The plumbing layer the v0.16 UI features (and dark mode) build on.
Shared design tokens (Tailwind v4):
- tailwind-theme.css (new): @custom-variant dark (class-driven, beats OS
default) + @theme color/font/radius tokens + baseline html/html.dark
rules so any page that hasn't been re-themed still renders the right
body bg + color-scheme.
- src/app.css + export-viewer/src/app.css now import the shared theme.
- src/app.html: 6-line FOUC guard sets <html class="dark"> before paint
(mirrored from theme-store.ts) so dark reloads no longer flash white.
Adds <meta name="theme-color"> kept in sync by initTheme().
Cross-cutting stores (one per concern, per docs/FEATURES §2.9):
- data-mode-store.ts: 'saver' | 'original' per-device, plus pickMediaUrl
helper so feed cards / lightbox / diashow all resolve URLs the same way.
- privacy-note-store.ts: hydrated from /me/context, refreshed on SSE
event-updated.
- quota-store.ts: { enabled, used, limit, active_uploaders, free_disk },
refreshed after each upload completes.
- theme-store.ts: 'system' | 'light' | 'dark' preference + derived
appliedTheme + initTheme() that syncs <html class>, localStorage,
and the theme-color meta. Listens to prefers-color-scheme.
- auth.ts: currentPin writable mirror + clearPin() helper called from
the global pin-reset SSE handler — fixes the stale-PIN bug where the
localStorage copy survived a reset.
DTO mirror:
- types.ts: QuotaDto, MeContextDto, PinResetResponse, DeltaResponse each
carry a `// mirrors backend/...` comment per the lib README convention.
SSE client:
- sse.ts: KNOWN_EVENTS registry (one entry per server-emitted type),
synthetic feed-delta dispatched after foreground reconnect via the
/feed/delta?since= endpoint, exponential backoff (1 → 60 s + jitter)
on errors, attempt counter reset on user-initiated visibility resume.
Upload queue:
- upload-queue.ts: IDB schema bumped to v2 — entries tagged with userId;
loadQueue filters by current user (no cross-user leak on shared
devices); uploadItem refuses to upload an entry whose userId differs
from getUserId() (defense-in-depth); new clearQueue() called on
explicit logout. v2 upgrade wipes pre-v2 entries (no userId, can't
attribute safely).
Mobile primitives:
- actions/longpress.ts: 500 ms hold with 10 px move tolerance, swallows
the next click + the right-click contextmenu so the gesture doesn't
double-fire the inner button's onclick.
- actions/doubletap.ts: tap-pair detector that preventDefaults the
second tap so iOS Safari doesn't also zoom on double-tap.
- components/ContextSheet.svelte: generic bottom sheet driven by a
ContextAction[] prop. Reused by feed posts, comments, host user rows.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
b89b1d6ffa |
chore: scaffold monorepo for EventSnap
- Rust/Axum backend skeleton with all crates, multi-stage Dockerfile - SvelteKit + TypeScript frontend with Tailwind CSS v4, adapter-node, Dockerfile - docker-compose.yml: db (postgres:16) → app → frontend → caddy with healthcheck and named volumes - Caddyfile: TLS via Let's Encrypt, cache headers, API/media routing to backend - .env.example: all environment variables documented with defaults - README.md: project overview, features, stack, deploy guide, roadmap - .gitignore: excludes secrets, build artifacts, node_modules, media uploads Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> |