fix(ui): four small bugs surfaced after the UI/UX merge

1. Settings gear was unclickable because H1's `drop-shadow` filter
   created a stacking context that painted the H1 over the absolutely-
   positioned trigger. Bump gear wrapper to z-10.
   (src/routes/+page.svelte)

2. Switch rows in settings had a dead zone — `<label>` wrapped a
   `<span role=switch>` (not a labelable element), so taps on the
   text fell through. Move role/click/keydown to the wrapping div
   so the whole row toggles. Pill stays decorative.
   (src/lib/SettingsButton.svelte)

3. Empty cells used `dark:[filter:brightness(...)]` which creates a
   per-cell stacking context — same hazard class as bug #1. Replace
   with a pointer-events-none overlay span (`hidden dark:block`).
   (src/lib/PlayerBoard.svelte)

4. Audio cache never evicted on voice swap, leaking ~92 Audio
   elements per change over a session. New `clearAudioCache()` in
   voice.js, called from `pickVoice` when the id actually changes.
   (src/lib/voice.js, src/lib/SettingsButton.svelte)

Tests 98/98 pass; build clean.
This commit is contained in:
tiennm99 committed 2026-04-27 09:51:49 +07:00
1 parent 7a6a19f242
commit 7d1bb5e68a
4 files changed
+43 -19

No files matched your search

+10 -2
View File
@@ -222,11 +222,19 @@
{@const rowComplete = hasNumber && rowCompleteness[row]}
{#if !hasNumber}
<!-- Empty cell. Dark-mode dim is an overlay div, not a CSS
filter, so we don't accidentally create a stacking
context on every cell. -->
<div
aria-hidden="true"
class="relative aspect-[3/4] sm:aspect-[3/5] border border-slate-400/50 dark:border-slate-600/40 dark:[filter:brightness(0.85)_saturate(0.9)]"
class="relative aspect-[3/4] sm:aspect-[3/5] border border-slate-400/50 dark:border-slate-600/40"
style:background-color="var(--empty-cell-bg)"
></div>
>
<span
aria-hidden="true"
class="hidden dark:block absolute inset-0 bg-black/15 pointer-events-none"
></span>
</div>
{:else}
<button
type="button"
+19 -15
View File
@@ -5,6 +5,7 @@
saveSettings,
settings,
} from "$lib/settings-store.svelte.js";
import { clearAudioCache } from "$lib/voice.js";
let open = $state(false);
@@ -79,6 +80,7 @@
/** @param {string} id */
function pickVoice(id) {
if (settings.voice !== id) clearAudioCache();
settings.voice = id;
saveSettings();
}
@@ -182,26 +184,28 @@
</fieldset>
{#snippet switchRow(label, isOn, onToggle)}
<label
<!-- Whole row is the switch — role/tabindex/click here, not on the
inner pill, so tapping the label text also toggles. -->
<div
role="switch"
tabindex="0"
aria-checked={isOn}
onclick={onToggle}
onkeydown={(/** @type {KeyboardEvent} */ e) => {
if (e.key === " " || e.key === "Enter") {
e.preventDefault();
onToggle();
}
}}
class="flex items-center justify-between gap-3 px-3 py-2 rounded-lg
border-2 border-slate-200 dark:border-slate-600 cursor-pointer
hover:border-slate-300 dark:hover:border-slate-500 transition-colors"
hover:border-slate-300 dark:hover:border-slate-500 transition-colors
focus:outline-none focus:ring-2 focus:ring-indigo-400"
>
<span class="text-sm text-slate-700 dark:text-slate-200">{label}</span>
<span
role="switch"
tabindex="0"
aria-checked={isOn}
onclick={onToggle}
onkeydown={(/** @type {KeyboardEvent} */ e) => {
if (e.key === " " || e.key === "Enter") {
e.preventDefault();
onToggle();
}
}}
aria-hidden="true"
class="relative inline-block w-10 h-6 rounded-full transition-colors flex-none
focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-400
focus:ring-offset-white dark:focus:ring-offset-slate-800
{isOn ? 'bg-emerald-500' : 'bg-slate-300 dark:bg-slate-600'}"
>
<span
@@ -209,7 +213,7 @@
{isOn ? 'translate-x-4' : 'translate-x-0'}"
></span>
</span>
</label>
</div>
{/snippet}
<!-- Master mode -->
+9
View File
@@ -39,6 +39,15 @@ function clipUrl(name) {
return `${base}/audio/${settings.voice}/${name}.mp3`;
}
/**
* Drop all cached <audio> elements. Call when the active voice changes
* so the old voice's clips don't leak across a long session.
*/
export function clearAudioCache() {
cancelPlayback();
cache.clear();
}
export function cancelPlayback() {
if (!isBrowser()) return;
if (activeClip) {
+5 -2
View File
@@ -12,7 +12,10 @@
>
<div class="w-full max-w-2xl">
<header class="relative text-center mb-6">
<div class="absolute right-0 top-0">
<!-- z-10: H1 has a CSS `drop-shadow` filter which creates a stacking
context. Without z-index here, that filtered H1 (later in tree
order) paints over the gear button and swallows clicks. -->
<div class="absolute right-0 top-0 z-10">
<SettingsButton />
</div>
<h1
@@ -23,7 +26,7 @@
<p
class="text-[0.65rem] sm:text-xs uppercase tracking-[0.3em] text-slate-500 dark:text-slate-400 italic mt-1"
>
Hội chợ Tân Tân
Hội chợ TN1
</p>
</header>