fix(camera): get the upload sheet out from in front of the shutter button
Some checks failed
Audit / cargo audit (backend) (push) Failing after 12m25s
Audit / npm audit (frontend) (push) Failing after 23m21s
Checks / Backend — cargo test + clippy + fmt (push) Failing after 55s
Checks / Frontend — vitest + svelte-check (push) Failing after 42m2s
Checks / E2E — typecheck + lint (push) Failing after 20m58s
E2E / Playwright E2E (chromium + webkit) (push) Failing after 19m54s
E2E / Cross-UA smoke matrix (push) Failing after 20m27s

Tapping "Kamera" opened the viewfinder with the Galerie/Kamera sheet still sitting over
the bottom of it, covering the capture controls. The sheet is `fixed`, so it could not
be scrolled out of the way: the only route to the shutter was the phone's back button,
which is not a discoverable step and is one most guests would read as "the camera is
broken".

Two independent causes, both fixed, because either one alone leaves a gap.

The sheet never closed. It stays mounted for its translate-y animation and nothing told
it the camera had taken over, so it kept its panel, its backdrop and its `aria-modal`
while a full-screen overlay was up. `CameraCapture` now reports when its preview is
live and the sheet dismisses itself on that signal.

Deliberately on the preview, not on the tap. Closing when "Kamera" is pressed would
dismiss the sheet before we know the camera works at all — and it often does not: a
denied permission, no camera, or any non-secure context (where `navigator.mediaDevices`
is simply absent) all end at the error panel. Closing early would leave the guest
looking at that error with nothing behind it. Gated on `loadedmetadata`, the sheet is
still there when the camera fails, so "Schließen" returns them to where they were. The
signal is one-shot, because flipping the lens or switching photo/video re-acquires the
stream and re-announcing "ready" would ask the caller to redo a dismissal it has
already done.

And the stacking was ambiguous. Both elements were `z-50` and the sheet is rendered
after the camera, so it won on paint order. The overlay moves to `z-[60]` — the tier
the Toaster already occupies, so toasts still surface above the viewfinder on DOM
order. This is the part that holds regardless of timing: the controls are now reachable
during the permission prompt and on the error panel, before anything has been
dismissed.

Focus follows the same reasoning. When the camera closes the sheet, restoring focus
immediately would put it on the FAB *behind* the overlay, where a Tab could walk the
page underneath; it is restored when the overlay goes away instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Fabian Hamm (Privat)
2026-08-04 19:38:49 +02:00
parent 0ae5a64e77
commit d5b4bf0ac1
2 changed files with 71 additions and 10 deletions

View File

@@ -5,9 +5,29 @@
interface Props {
oncapture: (blob: Blob, type: 'photo' | 'video') => void;
onclose: () => void;
/// Fired once, when the live preview is actually on screen — not when the camera was
/// merely requested. The caller uses it to dismiss whatever launched the camera.
///
/// Gated on the preview rather than on `getUserMedia` resolving, because the two differ
/// exactly where it matters: if permission is denied, or the page is not a secure context,
/// this never fires and the caller's UI stays put behind the error panel — so "Schließen"
/// returns the guest to where they were instead of dropping them somewhere they never
/// asked to be.
onready?: () => void;
}
let { oncapture, onclose }: Props = $props();
let { oncapture, onclose, onready }: Props = $props();
/// `onready` is a one-shot. `startCamera` re-runs on every lens flip and photo/video switch,
/// and re-announcing "the camera is ready" mid-session would ask the caller to redo a
/// dismissal it has already done.
let announcedReady = false;
function handlePreviewLive() {
if (announcedReady) return;
announcedReady = true;
onready?.();
}
let videoEl: HTMLVideoElement = $state()!;
let canvasEl: HTMLCanvasElement = $state()!;
@@ -157,7 +177,14 @@
}
</script>
<div class="fixed inset-0 z-50 flex flex-col bg-black">
<!-- z-[60], above the z-50 upload sheet that launches this. The sheet stays mounted for its
translate-y animation and is rendered AFTER this element, so at an equal z-index it painted
on top: the shutter button sat underneath the Galerie/Kamera panel and the only way to reach
it was the phone's back button. The sheet also dismisses itself once the preview is live
(see `onready`), but that is a behaviour; this is the guarantee — the capture controls are
reachable during the permission prompt and on the error panel too, when nothing has been
dismissed yet. -->
<div class="fixed inset-0 z-[60] flex flex-col bg-black">
<!-- Camera preview -->
<div class="relative flex-1 overflow-hidden">
{#if error}
@@ -197,6 +224,7 @@
autoplay
playsinline
muted
onloadedmetadata={handlePreviewLive}
class="h-full w-full object-cover {facingMode === 'user' ? 'scale-x-[-1]' : ''}"
></video>

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import { untrack } from 'svelte';
import { goto } from '$app/navigation';
import { uploadSheetOpen, uploadBadgeCount } from '$lib/ui-store';
import { pendingFiles } from '$lib/pending-upload-store';
@@ -56,6 +57,16 @@
}
}
function restoreFocus() {
if (!returnFocus) return;
try {
returnFocus.focus({ preventScroll: true });
} catch {
/* element gone */
}
returnFocus = null;
}
$effect(() => {
if (open) {
returnFocus = (document.activeElement as HTMLElement | null) ?? null;
@@ -66,13 +77,14 @@
});
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
} else if (returnFocus) {
try {
returnFocus.focus({ preventScroll: true });
} catch {
/* element gone */
}
returnFocus = null;
} else if (untrack(() => showCamera)) {
// The camera closed the sheet from under us. Restoring focus now would put it on the
// FAB *behind* a full-screen overlay, where a Tab could then walk the page underneath;
// `handleCameraClose` does it once the overlay is gone. `untrack` because this branch
// must not resubscribe the effect to `showCamera` — that would re-run the open branch
// when the camera mounts and re-capture `returnFocus` as the sheet's own Kamera button.
} else {
restoreFocus();
}
});
@@ -116,14 +128,35 @@
await goto('/upload');
}
// The camera preview is up, so the sheet has done its job — dismiss it. Leaving it open put
// the Galerie/Kamera panel over the shutter button, and since it is `fixed` it could not be
// scrolled away: the only escape was the phone's back button.
//
// Deliberately driven by the camera's own ready signal rather than by the tap on "Kamera".
// Closing on the tap would dismiss the sheet before we know the camera will work at all, so a
// denied permission — or an insecure context, where `getUserMedia` does not exist — would
// leave the guest looking at an error panel with nothing behind it. This way the sheet is
// still there if the camera never opens, and `handleCameraClose` returns them to it.
function handleCameraReady() {
close();
}
function handleCameraClose() {
showCamera = false;
// `close()` normally restores focus to whatever opened the sheet, but when the camera
// dismissed it that element was behind a full-screen overlay and the restore was skipped.
// Do it now that the overlay is gone, so focus never ends up on <body>.
restoreFocus();
}
</script>
<!-- Camera (rendered outside sheet so it gets full viewport) -->
{#if showCamera}
<CameraCapture oncapture={handleCapture} onclose={handleCameraClose} />
<CameraCapture
oncapture={handleCapture}
onclose={handleCameraClose}
onready={handleCameraReady}
/>
{/if}
<!-- Hidden file input -->