Add WCAG 2.1 Level AA accessibility across all components

This commit is contained in:
2026-02-07 12:10:10 +02:00
parent e67612a1ac
commit 32b17cbd27
18 changed files with 459 additions and 226 deletions

View File

@@ -21,6 +21,7 @@
<img src="https://img.shields.io/badge/svelte-5-FF3E00?style=flat-square&logo=svelte&logoColor=white" alt="Svelte 5" /> <img src="https://img.shields.io/badge/svelte-5-FF3E00?style=flat-square&logo=svelte&logoColor=white" alt="Svelte 5" />
<img src="https://img.shields.io/badge/rust-2021-000000?style=flat-square&logo=rust&logoColor=white" alt="Rust" /> <img src="https://img.shields.io/badge/rust-2021-000000?style=flat-square&logo=rust&logoColor=white" alt="Rust" />
<img src="https://img.shields.io/badge/tailwind-v4-06B6D4?style=flat-square&logo=tailwindcss&logoColor=white" alt="Tailwind v4" /> <img src="https://img.shields.io/badge/tailwind-v4-06B6D4?style=flat-square&logo=tailwindcss&logoColor=white" alt="Tailwind v4" />
<img src="https://img.shields.io/badge/WCAG_2.1-AA-228B22?style=flat-square" alt="WCAG 2.1 AA" />
</p> </p>
<p align="center"> <p align="center">
@@ -239,6 +240,23 @@ Native Windows toast notifications for:
<br /> <br />
### ♿ Accessibility
Core Cooldown targets **WCAG 2.1 Level AA** compliance. A break timer for preventing repetitive strain injury should be usable by everyone - including those who already live with disabilities.
| | Feature | Description |
|:--|:--------|:------------|
| ⌨️ | **Full keyboard navigation** | Every control reachable and operable via keyboard alone. Arrow keys adjust color pickers, steppers, and time spinners. Tab/Shift+Tab cycles through all interactive elements. |
| 🔍 | **Visible focus indicators** | Global `:focus-visible` outlines on all interactive elements - no hidden or suppressed focus rings |
| 🗣️ | **Screen reader support** | `aria-live` regions announce timer state changes, break activities, and status updates. Progress rings use `role="progressbar"` with value text. Stats chart has a screen-reader-accessible data table. |
| 🎯 | **Focus management** | View transitions move focus to the new view's heading. Break screen traps focus to prevent interaction with obscured content. |
| 🎨 | **Color contrast** | All text meets 4.5:1 minimum contrast ratio against dark backgrounds (WCAG AA) |
| 🖥️ | **Windows High Contrast** | `forced-colors: active` media query maps all theme tokens to system colors |
| 🐢 | **Reduced motion** | `prefers-reduced-motion` disables all CSS animations/transitions *and* all JavaScript-driven Web Animations API effects. No functionality lost - just calmer. |
| 🏷️ | **Descriptive labels** | All toggle switches, steppers, buttons, and form controls have descriptive accessible names instead of generic labels |
<br />
--- ---
## 📦 Portability ## 📦 Portability
@@ -511,7 +529,7 @@ No contribution agreements to sign. No corporate CLAs. No licensing traps. Every
- 🐛 Report bugs or rough edges - 🐛 Report bugs or rough edges
- 🧘 Suggest new break activities (especially with physiotherapy or ergonomics knowledge) - 🧘 Suggest new break activities (especially with physiotherapy or ergonomics knowledge)
- ♿ Improve accessibility - ♿ Improve accessibility (WCAG 2.1 AA foundation is in place - help us push further)
- 🐧 Port idle detection to macOS/Linux - 🐧 Port idle detection to macOS/Linux
- 🌍 Translate the interface - 🌍 Translate the interface
- 💌 Share it with someone who needs it - 💌 Share it with someone who needs it

113
src-tauri/Cargo.lock generated
View File

@@ -480,11 +480,10 @@ dependencies = [
[[package]] [[package]]
name = "core-cooldown" name = "core-cooldown"
version = "0.1.0" version = "0.1.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
"dirs 5.0.1",
"serde", "serde",
"serde_json", "serde_json",
"tauri", "tauri",
@@ -683,34 +682,13 @@ dependencies = [
"crypto-common", "crypto-common",
] ]
[[package]]
name = "dirs"
version = "5.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225"
dependencies = [
"dirs-sys 0.4.1",
]
[[package]] [[package]]
name = "dirs" name = "dirs"
version = "6.0.0" version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
dependencies = [ dependencies = [
"dirs-sys 0.5.0", "dirs-sys",
]
[[package]]
name = "dirs-sys"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
dependencies = [
"libc",
"option-ext",
"redox_users 0.4.6",
"windows-sys 0.48.0",
] ]
[[package]] [[package]]
@@ -721,7 +699,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
dependencies = [ dependencies = [
"libc", "libc",
"option-ext", "option-ext",
"redox_users 0.5.2", "redox_users",
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
@@ -2936,17 +2914,6 @@ dependencies = [
"bitflags 2.10.0", "bitflags 2.10.0",
] ]
[[package]]
name = "redox_users"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
dependencies = [
"getrandom 0.2.17",
"libredox",
"thiserror 1.0.69",
]
[[package]] [[package]]
name = "redox_users" name = "redox_users"
version = "0.5.2" version = "0.5.2"
@@ -3637,7 +3604,7 @@ dependencies = [
"anyhow", "anyhow",
"bytes", "bytes",
"cookie", "cookie",
"dirs 6.0.0", "dirs",
"dunce", "dunce",
"embed_plist", "embed_plist",
"getrandom 0.3.4", "getrandom 0.3.4",
@@ -3687,7 +3654,7 @@ checksum = "ca7bd893329425df750813e95bd2b643d5369d929438da96d5bbb7cc2c918f74"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"cargo_toml", "cargo_toml",
"dirs 6.0.0", "dirs",
"glob", "glob",
"heck 0.5.0", "heck 0.5.0",
"json-patch", "json-patch",
@@ -4238,7 +4205,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c" checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c"
dependencies = [ dependencies = [
"crossbeam-channel", "crossbeam-channel",
"dirs 6.0.0", "dirs",
"libappindicator", "libappindicator",
"muda", "muda",
"objc2", "objc2",
@@ -4812,15 +4779,6 @@ dependencies = [
"windows-targets 0.42.2", "windows-targets 0.42.2",
] ]
[[package]]
name = "windows-sys"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
dependencies = [
"windows-targets 0.48.5",
]
[[package]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.59.0" version = "0.59.0"
@@ -4863,21 +4821,6 @@ dependencies = [
"windows_x86_64_msvc 0.42.2", "windows_x86_64_msvc 0.42.2",
] ]
[[package]]
name = "windows-targets"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
dependencies = [
"windows_aarch64_gnullvm 0.48.5",
"windows_aarch64_msvc 0.48.5",
"windows_i686_gnu 0.48.5",
"windows_i686_msvc 0.48.5",
"windows_x86_64_gnu 0.48.5",
"windows_x86_64_gnullvm 0.48.5",
"windows_x86_64_msvc 0.48.5",
]
[[package]] [[package]]
name = "windows-targets" name = "windows-targets"
version = "0.52.6" version = "0.52.6"
@@ -4935,12 +4878,6 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
[[package]] [[package]]
name = "windows_aarch64_gnullvm" name = "windows_aarch64_gnullvm"
version = "0.52.6" version = "0.52.6"
@@ -4959,12 +4896,6 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
[[package]]
name = "windows_aarch64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
[[package]] [[package]]
name = "windows_aarch64_msvc" name = "windows_aarch64_msvc"
version = "0.52.6" version = "0.52.6"
@@ -4983,12 +4914,6 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
[[package]]
name = "windows_i686_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
[[package]] [[package]]
name = "windows_i686_gnu" name = "windows_i686_gnu"
version = "0.52.6" version = "0.52.6"
@@ -5019,12 +4944,6 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
[[package]]
name = "windows_i686_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
[[package]] [[package]]
name = "windows_i686_msvc" name = "windows_i686_msvc"
version = "0.52.6" version = "0.52.6"
@@ -5043,12 +4962,6 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
[[package]]
name = "windows_x86_64_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
[[package]] [[package]]
name = "windows_x86_64_gnu" name = "windows_x86_64_gnu"
version = "0.52.6" version = "0.52.6"
@@ -5067,12 +4980,6 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
[[package]] [[package]]
name = "windows_x86_64_gnullvm" name = "windows_x86_64_gnullvm"
version = "0.52.6" version = "0.52.6"
@@ -5091,12 +4998,6 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
[[package]]
name = "windows_x86_64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
[[package]] [[package]]
name = "windows_x86_64_msvc" name = "windows_x86_64_msvc"
version = "0.52.6" version = "0.52.6"
@@ -5159,7 +5060,7 @@ dependencies = [
"block2", "block2",
"cookie", "cookie",
"crossbeam-channel", "crossbeam-channel",
"dirs 6.0.0", "dirs",
"dpi", "dpi",
"dunce", "dunce",
"gdkx11", "gdkx11",

View File

@@ -52,10 +52,28 @@
}; };
}); });
// Transition parameters // Reduced motion preference
const DURATION = 700; let reducedMotion = $state(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
$effect(() => {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
const handler = (e: MediaQueryListEvent) => { reducedMotion = e.matches; };
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
});
// Transition parameters - zero when reduced motion active
const DURATION = $derived(reducedMotion ? 0 : 700);
const easing = cubicOut; const easing = cubicOut;
// Focus management: move focus to new view's heading on view change
$effect(() => {
const _view = effectiveView;
requestAnimationFrame(() => {
const heading = document.querySelector("h1[tabindex='-1']") as HTMLElement | null;
heading?.focus({ preventScroll: true });
});
});
// When fullscreen_mode is OFF, the separate break window handles breaks, // When fullscreen_mode is OFF, the separate break window handles breaks,
// so the main window should keep showing whatever view it was on (dashboard). // so the main window should keep showing whatever view it was on (dashboard).
const effectiveView = $derived( const effectiveView = $derived(
@@ -65,7 +83,7 @@
); );
</script> </script>
<div class="relative h-full bg-black"> <main class="relative h-full bg-black">
{#if $config.background_blobs_enabled} {#if $config.background_blobs_enabled}
<BackgroundBlobs accentColor={$config.accent_color} breakColor={$config.break_color} /> <BackgroundBlobs accentColor={$config.accent_color} breakColor={$config.break_color} />
{/if} {/if}
@@ -115,4 +133,4 @@
</div> </div>
{/if} {/if}
</div> </div>
</div> </main>

View File

@@ -14,7 +14,7 @@
--color-warning: #f0a500; --color-warning: #f0a500;
--color-danger: #f85149; --color-danger: #f85149;
--color-text-pri: #ffffff; --color-text-pri: #ffffff;
--color-text-sec: #777777; --color-text-sec: #8a8a8a;
--color-text-dim: #3a3a3a; --color-text-dim: #3a3a3a;
--color-caption-bg: #050505; --color-caption-bg: #050505;
} }
@@ -55,3 +55,51 @@ body {
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background: #333; background: #333;
} }
/* ── Accessibility: Screen-reader only ── */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
/* ── Accessibility: Focus indicators ── */
:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
/* ── Accessibility: Reduced motion ── */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
scroll-behavior: auto !important;
}
}
/* ── Accessibility: Windows High Contrast ── */
@media (forced-colors: active) {
:root {
--color-bg: Canvas;
--color-surface: Canvas;
--color-card: Canvas;
--color-card-lt: Canvas;
--color-border: ButtonBorder;
--color-accent: Highlight;
--color-accent-lt: Highlight;
--color-text-pri: CanvasText;
--color-text-sec: CanvasText;
--color-text-dim: GrayText;
--color-success: Highlight;
--color-warning: Highlight;
--color-danger: LinkText;
}
}

