a11y: Tasks 7-12 - Dashboard, Settings, StatsView, BreakScreen, Celebration

- Dashboard: text-text-sec tokens, nav landmark, toast hover persistence,
  goal progressbar ARIA, pomodoro sr-only text
- Settings: h3→h2 heading hierarchy, section aria-labelledby with ids,
  Working Hours heading added
- StatsView: h3→h2, tablist/tab/tabpanel ARIA pattern, sr-only data tables
  for 30-day chart and heatmap, contrast tokens
- BreakScreen: strict-mode focus safety span, breathing phase-only
  announcements, contrast tokens
- Celebration: JS-controlled hover/focus persistence, dismiss buttons,
  Escape key, removed pointer-events:none
- Titlebar: removed redundant role="banner" on <header>
This commit is contained in:
Your Name
2026-02-18 18:15:06 +02:00
parent 95f684450c
commit acf06c8d32
6 changed files with 443 additions and 216 deletions

View File

@@ -74,6 +74,18 @@
let breathCountdown = $state(4);
let breathScale = $state(0.6);
// Only announce phase name changes (not countdown ticks) to screen readers
let breathAnnouncement = $state("");
let lastBreathPhase = $state("");
$effect(() => {
// Extract just the phase name (e.g., "Inhale" from "Inhale 4")
const phaseName = breathPhase?.split(' ')[0] ?? "";
if (phaseName && phaseName !== lastBreathPhase) {
lastBreathPhase = phaseName;
breathAnnouncement = phaseName;
}
});
// Map raw 0.61.0 scale to 0.91.6 range for visible breathing text
const textScale = $derived(0.9 + (breathScale - 0.6) * (0.7 / 0.4));
@@ -169,11 +181,11 @@
<span
class="block mt-1.5 text-[9px] tracking-wider uppercase text-center font-medium"
style="transform: scale({textScale}); color: {breathColor}; opacity: {0.5 + breathT * 0.5}; transition: transform 0.15s ease-out, opacity 0.15s ease-out, color 0.15s ease-out;"
aria-live="polite" aria-atomic="true"
aria-label="Breathing guide: {breathPhase} {breathCountdown > 0 ? breathCountdown + ' seconds' : ''}"
aria-hidden="true"
>
{breathPhase}{breathCountdown > 0 ? ` ${breathCountdown}s` : ""}
</span>
<span class="sr-only" aria-live="polite" aria-atomic="true">{breathAnnouncement}</span>
{/if}
</div>
</TimerRing>
@@ -185,16 +197,16 @@
<h2 class="text-[17px] font-medium text-white mb-1.5" tabindex="-1">
{$timer.breakTitle}
</h2>
<p class="text-[12px] leading-relaxed text-[#8a8a8a] mb-4 max-w-[240px]">
<p class="text-[12px] leading-relaxed text-text-sec 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-[#8a8a8a] uppercase mb-1">
<div class="text-[9px] font-medium tracking-[0.15em] text-text-sec uppercase mb-1">
{getCategoryLabel(currentActivity.category)}
</div>
<p class="text-[12px] leading-relaxed text-[#8a8a8a]" aria-live="polite">
<p class="text-[12px] leading-relaxed text-text-sec" aria-live="polite">
{currentActivity.text}
</p>
</div>
@@ -205,7 +217,7 @@
<button
use:pressable
class="rounded-full border border-[#333] px-5 py-2 text-[11px]
tracking-wider text-[#8a8a8a] uppercase
tracking-wider text-text-sec uppercase
transition-colors duration-200
hover:border-[#444] hover:text-[#ccc]"
onclick={cancelBreak}
@@ -226,10 +238,15 @@
{/if}
</div>
{#if $config.snooze_limit > 0}
<p class="mt-2 text-[9px] text-[#8a8a8a]">
<p class="mt-2 text-[9px] text-text-sec">
{$timer.snoozesUsed}/{$config.snooze_limit} snoozes used
</p>
{/if}
{:else}
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<span tabindex="0" class="sr-only" aria-live="polite">
Break in progress, please wait
</span>
{/if}
</div>
@@ -302,11 +319,11 @@
class:text-[10px]={!isModal}
class:text-[9px]={isModal}
style="transform: scale({textScale}); color: {breathColor}; opacity: {0.5 + breathT * 0.5}; transition: transform 0.15s ease-out, opacity 0.15s ease-out, color 0.15s ease-out;"
aria-live="polite" aria-atomic="true"
aria-label="Breathing guide: {breathPhase} {breathCountdown > 0 ? breathCountdown + ' seconds' : ''}"
aria-hidden="true"
>
{breathPhase}{breathCountdown > 0 ? ` ${breathCountdown}s` : ""}
</span>
<span class="sr-only" aria-live="polite" aria-atomic="true">{breathAnnouncement}</span>
{/if}
</div>
</TimerRing>
@@ -328,7 +345,7 @@
</h2>
<p
class="max-w-[300px] text-center text-[13px] leading-relaxed text-[#8a8a8a]"
class="max-w-[300px] text-center text-[13px] leading-relaxed text-text-sec"
class:mb-4={$config.show_break_activities}
class:mb-8={!$config.show_break_activities}
use:fadeIn={{ delay: 0.35, y: 10 }}
@@ -341,10 +358,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-[#8a8a8a] uppercase">
<div class="mb-1.5 text-[10px] font-medium tracking-[0.15em] text-text-sec uppercase">
{getCategoryLabel(currentActivity.category)}
</div>
<p class="text-[13px] leading-relaxed text-[#8a8a8a]" aria-live="polite">
<p class="text-[13px] leading-relaxed text-text-sec" aria-live="polite">
{currentActivity.text}
</p>
</div>
@@ -354,8 +371,8 @@
<div class="flex items-center gap-3" use:fadeIn={{ delay: 0.45, y: 10 }}>
<button
use:pressable
class="rounded-full border border-[#222] px-6 py-2.5 text-[12px]
tracking-wider text-[#8a8a8a] uppercase
class="rounded-full border border-border px-6 py-2.5 text-[12px]
tracking-wider text-text-sec uppercase
transition-colors duration-200
hover:border-[#333] hover:text-[#ccc]"
onclick={cancelBreak}
@@ -376,10 +393,15 @@
{/if}
</div>
{#if $config.snooze_limit > 0}
<p class="mt-3 text-[10px] text-[#8a8a8a]">
<p class="mt-3 text-[10px] text-text-sec">
{$timer.snoozesUsed}/{$config.snooze_limit} snoozes used
</p>
{/if}
{:else}
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<span tabindex="0" class="sr-only" aria-live="polite">
Break in progress, please wait
</span>
{/if}
<!-- Bottom progress bar for modal -->