Add WCAG 2.1 Level AA accessibility across all components

This commit is contained in:
2026-02-07 12:10:10 +02:00
parent 9b9147c737
commit 8f7da8cd4a
18 changed files with 459 additions and 226 deletions

View File

@@ -4,6 +4,7 @@
import { timer, currentView, formatTime, type TimerSnapshot } from "../stores/timer";
import { config } from "../stores/config";
import TimerRing from "./TimerRing.svelte";
import { onMount } from "svelte";
import { scaleIn, fadeIn, pressable } from "../utils/animate";
import { pickRandomActivity, getCategoryIcon, getCategoryLabel, type BreakActivity } from "../utils/activities";
@@ -70,14 +71,36 @@
);
const isModal = $derived(!$config.fullscreen_mode && !standalone);
// Focus trap: keep Tab cycling within break screen
let breakContainer = $state<HTMLElement>(undefined!);
$effect(() => {
if (!breakContainer) return;
function trapFocus(e: KeyboardEvent) {
if (e.key !== "Tab") return;
const focusable = breakContainer.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey) {
if (document.activeElement === first) { e.preventDefault(); last.focus(); }
} else {
if (document.activeElement === last) { e.preventDefault(); first.focus(); }
}
}
breakContainer.addEventListener("keydown", trapFocus);
return () => breakContainer.removeEventListener("keydown", trapFocus);
});
</script>
{#if standalone}
<!-- ── Standalone break window: horizontal card, transparent background ── -->
<div class="standalone-card" use:scaleIn={{ duration: 0.5 }}>
<div class="standalone-card" use:scaleIn={{ duration: 0.5 }} bind:this={breakContainer}>
<!-- Ripples emanate from the ring, visible outside the card -->
<div class="standalone-ring-area">
<div class="ripple-container">
<div class="ripple-container" aria-hidden="true">
<div class="break-ripple ripple-1" style="--ripple-color: {$config.break_color}"></div>
<div class="break-ripple ripple-2" style="--ripple-color: {$config.break_color}"></div>
<div class="break-ripple ripple-3" style="--ripple-color: {$config.break_color}"></div>
@@ -88,6 +111,8 @@
size={140}
strokeWidth={5}
accentColor={$config.break_color}
label="Break timer"
valueText="{formatTime($timer.breakTimeRemaining)} remaining"
>
<div class="break-breathe-counter">
<span
@@ -103,19 +128,19 @@
<!-- Right side: text + buttons -->
<div class="standalone-content">
<h2 class="text-[17px] font-medium text-white mb-1.5">
<h2 class="text-[17px] font-medium text-white mb-1.5" tabindex="-1">
{$timer.breakTitle}
</h2>
<p class="text-[12px] leading-relaxed text-[#666] mb-4 max-w-[240px]">
<p class="text-[12px] leading-relaxed text-[#8a8a8a] mb-4 max-w-[240px]">
{$timer.breakMessage}
</p>
{#if $config.show_break_activities}
<div class="rounded-xl border border-[#ffffff08] bg-[#ffffff06] px-4 py-2.5 mb-4 max-w-[260px]">
<div class="text-[9px] font-medium tracking-[0.15em] text-[#555] uppercase mb-1">
<div class="text-[9px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase mb-1">
{getCategoryLabel(currentActivity.category)}
</div>
<p class="text-[12px] leading-relaxed text-[#999]">
<p class="text-[12px] leading-relaxed text-[#999]" aria-live="polite">
{currentActivity.text}
</p>
</div>
@@ -126,9 +151,9 @@
<button
use:pressable
class="rounded-full border border-[#333] px-5 py-2 text-[11px]
tracking-wider text-[#666] uppercase
tracking-wider text-[#8a8a8a] uppercase
transition-colors duration-200
hover:border-[#444] hover:text-[#aaa]"
hover:border-[#444] hover:text-[#ccc]"
onclick={cancelBreak}
>
{cancelBtnText}
@@ -147,7 +172,7 @@
{/if}
</div>
{#if $config.snooze_limit > 0}
<p class="mt-2 text-[9px] text-[#333]">
<p class="mt-2 text-[9px] text-[#8a8a8a]">
{$timer.snoozesUsed}/{$config.snooze_limit} snoozes used
</p>
{/if}
@@ -155,7 +180,7 @@
</div>
<!-- Bottom progress bar with clip-path -->
<div class="standalone-progress-container">
<div class="standalone-progress-container" role="progressbar" aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round($timer.breakProgress * 100)} aria-label="Break progress">
<div class="standalone-progress-track">
<div
class="h-full transition-[width] duration-1000 ease-linear"
@@ -170,6 +195,7 @@
<div
class="relative h-full flex items-center justify-center"
style="background: #000;"
bind:this={breakContainer}
>
<div
class="relative flex flex-col items-center"
@@ -177,7 +203,7 @@
>
<!-- Break ring with breathing pulse + ripples -->
<div class="mb-6 relative" use:scaleIn={{ duration: 0.6, delay: 0.1 }}>
<div class="absolute inset-0 flex items-center justify-center pointer-events-none">
<div class="absolute inset-0 flex items-center justify-center pointer-events-none" aria-hidden="true">
<div class="break-ripple ripple-1" style="--ripple-color: {$config.break_color}; --ripple-size: {isModal ? 160 : 200}px"></div>
<div class="break-ripple ripple-2" style="--ripple-color: {$config.break_color}; --ripple-size: {isModal ? 160 : 200}px"></div>
<div class="break-ripple ripple-3" style="--ripple-color: {$config.break_color}; --ripple-size: {isModal ? 160 : 200}px"></div>
@@ -189,6 +215,8 @@
size={isModal ? 160 : 200}
strokeWidth={isModal ? 5 : 6}
accentColor={$config.break_color}
label="Break timer"
valueText="{formatTime($timer.breakTimeRemaining)} remaining"
>
<div class="break-breathe-counter">
<span
@@ -204,12 +232,12 @@
</div>
</div>
<h2 class="mb-2 text-lg font-medium text-white" use:fadeIn={{ delay: 0.25, y: 10 }}>
<h2 class="mb-2 text-lg font-medium text-white" tabindex="-1" use:fadeIn={{ delay: 0.25, y: 10 }}>
{$timer.breakTitle}
</h2>
<p
class="max-w-[300px] text-center text-[13px] leading-relaxed text-[#555]"
class="max-w-[300px] text-center text-[13px] leading-relaxed text-[#8a8a8a]"
class:mb-4={$config.show_break_activities}
class:mb-8={!$config.show_break_activities}
use:fadeIn={{ delay: 0.35, y: 10 }}
@@ -222,10 +250,10 @@
class="mb-8 mx-auto max-w-[320px] rounded-2xl border border-[#1a1a1a] bg-[#0a0a0a] px-5 py-3.5 text-center"
use:fadeIn={{ delay: 0.4, y: 10 }}
>
<div class="mb-1.5 text-[10px] font-medium tracking-[0.15em] text-[#444] uppercase">
<div class="mb-1.5 text-[10px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
{getCategoryLabel(currentActivity.category)}
</div>
<p class="text-[13px] leading-relaxed text-[#999]">
<p class="text-[13px] leading-relaxed text-[#999]" aria-live="polite">
{currentActivity.text}
</p>
</div>
@@ -236,9 +264,9 @@
<button
use:pressable
class="rounded-full border border-[#222] px-6 py-2.5 text-[12px]
tracking-wider text-[#555] uppercase
tracking-wider text-[#8a8a8a] uppercase
transition-colors duration-200
hover:border-[#333] hover:text-[#999]"
hover:border-[#333] hover:text-[#ccc]"
onclick={cancelBreak}
>
{cancelBtnText}
@@ -257,7 +285,7 @@
{/if}
</div>
{#if $config.snooze_limit > 0}
<p class="mt-3 text-[10px] text-[#2a2a2a]">
<p class="mt-3 text-[10px] text-[#8a8a8a]">
{$timer.snoozesUsed}/{$config.snooze_limit} snoozes used
</p>
{/if}
@@ -265,7 +293,7 @@
<!-- Bottom progress bar for modal -->
{#if isModal}
<div class="break-modal-progress-container">
<div class="break-modal-progress-container" role="progressbar" aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round($timer.breakProgress * 100)} aria-label="Break progress">
<div class="break-modal-progress-track">
<div
class="h-full transition-[width] duration-1000 ease-linear"
@@ -278,7 +306,7 @@
<!-- Fullscreen progress bar - anchored to bottom of screen -->
{#if !isModal}
<div class="absolute bottom-8 left-12 right-12 h-[3px] rounded-full overflow-hidden">
<div class="absolute bottom-8 left-12 right-12 h-[3px] rounded-full overflow-hidden" role="progressbar" aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round($timer.breakProgress * 100)} aria-label="Break progress">
<div class="w-full h-full" style="background: rgba(255,255,255,0.03);">
<div
class="h-full transition-[width] duration-1000 ease-linear"