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:
@@ -37,6 +37,16 @@
|
||||
: "PAUSED",
|
||||
);
|
||||
|
||||
// Track status changes for aria-live region (announce only on change, not every tick)
|
||||
let lastAnnouncedStatus = $state("");
|
||||
let statusAnnouncement = $state("");
|
||||
$effect(() => {
|
||||
if (statusText !== lastAnnouncedStatus) {
|
||||
lastAnnouncedStatus = statusText;
|
||||
statusAnnouncement = `Timer status: ${statusText}. ${formatTime($timer.timeRemaining)} remaining.`;
|
||||
}
|
||||
});
|
||||
|
||||
const toggleBtnText = $derived(
|
||||
$timer.state === "running" ? "PAUSE" : "START",
|
||||
);
|
||||
@@ -87,6 +97,9 @@
|
||||
|
||||
<svelte:window bind:innerWidth={windowW} bind:innerHeight={windowH} />
|
||||
|
||||
<h1 class="sr-only" tabindex="-1">Dashboard</h1>
|
||||
<div aria-live="polite" class="sr-only">{statusAnnouncement}</div>
|
||||
|
||||
<div class="relative flex h-full flex-col items-center justify-center">
|
||||
<!-- Outer: responsive scaling (JS-driven), Inner: mount animation -->
|
||||
<div style="transform: scale({ringScale}); transform-origin: center center; margin-bottom: {ringMargin}px;">
|
||||
@@ -96,17 +109,20 @@
|
||||
size={280}
|
||||
strokeWidth={8}
|
||||
accentColor={$config.accent_color}
|
||||
label="Focus timer"
|
||||
valueText="{formatTime($timer.timeRemaining)} remaining"
|
||||
>
|
||||
<!-- Counter-scale wrapper: text shrinks less than ring -->
|
||||
<div style="transform: scale({textCounterScale}); transform-origin: center center;">
|
||||
<!-- Eye icon -->
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="mx-auto mb-3 eye-blink"
|
||||
width="26"
|
||||
height="26"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#444"
|
||||
stroke="#888"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
@@ -130,10 +146,7 @@
|
||||
<!-- Status label -->
|
||||
<span
|
||||
class="block text-center text-[11px] font-medium tracking-[0.25em]"
|
||||
class:text-[#444]={!$timer.prebreakWarning &&
|
||||
$timer.state === "running"}
|
||||
class:text-[#333]={!$timer.prebreakWarning &&
|
||||
$timer.state === "paused"}
|
||||
class:text-[#8a8a8a]={!$timer.prebreakWarning}
|
||||
class:text-warning={$timer.prebreakWarning}
|
||||
>
|
||||
{statusText}
|
||||
@@ -146,7 +159,7 @@
|
||||
<!-- Last break info -->
|
||||
<div use:fadeIn={{ delay: 0.4, y: 10 }}>
|
||||
{#if $timer.hasHadBreak}
|
||||
<p style="margin-bottom: {ringMargin}px;" class="text-[12px] text-[#2a2a2a]">
|
||||
<p style="margin-bottom: {ringMargin}px;" class="text-[12px] text-[#8a8a8a]">
|
||||
Last break {formatDurationAgo($timer.secondsSinceLastBreak)}
|
||||
</p>
|
||||
{:else}
|
||||
@@ -157,12 +170,13 @@
|
||||
<!-- Natural break notification toast -->
|
||||
{#if showNaturalBreakToast}
|
||||
<div
|
||||
role="alert"
|
||||
class="absolute top-6 left-1/2 -translate-x-1/2 rounded-2xl border px-5 py-3 text-center backdrop-blur-xl transition-all duration-500"
|
||||
style="border-color: rgba(63, 185, 80, 0.3); background: rgba(63, 185, 80, 0.1);"
|
||||
use:scaleIn={{ duration: 0.3, delay: 0 }}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#3fb950" stroke-width="2">
|
||||
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#3fb950" stroke-width="2">
|
||||
<path d="M20 6L9 17l-5-5"/>
|
||||
</svg>
|
||||
<span class="text-[13px] text-[#3fb950]">Natural break detected - timer reset</span>
|
||||
@@ -190,12 +204,13 @@
|
||||
aria-label="Start break now"
|
||||
use:pressable
|
||||
class="flex h-11 w-11 items-center justify-center rounded-full
|
||||
border border-[#222] text-[#383838]
|
||||
border border-[#222] text-[#8a8a8a]
|
||||
transition-colors duration-200
|
||||
hover:border-[#333] hover:text-[#666]"
|
||||
hover:border-[#333] hover:text-[#aaa]"
|
||||
onclick={startBreakNow}
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -216,15 +231,16 @@
|
||||
aria-label="Statistics"
|
||||
use:pressable
|
||||
class="flex h-11 w-11 items-center justify-center rounded-full
|
||||
border border-[#222] text-[#383838]
|
||||
border border-[#222] text-[#8a8a8a]
|
||||
transition-colors duration-200
|
||||
hover:border-[#333] hover:text-[#666]"
|
||||
hover:border-[#333] hover:text-[#aaa]"
|
||||
onclick={() => {
|
||||
invoke("set_view", { view: "stats" });
|
||||
currentView.set("stats");
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
width="17"
|
||||
height="17"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -247,12 +263,13 @@
|
||||
aria-label="Settings"
|
||||
use:pressable
|
||||
class="flex h-11 w-11 items-center justify-center rounded-full
|
||||
border border-[#222] text-[#383838]
|
||||
border border-[#222] text-[#8a8a8a]
|
||||
transition-colors duration-200
|
||||
hover:border-[#333] hover:text-[#666]"
|
||||
hover:border-[#333] hover:text-[#aaa]"
|
||||
onclick={openSettings}
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
width="17"
|
||||
height="17"
|
||||
viewBox="0 0 24 24"
|
||||
|
||||
Reference in New Issue
Block a user