Add WCAG 2.1 Level AA accessibility across all components

A break timer designed to prevent RSI should be usable by people who
already live with disabilities. This overhaul adds comprehensive
accessibility without changing the visual design.

Changes across 17 source files:
- Global focus-visible outlines, sr-only utility, forced-colors support
- prefers-reduced-motion kills all CSS animations AND JS Web Animations
- All text upgraded to 4.5:1+ contrast ratio (WCAG AA)
- Keyboard navigation for ColorPicker, Stepper, TimeSpinner
- Screen reader: aria-live status regions, progressbar roles, labeled
  controls, sr-only chart data table, focus management on view changes
- Focus trap on break screen, aria-hidden on decorative elements
- Descriptive labels on all 25+ toggle/stepper instances in Settings
- README updated with accessibility section and WCAG badge
This commit is contained in:
Your Name
2026-02-07 12:10:10 +02:00
parent d5ad1514d1
commit 4cbf4c5bb8
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"