View File

@@ -5,9 +5,17 @@
} }
let { accentColor, breakColor }: Props = $props(); let { accentColor, breakColor }: Props = $props();
let reducedMotion = $state(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
$effect(() => {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
const handler = (e: MediaQueryListEvent) => { reducedMotion = e.matches; };
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
});
</script> </script>
<div class="pointer-events-none absolute inset-0 overflow-hidden"> <div class="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden="true">
<!-- Gradient blobs --> <!-- Gradient blobs -->
<div <div
class="blob blob-1" class="blob blob-1"
@@ -30,6 +38,7 @@
<svg class="absolute inset-0 h-full w-full" style="opacity: 0.08; mix-blend-mode: overlay;"> <svg class="absolute inset-0 h-full w-full" style="opacity: 0.08; mix-blend-mode: overlay;">
<filter id="grain-filter"> <filter id="grain-filter">
<feTurbulence type="fractalNoise" baseFrequency="0.45" numOctaves="3" stitchTiles="stitch"> <feTurbulence type="fractalNoise" baseFrequency="0.45" numOctaves="3" stitchTiles="stitch">
{#if !reducedMotion}
<animate <animate
attributeName="seed" attributeName="seed"
from="0" from="0"
@@ -37,6 +46,7 @@
dur="2s" dur="2s"
repeatCount="indefinite" repeatCount="indefinite"
/> />
{/if}
</feTurbulence> </feTurbulence>
</filter> </filter>
<rect width="100%" height="100%" filter="url(#grain-filter)" /> <rect width="100%" height="100%" filter="url(#grain-filter)" />

View File

@@ -4,6 +4,7 @@
import { timer, currentView, formatTime, type TimerSnapshot } from "../stores/timer"; import { timer, currentView, formatTime, type TimerSnapshot } from "../stores/timer";
import { config } from "../stores/config"; import { config } from "../stores/config";
import TimerRing from "./TimerRing.svelte"; import TimerRing from "./TimerRing.svelte";
import { onMount } from "svelte";
import { scaleIn, fadeIn, pressable } from "../utils/animate"; import { scaleIn, fadeIn, pressable } from "../utils/animate";
import { pickRandomActivity, getCategoryIcon, getCategoryLabel, type BreakActivity } from "../utils/activities"; import { pickRandomActivity, getCategoryIcon, getCategoryLabel, type BreakActivity } from "../utils/activities";
@@ -70,14 +71,36 @@
); );
const isModal = $derived(!$config.fullscreen_mode && !standalone); 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> </script>
{#if standalone} {#if standalone}
<!-- ── Standalone break window: horizontal card, transparent background ── --> <!-- ── 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 --> <!-- Ripples emanate from the ring, visible outside the card -->
<div class="standalone-ring-area"> <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-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-2" style="--ripple-color: {$config.break_color}"></div>
<div class="break-ripple ripple-3" 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} size={140}
strokeWidth={5} strokeWidth={5}
accentColor={$config.break_color} accentColor={$config.break_color}
label="Break timer"
valueText="{formatTime($timer.breakTimeRemaining)} remaining"
> >
<div class="break-breathe-counter"> <div class="break-breathe-counter">
<span <span
@@ -103,19 +128,19 @@
<!-- Right side: text + buttons --> <!-- Right side: text + buttons -->
<div class="standalone-content"> <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} {$timer.breakTitle}
</h2> </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} {$timer.breakMessage}
</p> </p>
{#if $config.show_break_activities} {#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="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)} {getCategoryLabel(currentActivity.category)}
</div> </div>
<p class="text-[12px] leading-relaxed text-[#999]"> <p class="text-[12px] leading-relaxed text-[#999]" aria-live="polite">
{currentActivity.text} {currentActivity.text}
</p> </p>
</div> </div>
@@ -126,9 +151,9 @@
<button <button
use:pressable use:pressable
class="rounded-full border border-[#333] px-5 py-2 text-[11px] 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 transition-colors duration-200
hover:border-[#444] hover:text-[#aaa]" hover:border-[#444] hover:text-[#ccc]"
onclick={cancelBreak} onclick={cancelBreak}
> >
{cancelBtnText} {cancelBtnText}
@@ -147,7 +172,7 @@
{/if} {/if}
</div> </div>
{#if $config.snooze_limit > 0} {#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 {$timer.snoozesUsed}/{$config.snooze_limit} snoozes used
</p> </p>
{/if} {/if}
@@ -155,7 +180,7 @@
</div> </div>
<!-- Bottom progress bar with clip-path --> <!-- 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="standalone-progress-track">
<div <div
class="h-full transition-[width] duration-1000 ease-linear" class="h-full transition-[width] duration-1000 ease-linear"
@@ -170,6 +195,7 @@
<div <div
class="relative h-full flex items-center justify-center" class="relative h-full flex items-center justify-center"
style="background: #000;" style="background: #000;"
bind:this={breakContainer}
> >
<div <div
class="relative flex flex-col items-center" class="relative flex flex-col items-center"
@@ -177,7 +203,7 @@
> >
<!-- Break ring with breathing pulse + ripples --> <!-- Break ring with breathing pulse + ripples -->
<div class="mb-6 relative" use:scaleIn={{ duration: 0.6, delay: 0.1 }}> <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-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-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> <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} size={isModal ? 160 : 200}
strokeWidth={isModal ? 5 : 6} strokeWidth={isModal ? 5 : 6}
accentColor={$config.break_color} accentColor={$config.break_color}
label="Break timer"
valueText="{formatTime($timer.breakTimeRemaining)} remaining"
> >
<div class="break-breathe-counter"> <div class="break-breathe-counter">
<span <span
@@ -204,12 +232,12 @@
</div> </div>
</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} {$timer.breakTitle}
</h2> </h2>
<p <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-4={$config.show_break_activities}
class:mb-8={!$config.show_break_activities} class:mb-8={!$config.show_break_activities}
use:fadeIn={{ delay: 0.35, y: 10 }} 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" 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 }} 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)} {getCategoryLabel(currentActivity.category)}
</div> </div>
<p class="text-[13px] leading-relaxed text-[#999]"> <p class="text-[13px] leading-relaxed text-[#999]" aria-live="polite">
{currentActivity.text} {currentActivity.text}
</p> </p>
</div> </div>
@@ -236,9 +264,9 @@
<button <button
use:pressable use:pressable
class="rounded-full border border-[#222] px-6 py-2.5 text-[12px] 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 transition-colors duration-200
hover:border-[#333] hover:text-[#999]" hover:border-[#333] hover:text-[#ccc]"
onclick={cancelBreak} onclick={cancelBreak}
> >
{cancelBtnText} {cancelBtnText}
@@ -257,7 +285,7 @@
{/if} {/if}
</div> </div>
{#if $config.snooze_limit > 0} {#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 {$timer.snoozesUsed}/{$config.snooze_limit} snoozes used
</p> </p>
{/if} {/if}
@@ -265,7 +293,7 @@
<!-- Bottom progress bar for modal --> <!-- Bottom progress bar for modal -->
{#if isModal} {#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="break-modal-progress-track">
<div <div
class="h-full transition-[width] duration-1000 ease-linear" class="h-full transition-[width] duration-1000 ease-linear"
@@ -278,7 +306,7 @@
<!-- Fullscreen progress bar - anchored to bottom of screen --> <!-- Fullscreen progress bar - anchored to bottom of screen -->
{#if !isModal} {#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="w-full h-full" style="background: rgba(255,255,255,0.03);">
<div <div
class="h-full transition-[width] duration-1000 ease-linear" class="h-full transition-[width] duration-1000 ease-linear"

View File

@@ -182,6 +182,45 @@
const isPreset = $derived(presets.includes(value)); const isPreset = $derived(presets.includes(value));
// Color name lookup for accessible swatch labels
const colorNames: Record<string, string> = {
"#ff4d00": "Orange", "#ff6b35": "Tangerine", "#e63946": "Red", "#d62828": "Dark Red",
"#f77f00": "Amber", "#fcbf49": "Gold", "#2ec4b6": "Teal", "#3fb950": "Green",
"#7c6aef": "Purple", "#9b5de5": "Violet", "#4361ee": "Blue", "#4895ef": "Sky Blue",
"#f72585": "Pink", "#ff006e": "Hot Pink", "#ffffff": "White", "#888888": "Gray",
"#06d6a0": "Mint", "#80ed99": "Light Green", "#fca311": "Marigold", "#ffbe0b": "Yellow",
};
function getColorName(hex: string): string {
return colorNames[hex.toLowerCase()] ?? hex;
}
// Keyboard handlers for SL pad
function handleSLKeydown(e: KeyboardEvent) {
let handled = true;
switch (e.key) {
case "ArrowRight": sat = Math.min(100, sat + 5); break;
case "ArrowLeft": sat = Math.max(0, sat - 5); break;
case "ArrowUp": light = Math.min(100, light + 5); break;
case "ArrowDown": light = Math.max(0, light - 5); break;
default: handled = false;
}
if (handled) { e.preventDefault(); updateFromHSL(); }
}
// Keyboard handlers for Hue bar
function handleHueKeydown(e: KeyboardEvent) {
let handled = true;
switch (e.key) {
case "ArrowRight": hue = Math.min(360, hue + 5); break;
case "ArrowLeft": hue = Math.max(0, hue - 5); break;
case "ArrowUp": hue = Math.min(360, hue + 5); break;
case "ArrowDown": hue = Math.max(0, hue - 5); break;
default: handled = false;
}
if (handled) { e.preventDefault(); updateFromHSL(); }
}
// SL cursor position // SL cursor position
const slX = $derived(sat); const slX = $derived(sat);
const slY = $derived(100 - light); const slY = $derived(100 - light);
@@ -192,7 +231,7 @@
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<div class="text-[13px] text-white">{label}</div> <div class="text-[13px] text-white">{label}</div>
<div class="font-mono text-[11px] text-[#444]">{value}</div> <div class="font-mono text-[11px] text-[#8a8a8a]">{value}</div>
</div> </div>
<div <div
class="h-6 w-6 rounded-full border border-[#333] shadow-[0_0_8px_0px] transition-shadow duration-300" class="h-6 w-6 rounded-full border border-[#333] shadow-[0_0_8px_0px] transition-shadow duration-300"
@@ -211,7 +250,7 @@
: 'hover:scale-110 opacity-80 hover:opacity-100'}" : 'hover:scale-110 opacity-80 hover:opacity-100'}"
style="background: {color};" style="background: {color};"
onclick={() => selectPreset(color)} onclick={() => selectPreset(color)}
aria-label="Select {color}" aria-label="Select {getColorName(color)}"
></button> ></button>
{/each} {/each}
@@ -235,7 +274,7 @@
transition:slide={{ duration: 280, easing: cubicOut }} transition:slide={{ duration: 280, easing: cubicOut }}
> >
<!-- Saturation / Lightness pad --> <!-- Saturation / Lightness pad -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions --> <!-- svelte-ignore a11y_no_noninteractive_tabindex a11y_no_noninteractive_element_interactions -->
<div <div
bind:this={slPad} bind:this={slPad}
class="relative h-[120px] w-full cursor-crosshair rounded-lg overflow-hidden touch-none" class="relative h-[120px] w-full cursor-crosshair rounded-lg overflow-hidden touch-none"
@@ -244,9 +283,10 @@
onpointermove={handleSLPointerMove} onpointermove={handleSLPointerMove}
onpointerup={handleSLPointerUp} onpointerup={handleSLPointerUp}
onpointercancel={handleSLPointerUp} onpointercancel={handleSLPointerUp}
onkeydown={handleSLKeydown}
role="application" role="application"
aria-label="Saturation and lightness" aria-label="Saturation and lightness. Use arrow keys to adjust."
tabindex="-1" tabindex="0"
> >
<!-- Lightness overlay: white at top, black at bottom --> <!-- Lightness overlay: white at top, black at bottom -->
<div <div
@@ -262,7 +302,7 @@
</div> </div>
<!-- Hue bar --> <!-- Hue bar -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions --> <!-- svelte-ignore a11y_no_noninteractive_tabindex a11y_no_noninteractive_element_interactions -->
<div <div
bind:this={hueBar} bind:this={hueBar}
class="relative h-[16px] w-full cursor-pointer rounded-full overflow-hidden touch-none" class="relative h-[16px] w-full cursor-pointer rounded-full overflow-hidden touch-none"
@@ -273,9 +313,10 @@
onpointermove={handleHuePointerMove} onpointermove={handleHuePointerMove}
onpointerup={handleHuePointerUp} onpointerup={handleHuePointerUp}
onpointercancel={handleHuePointerUp} onpointercancel={handleHuePointerUp}
onkeydown={handleHueKeydown}
role="application" role="application"
aria-label="Hue" aria-label="Hue. Use arrow keys to adjust."
tabindex="-1" tabindex="0"
> >
<!-- Hue cursor --> <!-- Hue cursor -->
<div <div
@@ -288,8 +329,9 @@
<!-- Hex input --> <!-- Hex input -->
<input <input
type="text" type="text"
aria-label="Hex color value"
class="w-full rounded-lg border border-[#1a1a1a] bg-black px-3 py-1.5 text-[12px] class="w-full rounded-lg border border-[#1a1a1a] bg-black px-3 py-1.5 text-[12px]
font-mono text-white outline-none font-mono text-white
placeholder:text-[#333] focus:border-[#333]" placeholder:text-[#333] focus:border-[#333]"
placeholder="#ff4d00" placeholder="#ff4d00"
value={hexInput} value={hexInput}

View File

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

View File

@@ -52,13 +52,15 @@
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<div class="text-[13px] text-white">Countdown font</div> <div class="text-[13px] text-white">Countdown font</div>
<div class="text-[11px] text-[#555]"> <div class="text-[11px] text-[#8a8a8a]">
{value || "System default"} {value || "System default"}
</div> </div>
</div> </div>
<button <button
type="button" type="button"
class="rounded-lg border border-[#1a1a1a] bg-black px-3 py-1.5 text-[12px] text-[#666] aria-expanded={expanded}
aria-label={expanded ? "Close font browser" : "Browse fonts"}
class="rounded-lg border border-[#1a1a1a] bg-black px-3 py-1.5 text-[12px] text-[#8a8a8a]
transition-colors hover:border-[#333] hover:text-white" transition-colors hover:border-[#333] hover:text-white"
onclick={() => { expanded = !expanded; }} onclick={() => { expanded = !expanded; }}
> >
@@ -75,6 +77,8 @@
{#each fonts as font} {#each fonts as font}
<button <button
type="button" type="button"
aria-label="Select font: {font.label}"
aria-pressed={value === font.family}
class="flex flex-col items-center gap-1.5 rounded-xl border p-3 class="flex flex-col items-center gap-1.5 rounded-xl border p-3
transition-all duration-150 transition-all duration-150
{value === font.family {value === font.family
@@ -88,7 +92,7 @@
> >
25:00 25:00
</span> </span>
<span class="text-[9px] tracking-wider text-[#555] uppercase"> <span class="text-[9px] tracking-wider text-[#8a8a8a] uppercase">
{font.label} {font.label}
</span> </span>
</button> </button>

View File

@@ -192,8 +192,7 @@ const fontStyle = $derived(
}); });
</script> </script>
<!-- svelte-ignore a11y_no_static_element_interactions --> <div class="w-full h-full flex items-center justify-center overflow-hidden" role="status" aria-label="Mini timer: {timeText} {state === 'breakActive' ? 'break active' : state === 'running' ? 'running' : 'paused'}">
<div class="w-full h-full flex items-center justify-center overflow-hidden">
<div <div
style=" style="
width: {100 / zoomScale}%; width: {100 / zoomScale}%;
@@ -206,8 +205,10 @@ const fontStyle = $derived(
class="flex items-center justify-center w-full h-full" class="flex items-center justify-center w-full h-full"
style="padding: 22px 14px 22px 24px;" style="padding: 22px 14px 22px 24px;"
> >
<!-- svelte-ignore a11y_no_static_element_interactions a11y_no_noninteractive_element_interactions -->
<div <div
class="mini-pill flex h-full w-full items-center select-none" class="mini-pill flex h-full w-full items-center select-none"
role="application"
class:mini-draggable={draggable} class:mini-draggable={draggable}
style=" style="
background: rgba(0, 0, 0, 0.85); background: rgba(0, 0, 0, 0.85);
@@ -226,6 +227,7 @@ const fontStyle = $derived(
<div class="relative flex-shrink-0" style="width: {ringSize}px; height: {ringSize}px;"> <div class="relative flex-shrink-0" style="width: {ringSize}px; height: {ringSize}px;">
<!-- Glow SVG (larger for blur room) --> <!-- Glow SVG (larger for blur room) -->
<svg <svg
aria-hidden="true"
width={viewSize} width={viewSize}
height={viewSize} height={viewSize}
class="pointer-events-none absolute" class="pointer-events-none absolute"
@@ -295,6 +297,7 @@ const fontStyle = $derived(
<!-- Non-glow SVG: track + crisp ring --> <!-- Non-glow SVG: track + crisp ring -->
<svg <svg
aria-hidden="true"
width={ringSize} width={ringSize}
height={ringSize} height={ringSize}
class="absolute" class="absolute"

View File

@@ -99,10 +99,11 @@
aria-label="Back to dashboard" aria-label="Back to dashboard"
use:pressable use:pressable
class="mr-3 flex h-8 w-8 items-center justify-center rounded-full class="mr-3 flex h-8 w-8 items-center justify-center rounded-full
text-[#444] transition-colors hover:text-white" text-[#8a8a8a] transition-colors hover:text-white"
onclick={goBack} onclick={goBack}
> >
<svg <svg
aria-hidden="true"
width="16" width="16"
height="16" height="16"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@@ -117,6 +118,7 @@
</button> </button>
<h1 <h1
data-tauri-drag-region data-tauri-drag-region
tabindex="-1"
class="flex-1 text-lg font-medium text-white" class="flex-1 text-lg font-medium text-white"
> >
Settings Settings
@@ -132,7 +134,7 @@
<!-- Timer --> <!-- Timer -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0 }}> <section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0 }}>
<h3 <h3
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#666] uppercase" class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase"
> >
Timer Timer
</h3> </h3>
@@ -140,12 +142,13 @@
<div class="flex items-center"> <div class="flex items-center">
<div class="flex-1"> <div class="flex-1">
<div class="text-[13px] text-white">Break frequency</div> <div class="text-[13px] text-white">Break frequency</div>
<div class="text-[11px] text-[#777]"> <div class="text-[11px] text-[#8a8a8a]">
Every {$config.break_frequency} min Every {$config.break_frequency} min
</div> </div>
</div> </div>
<Stepper <Stepper
bind:value={$config.break_frequency} bind:value={$config.break_frequency}
label="Break frequency"
min={5} min={5}
max={120} max={120}
onchange={markChanged} onchange={markChanged}
@@ -157,12 +160,13 @@
<div class="flex items-center"> <div class="flex items-center">
<div class="flex-1"> <div class="flex-1">
<div class="text-[13px] text-white">Break duration</div> <div class="text-[13px] text-white">Break duration</div>
<div class="text-[11px] text-[#777]"> <div class="text-[11px] text-[#8a8a8a]">
{$config.break_duration} min {$config.break_duration} min
</div> </div>
</div> </div>
<Stepper <Stepper
bind:value={$config.break_duration} bind:value={$config.break_duration}
label="Break duration"
min={1} min={1}
max={60} max={60}
onchange={markChanged} onchange={markChanged}
@@ -174,10 +178,11 @@
<div class="flex items-center"> <div class="flex items-center">
<div class="flex-1"> <div class="flex-1">
<div class="text-[13px] text-white">Auto-start</div> <div class="text-[13px] text-white">Auto-start</div>
<div class="text-[11px] text-[#777]">Start timer on launch</div> <div class="text-[11px] text-[#8a8a8a]">Start timer on launch</div>
</div> </div>
<ToggleSwitch <ToggleSwitch
bind:checked={$config.auto_start} bind:checked={$config.auto_start}
label="Auto-start"
onchange={markChanged} onchange={markChanged}
/> />
</div> </div>
@@ -186,7 +191,7 @@
<!-- Break Screen --> <!-- Break Screen -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.06 }}> <section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.06 }}>
<h3 <h3
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#666] uppercase" class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase"
> >
Break Screen Break Screen
</h3> </h3>
@@ -230,12 +235,13 @@
<div class="flex items-center"> <div class="flex items-center">
<div class="flex-1"> <div class="flex-1">
<div class="text-[13px] text-white">Fullscreen break</div> <div class="text-[13px] text-white">Fullscreen break</div>
<div class="text-[11px] text-[#777]"> <div class="text-[11px] text-[#8a8a8a]">
{$config.fullscreen_mode ? "Fills entire screen" : "Centered modal"} {$config.fullscreen_mode ? "Fills entire screen" : "Centered modal"}
</div> </div>
</div> </div>
<ToggleSwitch <ToggleSwitch
bind:checked={$config.fullscreen_mode} bind:checked={$config.fullscreen_mode}
label="Fullscreen break"
onchange={markChanged} onchange={markChanged}
/> />
</div> </div>
@@ -245,12 +251,13 @@
<div class="flex items-center"> <div class="flex items-center">
<div class="flex-1"> <div class="flex-1">
<div class="text-[13px] text-white">Activity suggestions</div> <div class="text-[13px] text-white">Activity suggestions</div>
<div class="text-[11px] text-[#777]"> <div class="text-[11px] text-[#8a8a8a]">
Exercise ideas during breaks Exercise ideas during breaks
</div> </div>
</div> </div>
<ToggleSwitch <ToggleSwitch
bind:checked={$config.show_break_activities} bind:checked={$config.show_break_activities}
label="Activity suggestions"
onchange={markChanged} onchange={markChanged}
/> />
</div> </div>
@@ -259,7 +266,7 @@
<!-- Behavior --> <!-- Behavior -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.12 }}> <section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.12 }}>
<h3 <h3
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#666] uppercase" class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase"
> >
Behavior Behavior
</h3> </h3>
@@ -267,12 +274,13 @@
<div class="flex items-center"> <div class="flex items-center">
<div class="flex-1"> <div class="flex-1">
<div class="text-[13px] text-white">Strict mode</div> <div class="text-[13px] text-white">Strict mode</div>
<div class="text-[11px] text-[#777]"> <div class="text-[11px] text-[#8a8a8a]">
Disable skip and snooze Disable skip and snooze
</div> </div>
</div> </div>
<ToggleSwitch <ToggleSwitch
bind:checked={$config.strict_mode} bind:checked={$config.strict_mode}
label="Strict mode"
onchange={markChanged} onchange={markChanged}
/> />
</div> </div>
@@ -283,10 +291,11 @@
<div class="flex items-center"> <div class="flex items-center">
<div class="flex-1"> <div class="flex-1">
<div class="text-[13px] text-white">Allow end early</div> <div class="text-[13px] text-white">Allow end early</div>
<div class="text-[11px] text-[#777]">After 50% of break</div> <div class="text-[11px] text-[#8a8a8a]">After 50% of break</div>
</div> </div>
<ToggleSwitch <ToggleSwitch
bind:checked={$config.allow_end_early} bind:checked={$config.allow_end_early}
label="Allow end early"
onchange={markChanged} onchange={markChanged}
/> />
</div> </div>
@@ -296,12 +305,13 @@
<div class="flex items-center"> <div class="flex items-center">
<div class="flex-1"> <div class="flex-1">
<div class="text-[13px] text-white">Snooze duration</div> <div class="text-[13px] text-white">Snooze duration</div>
<div class="text-[11px] text-[#777]"> <div class="text-[11px] text-[#8a8a8a]">
{$config.snooze_duration} min {$config.snooze_duration} min
</div> </div>
</div> </div>
<Stepper <Stepper
bind:value={$config.snooze_duration} bind:value={$config.snooze_duration}
label="Snooze duration"
min={1} min={1}
max={30} max={30}
onchange={markChanged} onchange={markChanged}
@@ -313,7 +323,7 @@
<div class="flex items-center"> <div class="flex items-center">
<div class="flex-1"> <div class="flex-1">
<div class="text-[13px] text-white">Snooze limit</div> <div class="text-[13px] text-white">Snooze limit</div>
<div class="text-[11px] text-[#777]"> <div class="text-[11px] text-[#8a8a8a]">
{$config.snooze_limit === 0 {$config.snooze_limit === 0
? "Unlimited" ? "Unlimited"
: `${$config.snooze_limit} per break`} : `${$config.snooze_limit} per break`}
@@ -321,6 +331,7 @@
</div> </div>
<Stepper <Stepper
bind:value={$config.snooze_limit} bind:value={$config.snooze_limit}
label="Snooze limit"
min={0} min={0}
max={5} max={5}
formatValue={(v) => (v === 0 ? "\u221E" : String(v))} formatValue={(v) => (v === 0 ? "\u221E" : String(v))}
@@ -334,12 +345,13 @@
<div class="flex items-center"> <div class="flex items-center">
<div class="flex-1"> <div class="flex-1">
<div class="text-[13px] text-white">Immediate breaks</div> <div class="text-[13px] text-white">Immediate breaks</div>
<div class="text-[11px] text-[#777]"> <div class="text-[11px] text-[#8a8a8a]">
Skip pre-break warning Skip pre-break warning
</div> </div>
</div> </div>
<ToggleSwitch <ToggleSwitch
bind:checked={$config.immediately_start_breaks} bind:checked={$config.immediately_start_breaks}
label="Immediate breaks"
onchange={markChanged} onchange={markChanged}
/> />
</div> </div>
@@ -350,12 +362,13 @@
<div class="flex items-center"> <div class="flex items-center">
<div class="flex-1"> <div class="flex-1">
<div class="text-[13px] text-white">Working hours</div> <div class="text-[13px] text-white">Working hours</div>
<div class="text-[11px] text-[#777]"> <div class="text-[11px] text-[#8a8a8a]">
Only show breaks during your configured work schedule Only show breaks during your configured work schedule
</div> </div>
</div> </div>
<ToggleSwitch <ToggleSwitch
bind:checked={$config.working_hours_enabled} bind:checked={$config.working_hours_enabled}
label="Working hours"
onchange={markChanged} onchange={markChanged}
/> />
</div> </div>
@@ -370,6 +383,7 @@
<div class="flex items-center gap-3 mb-3"> <div class="flex items-center gap-3 mb-3">
<ToggleSwitch <ToggleSwitch
bind:checked={$config.working_hours_schedule[dayIndex].enabled} bind:checked={$config.working_hours_schedule[dayIndex].enabled}
label={dayName}
onchange={markChanged} onchange={markChanged}
/> />
<span class="text-[13px] text-white w-20">{dayName}</span> <span class="text-[13px] text-white w-20">{dayName}</span>
@@ -448,7 +462,7 @@
<!-- Idle Detection --> <!-- Idle Detection -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.21 }}> <section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.21 }}>
<h3 <h3
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#666] uppercase" class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase"
> >
Idle Detection Idle Detection
</h3> </h3>
@@ -456,10 +470,11 @@
<div class="flex items-center"> <div class="flex items-center">
<div class="flex-1"> <div class="flex-1">
<div class="text-[13px] text-white">Auto-pause when idle</div> <div class="text-[13px] text-white">Auto-pause when idle</div>
<div class="text-[11px] text-[#777]">Pause timer when away</div> <div class="text-[11px] text-[#8a8a8a]">Pause timer when away</div>
</div> </div>
<ToggleSwitch <ToggleSwitch
bind:checked={$config.idle_detection_enabled} bind:checked={$config.idle_detection_enabled}
label="Auto-pause when idle"
onchange={markChanged} onchange={markChanged}
/> />
</div> </div>
@@ -470,12 +485,13 @@
<div class="flex items-center"> <div class="flex items-center">
<div class="flex-1"> <div class="flex-1">
<div class="text-[13px] text-white">Idle timeout</div> <div class="text-[13px] text-white">Idle timeout</div>
<div class="text-[11px] text-[#777]"> <div class="text-[11px] text-[#8a8a8a]">
{$config.idle_timeout}s of inactivity {$config.idle_timeout}s of inactivity
</div> </div>
</div> </div>
<Stepper <Stepper
bind:value={$config.idle_timeout} bind:value={$config.idle_timeout}
label="Idle timeout"
min={30} min={30}
max={600} max={600}
step={30} step={30}
@@ -489,7 +505,7 @@
<!-- Smart Breaks --> <!-- Smart Breaks -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.24 }}> <section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.24 }}>
<h3 <h3
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#666] uppercase" class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase"
> >
Smart Breaks Smart Breaks
</h3> </h3>
@@ -497,10 +513,11 @@
<div class="flex items-center"> <div class="flex items-center">
<div class="flex-1"> <div class="flex-1">
<div class="text-[13px] text-white">Enable smart breaks</div> <div class="text-[13px] text-white">Enable smart breaks</div>
<div class="text-[11px] text-[#777]">Auto-reset timer when you step away</div> <div class="text-[11px] text-[#8a8a8a]">Auto-reset timer when you step away</div>
</div> </div>
<ToggleSwitch <ToggleSwitch
bind:checked={$config.smart_breaks_enabled} bind:checked={$config.smart_breaks_enabled}
label="Enable smart breaks"
onchange={markChanged} onchange={markChanged}
/> />
</div> </div>
@@ -511,7 +528,7 @@
<div class="flex items-center"> <div class="flex items-center">
<div class="flex-1"> <div class="flex-1">
<div class="text-[13px] text-white">Minimum away time</div> <div class="text-[13px] text-white">Minimum away time</div>
<div class="text-[11px] text-[#777]"> <div class="text-[11px] text-[#8a8a8a]">
{$config.smart_break_threshold >= 60 {$config.smart_break_threshold >= 60
? `${Math.floor($config.smart_break_threshold / 60)} min` ? `${Math.floor($config.smart_break_threshold / 60)} min`
: `${$config.smart_break_threshold}s`} to count as break : `${$config.smart_break_threshold}s`} to count as break
@@ -519,6 +536,7 @@
</div> </div>
<Stepper <Stepper
bind:value={$config.smart_break_threshold} bind:value={$config.smart_break_threshold}
label="Minimum away time"
min={120} min={120}
max={900} max={900}
step={60} step={60}
@@ -532,10 +550,11 @@
<div class="flex items-center"> <div class="flex items-center">
<div class="flex-1"> <div class="flex-1">
<div class="text-[13px] text-white">Count in statistics</div> <div class="text-[13px] text-white">Count in statistics</div>
<div class="text-[11px] text-[#777]">Track natural breaks in stats</div> <div class="text-[11px] text-[#8a8a8a]">Track natural breaks in stats</div>
</div> </div>
<ToggleSwitch <ToggleSwitch
bind:checked={$config.smart_break_count_stats} bind:checked={$config.smart_break_count_stats}
label="Count in statistics"
onchange={markChanged} onchange={markChanged}
/> />
</div> </div>
@@ -545,7 +564,7 @@
<!-- Notifications --> <!-- Notifications -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.27 }}> <section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.27 }}>
<h3 <h3
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#666] uppercase" class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase"
> >
Notifications Notifications
</h3> </h3>
@@ -553,10 +572,11 @@
<div class="flex items-center"> <div class="flex items-center">
<div class="flex-1"> <div class="flex-1">
<div class="text-[13px] text-white">Pre-break alert</div> <div class="text-[13px] text-white">Pre-break alert</div>
<div class="text-[11px] text-[#777]">Warn before breaks</div> <div class="text-[11px] text-[#8a8a8a]">Warn before breaks</div>
</div> </div>
<ToggleSwitch <ToggleSwitch
bind:checked={$config.notification_enabled} bind:checked={$config.notification_enabled}
label="Pre-break alert"
onchange={markChanged} onchange={markChanged}
/> />
</div> </div>
@@ -567,12 +587,13 @@
<div class="flex items-center"> <div class="flex items-center">
<div class="flex-1"> <div class="flex-1">
<div class="text-[13px] text-white">Alert timing</div> <div class="text-[13px] text-white">Alert timing</div>
<div class="text-[11px] text-[#777]"> <div class="text-[11px] text-[#8a8a8a]">
{$config.notification_before_break}s before {$config.notification_before_break}s before
</div> </div>
</div> </div>
<Stepper <Stepper
bind:value={$config.notification_before_break} bind:value={$config.notification_before_break}
label="Alert timing"
min={0} min={0}
max={300} max={300}
step={10} step={10}
@@ -585,7 +606,7 @@
<!-- Sound --> <!-- Sound -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.27 }}> <section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.27 }}>
<h3 <h3
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#666] uppercase" class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase"
> >
Sound Sound
</h3> </h3>
@@ -593,10 +614,11 @@
<div class="flex items-center"> <div class="flex items-center">
<div class="flex-1"> <div class="flex-1">
<div class="text-[13px] text-white">Sound effects</div> <div class="text-[13px] text-white">Sound effects</div>
<div class="text-[11px] text-[#777]">Play sounds on break events</div> <div class="text-[11px] text-[#8a8a8a]">Play sounds on break events</div>
</div> </div>
<ToggleSwitch <ToggleSwitch
bind:checked={$config.sound_enabled} bind:checked={$config.sound_enabled}
label="Sound effects"
onchange={markChanged} onchange={markChanged}
/> />
</div> </div>
@@ -607,10 +629,11 @@
<div class="flex items-center"> <div class="flex items-center">
<div class="flex-1"> <div class="flex-1">
<div class="text-[13px] text-white">Volume</div> <div class="text-[13px] text-white">Volume</div>
<div class="text-[11px] text-[#777]">{$config.sound_volume}%</div> <div class="text-[11px] text-[#8a8a8a]">{$config.sound_volume}%</div>
</div> </div>
<Stepper <Stepper
bind:value={$config.sound_volume} bind:value={$config.sound_volume}
label="Volume"
min={0} min={0}
max={100} max={100}
step={10} step={10}
@@ -649,7 +672,7 @@
<!-- Appearance --> <!-- Appearance -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.3 }}> <section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.3 }}>
<h3 <h3
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#666] uppercase" class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase"
> >
Appearance Appearance
</h3> </h3>
@@ -657,12 +680,13 @@
<div class="flex items-center"> <div class="flex items-center">
<div class="flex-1"> <div class="flex-1">
<div class="text-[13px] text-white">UI zoom</div> <div class="text-[13px] text-white">UI zoom</div>
<div class="text-[11px] text-[#777]"> <div class="text-[11px] text-[#8a8a8a]">
{$config.ui_zoom}% {$config.ui_zoom}%
</div> </div>
</div> </div>
<Stepper <Stepper
bind:value={$config.ui_zoom} bind:value={$config.ui_zoom}
label="UI zoom"
min={50} min={50}
max={200} max={200}
step={5} step={5}
@@ -705,12 +729,13 @@
<div class="flex items-center"> <div class="flex items-center">
<div class="flex-1"> <div class="flex-1">
<div class="text-[13px] text-white">Animated background</div> <div class="text-[13px] text-white">Animated background</div>
<div class="text-[11px] text-[#777]"> <div class="text-[11px] text-[#8a8a8a]">
Gradient blobs with film grain Gradient blobs with film grain
</div> </div>
</div> </div>
<ToggleSwitch <ToggleSwitch
bind:checked={$config.background_blobs_enabled} bind:checked={$config.background_blobs_enabled}
label="Animated background"
onchange={markChanged} onchange={markChanged}
/> />
</div> </div>
@@ -719,7 +744,7 @@
<!-- Mini Mode --> <!-- Mini Mode -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.33 }}> <section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.33 }}>
<h3 <h3
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#666] uppercase" class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase"
> >
Mini Mode Mini Mode
</h3> </h3>
@@ -727,12 +752,13 @@
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<div class="text-[13px] text-white">Click-through</div> <div class="text-[13px] text-white">Click-through</div>
<div class="text-[11px] text-[#555]"> <div class="text-[11px] text-[#8a8a8a]">
Mini timer ignores clicks until you hover over it Mini timer ignores clicks until you hover over it
</div> </div>
</div> </div>
<ToggleSwitch <ToggleSwitch
bind:checked={$config.mini_click_through} bind:checked={$config.mini_click_through}
label="Click-through"
onchange={markChanged} onchange={markChanged}
/> />
</div> </div>
@@ -741,12 +767,13 @@
<div class="mt-4 flex items-center justify-between"> <div class="mt-4 flex items-center justify-between">
<div> <div>
<div class="text-[13px] text-white">Hover delay</div> <div class="text-[13px] text-white">Hover delay</div>
<div class="text-[11px] text-[#555]"> <div class="text-[11px] text-[#8a8a8a]">
Seconds to hover before it becomes draggable Seconds to hover before it becomes draggable
</div> </div>
</div> </div>
<Stepper <Stepper
bind:value={$config.mini_hover_threshold} bind:value={$config.mini_hover_threshold}
label="Hover delay"
min={1} min={1}
max={10} max={10}
step={0.5} step={0.5}
@@ -760,7 +787,7 @@
<!-- Shortcuts --> <!-- Shortcuts -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.36 }}> <section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.36 }}>
<h3 <h3
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#666] uppercase" class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase"
> >
Keyboard Shortcuts Keyboard Shortcuts
</h3> </h3>
@@ -768,15 +795,15 @@
<div class="space-y-3"> <div class="space-y-3">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-[13px] text-white">Pause / Resume</span> <span class="text-[13px] text-white">Pause / Resume</span>
<kbd class="rounded-lg bg-[#161616] px-2.5 py-1 text-[11px] text-[#666]">Ctrl+Shift+P</kbd> <kbd class="rounded-lg bg-[#161616] px-2.5 py-1 text-[11px] text-[#8a8a8a]">Ctrl+Shift+P</kbd>
</div> </div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-[13px] text-white">Start break now</span> <span class="text-[13px] text-white">Start break now</span>
<kbd class="rounded-lg bg-[#161616] px-2.5 py-1 text-[11px] text-[#666]">Ctrl+Shift+B</kbd> <kbd class="rounded-lg bg-[#161616] px-2.5 py-1 text-[11px] text-[#8a8a8a]">Ctrl+Shift+B</kbd>
</div> </div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-[13px] text-white">Show / Hide window</span> <span class="text-[13px] text-white">Show / Hide window</span>
<kbd class="rounded-lg bg-[#161616] px-2.5 py-1 text-[11px] text-[#666]">Ctrl+Shift+S</kbd> <kbd class="rounded-lg bg-[#161616] px-2.5 py-1 text-[11px] text-[#8a8a8a]">Ctrl+Shift+S</kbd>
</div> </div>
</div> </div>
</section> </section>

