Files
core-cooldown/src/lib/components/Dashboard.svelte
Your Name 4cbf4c5bb8 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
2026-02-07 12:13:03 +02:00

307 lines
10 KiB
Svelte

<script lang="ts">
import { invoke } from "@tauri-apps/api/core";
import {
timer,
currentView,
formatTime,
formatDurationAgo,
type TimerSnapshot,
} from "../stores/timer";
import { config } from "../stores/config";
import TimerRing from "./TimerRing.svelte";
import { scaleIn, fadeIn, pressable, glowHover } from "../utils/animate";
async function toggleTimer() {
const snap = await invoke<TimerSnapshot>("toggle_timer");
timer.set(snap);
}
async function startBreakNow() {
const snap = await invoke<TimerSnapshot>("start_break_now");
timer.set(snap);
currentView.set(snap.currentView);
}
function openSettings() {
invoke("set_view", { view: "settings" });
currentView.set("settings");
}
const statusText = $derived(
$timer.idlePaused
? "IDLE"
: $timer.prebreakWarning
? "BREAK SOON"
: $timer.state === "running"
? "FOCUS"
: "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",
);
// Responsive ring scaling computed from window dimensions
let windowW = $state(window.innerWidth);
let windowH = $state(window.innerHeight);
// Ring scales 1.0 → 0.6 based on both dimensions
const ringScale = $derived(
Math.min(1, Math.max(0.6, Math.min(
(windowH - 300) / 400,
(windowW - 200) / 300,
))),
);
// Text scales less aggressively: 1.0 → 0.7 (counter-scale inside ring)
// counterScale = textScale / ringScale, where textScale = lerp(0.7, 1.0, (ringScale-0.6)/0.4)
const textCounterScale = $derived(
ringScale < 1
? Math.min(1.2, (0.7 + (ringScale - 0.6) * 0.75) / ringScale)
: 1,
);
// Gap between ring and button, compensating for CSS transform phantom space.
// transform: scale() doesn't affect layout, so the 280px box stays full-size
// even when visually shrunk — creating phantom space below the visual ring.
const ringSize = 280;
const phantomBelow = $derived((ringSize * (1 - ringScale)) / 2);
const targetGap = $derived(Math.max(16, Math.round((windowH - 400) * 0.05 + 16)));
const ringMargin = $derived(targetGap - phantomBelow);
// Natural break notification
let showNaturalBreakToast = $state(false);
let naturalBreakToastTimeout: ReturnType<typeof setTimeout> | null = null;
// Watch for natural break detection
$effect(() => {
if ($timer.naturalBreakOccurred) {
showNaturalBreakToast = true;
if (naturalBreakToastTimeout) clearTimeout(naturalBreakToastTimeout);
naturalBreakToastTimeout = setTimeout(() => {
showNaturalBreakToast = false;
}, 5000);
}
});
</script>
<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;">
<div use:scaleIn={{ duration: 0.7, delay: 0.1 }}>
<TimerRing
progress={$timer.progress}
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="#888"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z"
/>
<circle cx="12" cy="12" r="3" />
</svg>
<!-- Time display -->
<span
class="block text-center text-[54px] font-semibold leading-none tracking-tight tabular-nums text-white"
style={$config.countdown_font ? `font-family: '${$config.countdown_font}', monospace;` : ""}
>
{formatTime($timer.timeRemaining)}
</span>
<div class="h-3"></div>
<!-- Status label -->
<span
class="block text-center text-[11px] font-medium tracking-[0.25em]"
class:text-[#8a8a8a]={!$timer.prebreakWarning}
class:text-warning={$timer.prebreakWarning}
>
{statusText}
</span>
</div>
</TimerRing>
</div>
</div>
<!-- Last break info -->
<div use:fadeIn={{ delay: 0.4, y: 10 }}>
{#if $timer.hasHadBreak}
<p style="margin-bottom: {ringMargin}px;" class="text-[12px] text-[#8a8a8a]">
Last break {formatDurationAgo($timer.secondsSinceLastBreak)}
</p>
{:else}
<div style="margin-bottom: {ringMargin}px; height: 18px;"></div>
{/if}
</div>
<!-- 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 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>
</div>
</div>
{/if}
<!-- Pause / Start button -->
<button
use:fadeIn={{ delay: 0.3, y: 12 }}
use:pressable
use:glowHover={{ color: $config.accent_color }}
class="w-[200px] rounded-full py-3.5 text-[13px] font-medium
tracking-[0.2em] text-white uppercase backdrop-blur-xl
transition-colors duration-200"
style="background: rgba(20,20,20,0.7);"
onclick={toggleTimer}
>
{toggleBtnText}
</button>
<!-- Bottom left: start break now -->
<div class="absolute bottom-5 left-5" use:fadeIn={{ delay: 0.5, y: 8 }}>
<button
aria-label="Start break now"
use:pressable
class="flex h-11 w-11 items-center justify-center rounded-full
border border-[#222] text-[#8a8a8a]
transition-colors duration-200
hover:border-[#333] hover:text-[#aaa]"
onclick={startBreakNow}
>
<svg
aria-hidden="true"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
>
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
</button>
</div>
<!-- Bottom center: stats -->
<div class="absolute bottom-5 left-1/2 -translate-x-1/2" use:fadeIn={{ delay: 0.52, y: 8 }}>
<button
aria-label="Statistics"
use:pressable
class="flex h-11 w-11 items-center justify-center rounded-full
border border-[#222] text-[#8a8a8a]
transition-colors duration-200
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"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="3" y="12" width="4" height="9" rx="1" />
<rect x="10" y="7" width="4" height="14" rx="1" />
<rect x="17" y="3" width="4" height="18" rx="1" />
</svg>
</button>
</div>
<!-- Bottom right: settings -->
<div class="absolute bottom-5 right-5" use:fadeIn={{ delay: 0.55, y: 8 }}>
<button
aria-label="Settings"
use:pressable
class="flex h-11 w-11 items-center justify-center rounded-full
border border-[#222] text-[#8a8a8a]
transition-colors duration-200
hover:border-[#333] hover:text-[#aaa]"
onclick={openSettings}
>
<svg
aria-hidden="true"
width="17"
height="17"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"
/>
<circle cx="12" cy="12" r="3" />
</svg>
</button>
</div>
</div>
<style>
/* Eye blink animation - natural human blink (~4s interval, 0.5s duration) */
.eye-blink {
transform-origin: center center;
animation: blink 4s infinite ease-in-out;
}
@keyframes blink {
0%, 45%, 55%, 100% {
transform: scaleY(1);
}
50% {
transform: scaleY(0.1);
}
}
</style>