View File

@@ -109,7 +109,7 @@
} }
// Day label // Day label
ctx.fillStyle = "#444"; ctx.fillStyle = "#8a8a8a";
ctx.font = "10px -apple-system, sans-serif"; ctx.font = "10px -apple-system, sans-serif";
ctx.textAlign = "center"; ctx.textAlign = "center";
const label = day.date.slice(5); // "MM-DD" const label = day.date.slice(5); // "MM-DD"
@@ -117,6 +117,14 @@
}); });
} }
// Accessible chart summary
const chartAriaLabel = $derived(() => {
if (history.length === 0) return "No break history data available";
const total = history.reduce((sum, d) => sum + d.breaksCompleted, 0);
const skipped = history.reduce((sum, d) => sum + d.breaksSkipped, 0);
return `Weekly break chart: ${total} completed and ${skipped} skipped over ${history.length} days`;
});
function roundedRect( function roundedRect(
ctx: CanvasRenderingContext2D, ctx: CanvasRenderingContext2D,
x: number, x: number,
@@ -149,10 +157,11 @@
aria-label="Back to dashboard" aria-label="Back to dashboard"
use:pressable use:pressable
class="mr-3 flex h-8 w-8 items-center justify-center rounded-full class="mr-3 flex h-8 w-8 items-center justify-center rounded-full
text-[#444] transition-colors hover:text-white" text-[#8a8a8a] transition-colors hover:text-white"
onclick={goBack} onclick={goBack}
> >
<svg <svg
aria-hidden="true"
width="16" width="16"
height="16" height="16"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@@ -167,6 +176,7 @@
</button> </button>
<h1 <h1
data-tauri-drag-region data-tauri-drag-region
tabindex="-1"
class="flex-1 text-lg font-medium text-white" class="flex-1 text-lg font-medium text-white"
> >
Statistics Statistics
@@ -179,7 +189,7 @@
<!-- Today's summary --> <!-- Today's summary -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0 }}> <section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0 }}>
<h3 <h3
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#666] uppercase" class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase"
> >
Today Today
</h3> </h3>
@@ -189,7 +199,7 @@
<div class="text-[28px] font-semibold text-white tabular-nums"> <div class="text-[28px] font-semibold text-white tabular-nums">
{stats?.todayCompleted ?? 0} {stats?.todayCompleted ?? 0}
</div> </div>
<div class="text-[11px] text-[#777]">Breaks taken</div> <div class="text-[11px] text-[#8a8a8a]">Breaks taken</div>
</div> </div>
<div class="text-center"> <div class="text-center">
<div class="text-[28px] font-semibold tabular-nums" <div class="text-[28px] font-semibold tabular-nums"
@@ -197,19 +207,19 @@
> >
{compliancePercent}% {compliancePercent}%
</div> </div>
<div class="text-[11px] text-[#777]">Compliance</div> <div class="text-[11px] text-[#8a8a8a]">Compliance</div>
</div> </div>
<div class="text-center"> <div class="text-center">
<div class="text-[28px] font-semibold text-white tabular-nums"> <div class="text-[28px] font-semibold text-white tabular-nums">
{breakTimeFormatted()} {breakTimeFormatted()}
</div> </div>
<div class="text-[11px] text-[#777]">Break time</div> <div class="text-[11px] text-[#8a8a8a]">Break time</div>
</div> </div>
<div class="text-center"> <div class="text-center">
<div class="text-[28px] font-semibold text-white tabular-nums"> <div class="text-[28px] font-semibold text-white tabular-nums">
{stats?.todaySkipped ?? 0} {stats?.todaySkipped ?? 0}
</div> </div>
<div class="text-[11px] text-[#777]">Skipped</div> <div class="text-[11px] text-[#8a8a8a]">Skipped</div>
</div> </div>
</div> </div>
</section> </section>
@@ -217,7 +227,7 @@
<!-- Streak --> <!-- Streak -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.06 }}> <section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.06 }}>
<h3 <h3
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#666] uppercase" class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase"
> >
Streak Streak
</h3> </h3>
@@ -225,7 +235,7 @@
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<div class="text-[13px] text-white">Current streak</div> <div class="text-[13px] text-white">Current streak</div>
<div class="text-[11px] text-[#777]">Consecutive days with breaks</div> <div class="text-[11px] text-[#8a8a8a]">Consecutive days with breaks</div>
</div> </div>
<div class="text-[24px] font-semibold tabular-nums" style="color: {$config.accent_color}"> <div class="text-[24px] font-semibold tabular-nums" style="color: {$config.accent_color}">
{stats?.currentStreak ?? 0} {stats?.currentStreak ?? 0}
@@ -237,7 +247,7 @@
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<div class="text-[13px] text-white">Best streak</div> <div class="text-[13px] text-white">Best streak</div>
<div class="text-[11px] text-[#777]">All-time record</div> <div class="text-[11px] text-[#8a8a8a]">All-time record</div>
</div> </div>
<div class="text-[24px] font-semibold text-white tabular-nums"> <div class="text-[24px] font-semibold text-white tabular-nums">
{stats?.bestStreak ?? 0} {stats?.bestStreak ?? 0}
@@ -248,17 +258,39 @@
<!-- Weekly chart --> <!-- Weekly chart -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.12 }}> <section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.12 }}>
<h3 <h3
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#666] uppercase" class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase"
> >
Last 7 Days Last 7 Days
</h3> </h3>
<!-- svelte-ignore a11y_no_interactive_element_to_noninteractive_role -->
<canvas <canvas
bind:this={chartCanvas} bind:this={chartCanvas}
class="h-[140px] w-full" class="h-[140px] w-full"
role="img"
aria-label={chartAriaLabel()}
></canvas> ></canvas>
<div class="mt-3 flex items-center justify-center gap-4 text-[10px] text-[#777]"> <!-- Screen-reader accessible data table for the chart -->
{#if history.length > 0}
<table class="sr-only">
<caption>Break history for the last {history.length} days</caption>
<thead>
<tr><th>Date</th><th>Completed</th><th>Skipped</th></tr>
</thead>
<tbody>
{#each history as day}
<tr>
<td>{day.date}</td>
<td>{day.breaksCompleted}</td>
<td>{day.breaksSkipped}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
<div class="mt-3 flex items-center justify-center gap-4 text-[10px] text-[#8a8a8a]">
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
<div class="h-2 w-2 rounded-sm" style="background: {$config.accent_color}"></div> <div class="h-2 w-2 rounded-sm" style="background: {$config.accent_color}"></div>
Completed Completed

View File

@@ -6,6 +6,7 @@
step?: number; step?: number;
formatValue?: (v: number) => string; formatValue?: (v: number) => string;
onchange?: (value: number) => void; onchange?: (value: number) => void;
label?: string;
} }
let { let {
@@ -15,6 +16,7 @@
step = 1, step = 1,
formatValue = (v: number) => String(v), formatValue = (v: number) => String(v),
onchange, onchange,
label = "Value",
}: Props = $props(); }: Props = $props();
let holdTimer: ReturnType<typeof setTimeout> | null = null; let holdTimer: ReturnType<typeof setTimeout> | null = null;
@@ -55,32 +57,36 @@
} }
</script> </script>
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5" role="group" aria-label={label}>
<button <button
type="button" type="button"
aria-label="Decrease"
class="flex h-7 w-7 items-center justify-center rounded-lg class="flex h-7 w-7 items-center justify-center rounded-lg
bg-[#141414] text-[#444] transition-colors bg-[#141414] text-[#999] transition-colors
hover:bg-[#1c1c1c] hover:text-white hover:bg-[#1c1c1c] hover:text-white
disabled:opacity-20" disabled:opacity-20"
onmousedown={() => startHold(decrement)} onmousedown={() => startHold(decrement)}
onmouseup={stopHold} onmouseup={stopHold}
onmouseleave={stopHold} onmouseleave={stopHold}
onclick={(e) => { if (e.detail === 0) decrement(); }}
disabled={value <= min} disabled={value <= min}
> >
&minus; &minus;
</button> </button>
<span class="min-w-[38px] text-center text-[13px] tabular-nums text-white"> <span class="min-w-[38px] text-center text-[13px] tabular-nums text-white" aria-live="polite" aria-atomic="true">
{formatValue(value)} {formatValue(value)}
</span> </span>
<button <button
type="button" type="button"
aria-label="Increase"
class="flex h-7 w-7 items-center justify-center rounded-lg class="flex h-7 w-7 items-center justify-center rounded-lg
bg-[#141414] text-[#444] transition-colors bg-[#141414] text-[#999] transition-colors
hover:bg-[#1c1c1c] hover:text-white hover:bg-[#1c1c1c] hover:text-white
disabled:opacity-20" disabled:opacity-20"
onmousedown={() => startHold(increment)} onmousedown={() => startHold(increment)}
onmouseup={stopHold} onmouseup={stopHold}
onmouseleave={stopHold} onmouseleave={stopHold}
onclick={(e) => { if (e.detail === 0) increment(); }}
disabled={value >= max} disabled={value >= max}
> >
+ +

View File

@@ -247,6 +247,31 @@
function format(n: number): string { function format(n: number): string {
return String(n).padStart(2, "0"); return String(n).padStart(2, "0");
} }
// Keyboard handlers for arrow key operation
function handleHoursKeydown(e: KeyboardEvent) {
if (e.key === "ArrowUp") {
e.preventDefault();
displayHours = wrapValue(displayHours + 1, 24);
emitValue(displayHours, displayMinutes);
} else if (e.key === "ArrowDown") {
e.preventDefault();
displayHours = wrapValue(displayHours - 1, 24);
emitValue(displayHours, displayMinutes);
}
}
function handleMinutesKeydown(e: KeyboardEvent) {
if (e.key === "ArrowUp") {
e.preventDefault();
displayMinutes = wrapValue(displayMinutes + 1, 60);
emitValue(displayHours, displayMinutes);
} else if (e.key === "ArrowDown") {
e.preventDefault();
displayMinutes = wrapValue(displayMinutes - 1, 60);
emitValue(displayHours, displayMinutes);
}
}
</script> </script>
<div class="time-spinner" style="font-family: {countdownFont ? `'${countdownFont}', monospace` : 'monospace'};"> <div class="time-spinner" style="font-family: {countdownFont ? `'${countdownFont}', monospace` : 'monospace'};">
@@ -265,6 +290,7 @@
onpointermove={handleHoursPointerMove} onpointermove={handleHoursPointerMove}
onpointerup={handleHoursPointerUp} onpointerup={handleHoursPointerUp}
onpointercancel={handleHoursPointerUp} onpointercancel={handleHoursPointerUp}
onkeydown={handleHoursKeydown}
> >
<div class="wheel-viewport"> <div class="wheel-viewport">
<div class="wheel-cylinder"> <div class="wheel-cylinder">
@@ -300,6 +326,7 @@
onpointermove={handleMinutesPointerMove} onpointermove={handleMinutesPointerMove}
onpointerup={handleMinutesPointerUp} onpointerup={handleMinutesPointerUp}
onpointercancel={handleMinutesPointerUp} onpointercancel={handleMinutesPointerUp}
onkeydown={handleMinutesKeydown}
> >
<div class="wheel-viewport"> <div class="wheel-viewport">
<div class="wheel-cylinder"> <div class="wheel-cylinder">

View File

@@ -4,6 +4,8 @@
size?: number; size?: number;
strokeWidth?: number; strokeWidth?: number;
accentColor?: string; accentColor?: string;
label?: string;
valueText?: string;
children?: import("svelte").Snippet; children?: import("svelte").Snippet;
} }
@@ -12,6 +14,8 @@
size = 280, size = 280,
strokeWidth = 8, strokeWidth = 8,
accentColor = "#ff4d00", accentColor = "#ff4d00",
label = "Timer",
valueText = "",
children, children,
}: Props = $props(); }: Props = $props();
@@ -44,9 +48,16 @@
<div <div
class="relative flex items-center justify-center" class="relative flex items-center justify-center"
style="width: {size}px; height: {size}px;" style="width: {size}px; height: {size}px;"
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(progress * 100)}
aria-label={label}
aria-valuetext={valueText}
> >
<!-- Glow SVG - drawn larger than the container so blur isn't clipped --> <!-- Glow SVG - drawn larger than the container so blur isn't clipped -->
<svg <svg
aria-hidden="true"
width={viewSize} width={viewSize}
height={viewSize} height={viewSize}
class="pointer-events-none absolute" class="pointer-events-none absolute"
@@ -136,6 +147,7 @@
<!-- Non-glow SVG - exact size, draws the track + crisp ring --> <!-- Non-glow SVG - exact size, draws the track + crisp ring -->
<svg <svg
aria-hidden="true"
width={size} width={size}
height={size} height={size}
class="absolute" class="absolute"

View File

@@ -9,8 +9,9 @@
data-tauri-drag-region data-tauri-drag-region
class="group absolute top-0 left-0 right-0 z-50 flex h-10 items-center justify-end pr-3.5 select-none" class="group absolute top-0 left-0 right-0 z-50 flex h-10 items-center justify-end pr-3.5 select-none"
> >
<!-- Centered app name --> <!-- Centered app name (decorative - OS window title handles screen readers) -->
<span <span
aria-hidden="true"
class="pointer-events-none absolute inset-0 flex items-center justify-center class="pointer-events-none absolute inset-0 flex items-center justify-center
text-[11px] font-semibold tracking-[0.3em] text-[#383838] uppercase" text-[11px] font-semibold tracking-[0.3em] text-[#383838] uppercase"
style="font-family: 'Space Mono', monospace;" style="font-family: 'Space Mono', monospace;"
@@ -19,7 +20,7 @@
</span> </span>
<!-- Traffic light buttons (order: maximize, minimize, close → close is rightmost) --> <!-- Traffic light buttons (order: maximize, minimize, close → close is rightmost) -->
<div class="flex items-center gap-[8px] opacity-10 transition-opacity duration-300 group-hover:opacity-100"> <div class="flex items-center gap-[8px] opacity-10 transition-opacity duration-300 group-hover:opacity-100 group-focus-within:opacity-100">
<!-- Maximize (green) --> <!-- Maximize (green) -->
<button <button
aria-label="Maximize" aria-label="Maximize"

View File

@@ -4,9 +4,10 @@
interface Props { interface Props {
checked: boolean; checked: boolean;
onchange?: (value: boolean) => void; onchange?: (value: boolean) => void;
label?: string;
} }
let { checked = $bindable(), onchange }: Props = $props(); let { checked = $bindable(), onchange, label = "Toggle" }: Props = $props();
function toggle() { function toggle() {
checked = !checked; checked = !checked;
@@ -17,10 +18,10 @@
<button <button
type="button" type="button"
role="switch" role="switch"
aria-label="Toggle" aria-label={label}
aria-checked={checked} aria-checked={checked}
class="relative inline-flex h-[24px] w-[48px] shrink-0 cursor-pointer rounded-full class="relative inline-flex h-[24px] w-[48px] shrink-0 cursor-pointer rounded-full
transition-colors duration-200 ease-in-out focus:outline-none" transition-colors duration-200 ease-in-out"
style="background: {checked ? $config.accent_color : '#1a1a1a'};" style="background: {checked ? $config.accent_color : '#1a1a1a'};"
onclick={toggle} onclick={toggle}
> >

View File

@@ -1,5 +1,15 @@
import { animate } from "motion"; import { animate } from "motion";
// Module-level reduced motion query — shared across all actions
const reducedMotionQuery =
typeof window !== "undefined"
? window.matchMedia("(prefers-reduced-motion: reduce)")
: null;
function prefersReducedMotion(): boolean {
return reducedMotionQuery?.matches ?? false;
}
/** /**
* Svelte action: fade in + slide up on mount * Svelte action: fade in + slide up on mount
*/ */
@@ -7,6 +17,11 @@ export function fadeIn(
node: HTMLElement, node: HTMLElement,
options?: { duration?: number; delay?: number; y?: number }, options?: { duration?: number; delay?: number; y?: number },
) { ) {
if (prefersReducedMotion()) {
node.style.opacity = "1";
return { destroy() {} };
}
const { duration = 0.5, delay = 0, y = 15 } = options ?? {}; const { duration = 0.5, delay = 0, y = 15 } = options ?? {};
node.style.opacity = "0"; node.style.opacity = "0";
@@ -30,6 +45,11 @@ export function scaleIn(
node: HTMLElement, node: HTMLElement,
options?: { duration?: number; delay?: number }, options?: { duration?: number; delay?: number },
) { ) {
if (prefersReducedMotion()) {
node.style.opacity = "1";
return { destroy() {} };
}
const { duration = 0.6, delay = 0 } = options ?? {}; const { duration = 0.6, delay = 0 } = options ?? {};
node.style.opacity = "0"; node.style.opacity = "0";
@@ -53,6 +73,11 @@ export function inView(
node: HTMLElement, node: HTMLElement,
options?: { delay?: number; y?: number; threshold?: number }, options?: { delay?: number; y?: number; threshold?: number },
) { ) {
if (prefersReducedMotion()) {
node.style.opacity = "1";
return { destroy() {} };
}
const { delay = 0, y = 20, threshold = 0.05 } = options ?? {}; const { delay = 0, y = 20, threshold = 0.05 } = options ?? {};
node.style.opacity = "0"; node.style.opacity = "0";
node.style.transform = `translateY(${y}px)`; node.style.transform = `translateY(${y}px)`;
@@ -90,6 +115,10 @@ export function inView(
* Svelte action: spring-scale press feedback on buttons * Svelte action: spring-scale press feedback on buttons
*/ */
export function pressable(node: HTMLElement) { export function pressable(node: HTMLElement) {
if (prefersReducedMotion()) {
return { destroy() {} };
}
let active: ReturnType<typeof animate> | null = null; let active: ReturnType<typeof animate> | null = null;
function onDown() { function onDown() {
@@ -137,6 +166,10 @@ export function glowHover(
node: HTMLElement, node: HTMLElement,
options?: { color?: string }, options?: { color?: string },
) { ) {
if (prefersReducedMotion()) {
return { update() {}, destroy() {} };
}
let color = options?.color ?? "#ff4d00"; let color = options?.color ?? "#ff4d00";
let enterAnim: ReturnType<typeof animate> | null = null; let enterAnim: ReturnType<typeof animate> | null = null;
let leaveAnim: ReturnType<typeof animate> | null = null; let leaveAnim: ReturnType<typeof animate> | null = null;
@@ -191,6 +224,11 @@ export function glowHover(
* container itself (which would break overflow clipping). * container itself (which would break overflow clipping).
*/ */
export function dragScroll(node: HTMLElement) { export function dragScroll(node: HTMLElement) {
if (prefersReducedMotion()) {
// Allow normal scrolling without the momentum/elastic physics
return { destroy() {} };
}
const content = node.children[0] as HTMLElement | null; const content = node.children[0] as HTMLElement | null;
let isDown = false; let isDown = false;