13 Commits

Author SHA1 Message Date
Your Name
460bf2c613 Bump version to 0.1.2 2026-02-07 12:16:03 +02:00
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
d5ad1514d1 Update README.md 2026-02-07 09:35:24 +00:00
925a7d5516 Update README.md 2026-02-07 09:34:36 +00:00
Your Name
87ab035c68 Update README - fix download links, activity count, and accuracy
- Fix download links to point to Gitea releases page (not GitHub-style relative URLs)
- Correct activity count from 70 to 72
- Enlarge break screen screenshot
- Add missing IPC entries (natural-break-detected event, cursor/window commands)
- Add msvc_compat.rs to backend modules table
- Update winapi description to include WebView2 check dialog
- Remove unused dirs crate from dependencies
2026-02-07 11:24:16 +02:00
Your Name
6b584efb40 Bump version to 0.1.1 2026-02-07 11:16:29 +02:00
Your Name
b01dbd6c0b Tighten TimeSpinner spacing and fix build warnings
Reduce gap between numbers and h/m unit labels in the workday
schedule spinners. Remove unused parse_hour function and fix
Svelte state_referenced_locally warning in BreakScreen.
2026-02-07 11:16:29 +02:00
Your Name
d93d231a45 Enable custom-protocol for embedded frontend assets
Without this feature, Tauri falls back to devUrl even in release
builds. The tauri CLI adds it automatically but direct cargo
builds need it in Cargo.toml.
2026-02-07 11:00:45 +02:00
Your Name
4f4599c4c9 Fix WebView2 detection - use loader API instead of registry
Registry check gave false negatives on systems where WebView2
is installed through Edge rather than EdgeUpdate. Now calls
GetAvailableCoreWebView2BrowserVersionString (statically linked)
which detects all installation methods.
2026-02-07 10:51:49 +02:00
Your Name
e9021e51e5 Statically link WebView2Loader - single exe, no DLL needed
build.rs swaps the dynamic import library with the static archive
from webview2-com-sys, so the WebView2 loader code is baked into
the exe. msvc_compat.rs provides the MSVC CRT symbols (security
cookie, thread-safe init, C++ operators) that the MSVC-compiled
static library expects.
2026-02-07 02:32:54 +02:00
Your Name
37d0d638d5 Detect missing WebView2 at startup, show download link
Checks Windows registry for WebView2 Runtime before Tauri
initializes. If missing, shows a native MessageBox explaining
the requirement and opens the Microsoft download page.
2026-02-07 02:08:51 +02:00
Your Name
6bba2835bb Fix fullscreen break screen - centering, progress bar, ripple sizing
- Center break content on fullscreen (was top-left aligned)
- Move fullscreen progress bar to screen bottom (was overlapping buttons)
- Scale ripple circles to match ring size per mode (140/160/200px)
- Increase ripple expansion for more dramatic spread
2026-02-07 01:56:10 +02:00
Your Name
3aeb83f69b Add screenshots, move philosophy section above features 2026-02-07 01:36:43 +02:00
31 changed files with 798 additions and 293 deletions

124
README.md
View File

@@ -21,10 +21,11 @@
<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/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 align="center">
<a href="../../releases/latest"><strong>Download latest release</strong></a>
<a href="https://git.lashman.live/lashman/core-cooldown/releases"><strong>Download latest release</strong></a>
</p>
<br />
@@ -41,6 +42,62 @@ Core Cooldown is a single portable `.exe`. No installer, no account, no telemetr
---
## 💡 Philosophy
Core Cooldown exists because rest is not a luxury. It is a fundamental need that no productivity framework, no employer, and no software platform should gatekeep behind a paywall or a subscription.
This application:
- **Collects nothing.** No analytics, no telemetry, no usage tracking, no crash reports phoned home. Your break habits are your own business.
- **Costs nothing.** Not "free tier with limitations." Not "free for personal use." Free, unconditionally, for everyone.
- **Requires nothing.** No account, no email, no app store, no internet connection after download. It runs on your machine and answers to you alone.
- **Owns nothing.** Released under CC0 - the most complete relinquishment of rights possible under law. There is no owner. There are no restrictions. The code belongs to the commons.
Tools for human wellbeing should never be enclosed, never be scarce, and never serve a master other than the person using them.
<br />
---
## 🖼️ Screenshots
<p align="center">
<img src="screenshots/01-dashboard.png" alt="Dashboard - Main Timer" width="420" /><br />
<sub><strong>Dashboard</strong> - Focus timer with countdown ring, status pill, and quick controls</sub>
</p>
<br />
<p align="center">
<img src="screenshots/02-stats.png" alt="Statistics" width="420" /><br />
<sub><strong>Statistics</strong> - Daily summary, compliance rate, streaks, and 7-day history chart</sub>
</p>
<br />
<p align="center">
<img src="screenshots/03-settings.png" alt="Settings" width="420" /><br />
<sub><strong>Settings</strong> - Grouped configuration cards with live preview</sub>
</p>
<br />
<p align="center">
<img src="screenshots/04-break.png" alt="Break Screen" width="600" /><br />
<sub><strong>Break Screen</strong> - Always-on-top break overlay with activity suggestions</sub>
</p>
<br />
<p align="center">
<img src="screenshots/05-mini.png" alt="Mini Mode" width="300" /><br />
<sub><strong>Mini Mode</strong> - Compact floating timer, click-through until hovered</sub>
</p>
<br />
---
## Features
### ⏱️ Timer & Breaks
@@ -72,7 +129,7 @@ Core Cooldown is a single portable `.exe`. No installer, no account, no telemetr
### 🧘 Break Activities
Each break shows a randomized suggestion from a curated library of **70 activities** across four categories:
Each break shows a randomized suggestion from a curated library of **72 activities** across four categories:
| Category | Examples |
|:---------|:---------|
@@ -183,6 +240,23 @@ Native Windows toast notifications for:
<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
@@ -217,7 +291,7 @@ Core Cooldown is **fully portable**. The executable carries everything it needs
That's it. No elevated permissions. No runtime dependencies. The first launch may take a moment while Windows initializes the WebView2 runtime.
**[Download latest release →](../../releases/latest)**
**[Download latest release →](https://git.lashman.live/lashman/core-cooldown/releases)**
<br />
@@ -288,17 +362,17 @@ A split-architecture desktop app: Rust backend for system integration and timer
```
┌──────────────────────────────────────────────────────────────┐
🔲 System Tray │
System Tray
│ (dynamic icon · tooltip · menu) │
├──────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Main Window │ │ Break Window│ │ Mini Window │ │
│ │ (WebView) │ │ (WebView) │ │ (WebView) │ │
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Main Window │ │ Break Window│ │ Mini Window │ │
│ │ (WebView) │ │ (WebView) │ │ (WebView) │ │
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
│ │ │ │ │
│ └─────────┬───────┴──────────────────┘ │
│ │ Tauri IPC
│ │ Tauri IPC │
│ ┌─────────┴─────────┐ │
│ │ Rust Backend │ │
│ │ │ │
@@ -306,7 +380,7 @@ A split-architecture desktop app: Rust backend for system integration and timer
│ │ Config │ JSON persistence (portable) │
│ │ Stats │ break history tracking │
│ │ IdleDetector │ GetLastInputInfo polling │
│ │ GlobalShortcuts │ Ctrl+Shift+P/B/S │
│ │ GlobalShortcuts │ Ctrl+Shift+P/B/S
│ │ TrayIcon │ RGBA ring rendering │
│ │ Notifications │ Windows toast │
│ └───────────────────┘ │
@@ -323,7 +397,8 @@ A split-architecture desktop app: Rust backend for system integration and timer
| `config.rs` | Config struct with serde serialization, validation (clamping all values to safe ranges), portable file I/O |
| `timer.rs` | Timer state machine (`Running` / `Paused` / `BreakActive`), idle detection via Windows API, working hours enforcement |
| `stats.rs` | Daily break statistics, streak calculation, history queries |
| `main.rs` | Entry point |
| `main.rs` | Entry point, WebView2 Runtime detection |
| `msvc_compat.rs` | MSVC CRT compatibility stubs for static WebView2Loader linking on MinGW |
</details>
@@ -336,7 +411,7 @@ A split-architecture desktop app: Rust backend for system integration and timer
| **Windows** | `BreakWindow` (standalone break modal), `MiniTimer` (floating mini mode) |
| **Components** | `TimerRing`, `Titlebar`, `ToggleSwitch`, `Stepper`, `ColorPicker`, `FontSelector`, `TimeSpinner`, `BackgroundBlobs` |
| **Stores** | `timer.ts` (reactive timer state from IPC events), `config.ts` (config with debounced auto-save) |
| **Utilities** | `sounds.ts` (Web Audio synthesis), `activities.ts` (70 break activities), `animate.ts` (motion library) |
| **Utilities** | `sounds.ts` (Web Audio synthesis), `activities.ts` (72 break activities), `animate.ts` (motion library) |
</details>
@@ -344,10 +419,10 @@ A split-architecture desktop app: Rust backend for system integration and timer
<summary><strong>IPC contract</strong></summary>
**Commands** (frontend → backend):
`get_config` · `save_config` · `update_pending_config` · `reset_config` · `toggle_timer` · `start_break_now` · `cancel_break` · `snooze` · `get_timer_state` · `set_view` · `get_stats` · `get_daily_history`
`get_config` · `save_config` · `update_pending_config` · `reset_config` · `toggle_timer` · `start_break_now` · `cancel_break` · `snooze` · `get_timer_state` · `set_view` · `get_stats` · `get_daily_history` · `get_cursor_position` · `save_window_position`
**Events** (backend → frontend):
`timer-tick` · `break-started` · `break-ended` · `prebreak-warning` · `config-changed`
`timer-tick` · `break-started` · `break-ended` · `prebreak-warning` · `config-changed` · `natural-break-detected`
</details>
@@ -422,7 +497,7 @@ All settings stored in `config.json` next to the executable. The settings panel
| `serde` / `serde_json` | Config and stats serialization |
| `chrono` | Date/time handling for schedules and statistics |
| `anyhow` | Error handling |
| `winapi` | Windows idle detection (`GetLastInputInfo`) |
| `winapi` | Windows idle detection (`GetLastInputInfo`), WebView2 check dialog |
</details>
@@ -454,7 +529,7 @@ No contribution agreements to sign. No corporate CLAs. No licensing traps. Every
- 🐛 Report bugs or rough edges
- 🧘 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
- 🌍 Translate the interface
- 💌 Share it with someone who needs it
@@ -465,23 +540,6 @@ The best software is built through mutual aid - people helping people because it
---
## 💡 Philosophy
Core Cooldown exists because rest is not a luxury. It is a fundamental need that no productivity framework, no employer, and no software platform should gatekeep behind a paywall or a subscription.
This application:
- **Collects nothing.** No analytics, no telemetry, no usage tracking, no crash reports phoned home. Your break habits are your own business.
- **Costs nothing.** Not "free tier with limitations." Not "free for personal use." Free, unconditionally, for everyone.
- **Requires nothing.** No account, no email, no app store, no internet connection after download. It runs on your machine and answers to you alone.
- **Owns nothing.** Released under CC0 - the most complete relinquishment of rights possible under law. There is no owner. There are no restrictions. The code belongs to the commons.
Tools for human wellbeing should never be enclosed, never be scarce, and never serve a master other than the person using them.
<br />
---
## 📄 License
<p align="center">

View File

@@ -1,7 +1,7 @@
{
"name": "core-cooldown",
"private": true,
"version": "0.1.0",
"version": "0.1.2",
"type": "module",
"scripts": {
"dev": "vite",

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

BIN
screenshots/02-stats.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

BIN
screenshots/03-settings.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

BIN
screenshots/04-break.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

BIN
screenshots/05-mini.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

113
src-tauri/Cargo.lock generated
View File

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

View File

@@ -1,6 +1,6 @@
[package]
name = "core-cooldown"
version = "0.1.0"
version = "0.1.2"
edition = "2021"
[lib]
@@ -11,15 +11,14 @@ crate-type = ["lib", "staticlib"]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["tray-icon"] }
tauri = { version = "2", features = ["tray-icon", "custom-protocol"] }
tauri-plugin-shell = "2"
tauri-plugin-notification = "2"
tauri-plugin-global-shortcut = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
dirs = "5"
chrono = "0.4"
anyhow = "1"
[target.'cfg(windows)'.dependencies]
winapi = { version = "0.3", features = ["winuser", "sysinfoapi", "windef"] }
winapi = { version = "0.3", features = ["winuser", "sysinfoapi", "windef", "shellapi"] }

View File

@@ -7,5 +7,75 @@ fn main() {
std::env::set_var("PATH", mingw_bin);
}
// On GNU targets, replace the WebView2Loader import library with the static
// library so the loader is baked into the exe — no DLL to ship.
#[cfg(target_env = "gnu")]
swap_webview2_to_static();
tauri_build::build()
}
/// Replace `WebView2Loader.dll.lib` (dynamic import lib) with the contents of
/// `WebView2LoaderStatic.lib` (static archive) in the webview2-com-sys build
/// output. The linker then statically links the WebView2 loader code, removing
/// the runtime dependency on WebView2Loader.dll.
#[cfg(target_env = "gnu")]
fn swap_webview2_to_static() {
use std::fs;
use std::path::PathBuf;
let out_dir = std::env::var("OUT_DIR").unwrap_or_default();
// OUT_DIR = target/.../build/core-cooldown-HASH/out
// We need: target/.../build/ (two levels up)
let build_dir = PathBuf::from(&out_dir)
.parent() // core-cooldown-HASH
.and_then(|p| p.parent()) // build/
.map(|p| p.to_path_buf());
let build_dir = match build_dir {
Some(d) => d,
None => return,
};
let target_arch = match std::env::var("CARGO_CFG_TARGET_ARCH")
.unwrap_or_default()
.as_str()
{
"x86_64" => "x64",
"x86" => "x86",
"aarch64" => "arm64",
_ => return,
};
let entries = match fs::read_dir(&build_dir) {
Ok(e) => e,
Err(_) => return,
};
for entry in entries.flatten() {
let name = entry.file_name();
let name = name.to_string_lossy();
if !name.starts_with("webview2-com-sys-") {
continue;
}
let lib_dir = entry.path().join("out").join(target_arch);
let import_lib = lib_dir.join("WebView2Loader.dll.lib");
let static_lib = lib_dir.join("WebView2LoaderStatic.lib");
if static_lib.exists() && import_lib.exists() {
if let Ok(static_bytes) = fs::read(&static_lib) {
match fs::write(&import_lib, &static_bytes) {
Ok(_) => println!(
"cargo:warning=Swapped WebView2Loader to static linking ({})",
lib_dir.display()
),
Err(e) => println!(
"cargo:warning=Failed to swap WebView2Loader lib: {}",
e
),
}
}
}
}
}

View File

@@ -1,4 +1,6 @@
mod config;
#[cfg(all(windows, target_env = "gnu"))]
mod msvc_compat;
mod stats;
mod timer;

View File

@@ -2,5 +2,66 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
#[cfg(windows)]
if !webview2_check::is_webview2_installed() {
webview2_check::show_missing_dialog();
return;
}
core_cooldown_lib::run()
}
#[cfg(windows)]
mod webview2_check {
use std::ptr;
use winapi::um::winuser::{MessageBoxW, MB_OK, MB_ICONWARNING};
use winapi::um::shellapi::ShellExecuteW;
// Statically linked from WebView2LoaderStatic.lib via msvc_compat shims
extern "system" {
fn GetAvailableCoreWebView2BrowserVersionString(
browser_executable_folder: *const u16,
version_info: *mut *mut u16,
) -> i32;
}
extern "system" {
fn CoTaskMemFree(pv: *mut std::ffi::c_void);
}
fn to_wide(s: &str) -> Vec<u16> {
s.encode_utf16().chain(std::iter::once(0)).collect()
}
pub fn is_webview2_installed() -> bool {
unsafe {
let mut version_info: *mut u16 = ptr::null_mut();
let hr = GetAvailableCoreWebView2BrowserVersionString(
ptr::null(),
&mut version_info,
);
let installed = hr == 0 && !version_info.is_null();
if !version_info.is_null() {
CoTaskMemFree(version_info as *mut _);
}
installed
}
}
pub fn show_missing_dialog() {
let title = to_wide("Core Cooldown - WebView2 Required");
let message = to_wide(
"Microsoft WebView2 Runtime is required to run Core Cooldown, \
but it was not found on this system.\n\n\
Click OK to open the download page in your browser.\n\n\
After installing WebView2, restart Core Cooldown."
);
let url = to_wide("https://developer.microsoft.com/en-us/microsoft-edge/webview2/consumer");
let open = to_wide("open");
unsafe {
MessageBoxW(ptr::null_mut(), message.as_ptr(), title.as_ptr(), MB_OK | MB_ICONWARNING);
ShellExecuteW(ptr::null_mut(), open.as_ptr(), url.as_ptr(), ptr::null(), ptr::null(), 1);
}
}
}

View File

@@ -0,0 +1,98 @@
//! Compatibility shims for MSVC CRT symbols required by WebView2LoaderStatic.lib.
//!
//! When statically linking the WebView2 loader on GNU/MinGW, the MSVC-compiled
//! object code references these symbols. We provide minimal implementations so
//! the linker can resolve them.
use std::sync::atomic::{AtomicI32, Ordering};
// ── MSVC Buffer Security Check (/GS) ────────────────────────────────────────
//
// MSVC's /GS flag instruments functions with stack canaries. These two symbols
// implement the canary check. The cookie value is arbitrary — real MSVC CRT
// randomises it at startup, but for a statically-linked helper library this
// fixed sentinel is sufficient.
#[no_mangle]
pub static __security_cookie: u64 = 0x00002B992DDFA232;
#[no_mangle]
pub unsafe extern "C" fn __security_check_cookie(cookie: u64) {
if cookie != __security_cookie {
std::process::abort();
}
}
// ── MSVC Thread-Safe Static Initialisation ───────────────────────────────────
//
// C++11 guarantees that function-local statics are initialised exactly once,
// even under concurrent access. MSVC implements this with an epoch counter and
// a set of helper functions. The WebView2 loader uses a few statics internally.
//
// Simplified implementation: uses an atomic spin for the guard. This is safe
// because WebView2 initialisation runs on the main thread in practice.
#[no_mangle]
pub static _Init_thread_epoch: AtomicI32 = AtomicI32::new(0);
#[no_mangle]
pub unsafe extern "C" fn _Init_thread_header(guard: *mut i32) {
if guard.is_null() {
return;
}
// Spin until we can claim the guard (-1 = uninitialized, 0 = done, 1 = in progress)
loop {
let val = guard.read_volatile();
if val == 0 {
// Already initialised — tell caller to skip
return;
}
if val == -1 {
// Not yet initialised — try to claim it
guard.write_volatile(1);
return;
}
// val == 1: another thread is initialising — yield and retry
std::thread::yield_now();
}
}
#[no_mangle]
pub unsafe extern "C" fn _Init_thread_footer(guard: *mut i32) {
if !guard.is_null() {
guard.write_volatile(0); // Mark initialisation complete
_Init_thread_epoch.fetch_add(1, Ordering::Release);
}
}
// ── MSVC C++ Runtime Operators (mangled names) ───────────────────────────────
//
// The static library is compiled with MSVC, which uses its own C++ name mangling.
// MinGW's libstdc++ exports the same operators but with GCC/Itanium mangling,
// so the linker can't match them. We provide the MSVC-mangled versions here.
/// `std::nothrow` — MSVC-mangled `?nothrow@std@@3Unothrow_t@1@B`
/// An empty struct constant used as a tag for nothrow `new`.
#[export_name = "?nothrow@std@@3Unothrow_t@1@B"]
pub static MSVC_STD_NOTHROW: u8 = 0;
/// `operator new(size_t, const std::nothrow_t&)` — nothrow allocation
/// MSVC-mangled: `??2@YAPEAX_KAEBUnothrow_t@std@@@Z`
#[export_name = "??2@YAPEAX_KAEBUnothrow_t@std@@@Z"]
pub unsafe extern "C" fn msvc_operator_new_nothrow(size: usize, _nothrow: *const u8) -> *mut u8 {
let size = if size == 0 { 1 } else { size };
let layout = std::alloc::Layout::from_size_align_unchecked(size, 8);
let ptr = std::alloc::alloc(layout);
ptr // null on failure — nothrow semantics
}
/// `operator delete(void*, size_t)` — sized deallocation
/// MSVC-mangled: `??3@YAXPEAX_K@Z`
#[export_name = "??3@YAXPEAX_K@Z"]
pub unsafe extern "C" fn msvc_operator_delete_sized(ptr: *mut u8, size: usize) {
if !ptr.is_null() {
let size = if size == 0 { 1 } else { size };
let layout = std::alloc::Layout::from_size_align_unchecked(size, 8);
std::alloc::dealloc(ptr, layout);
}
}

View File

@@ -462,14 +462,6 @@ pub enum IdleCheckResult {
NaturalBreakDetected(u64), // duration in seconds
}
fn parse_hour(time_str: &str) -> u32 {
time_str
.split(':')
.next()
.and_then(|h| h.parse().ok())
.unwrap_or(9)
}
/// Returns the number of seconds since last user input (mouse/keyboard).
#[cfg(windows)]
pub fn get_idle_seconds() -> u64 {

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-config-schema/schema.json",
"productName": "Core Cooldown",
"version": "0.1.0",
"version": "0.1.2",
"identifier": "com.corecooldown.app",
"build": {
"frontendDist": "../dist",

View File

@@ -52,10 +52,28 @@
};
});
// Transition parameters
const DURATION = 700;
// Reduced motion preference
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;
// 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,
// so the main window should keep showing whatever view it was on (dashboard).
const effectiveView = $derived(
@@ -65,7 +83,7 @@
);
</script>
<div class="relative h-full bg-black">
<main class="relative h-full bg-black">
{#if $config.background_blobs_enabled}
<BackgroundBlobs accentColor={$config.accent_color} breakColor={$config.break_color} />
{/if}
@@ -115,4 +133,4 @@
</div>
{/if}
</div>
</div>
</main>

View File

@@ -14,7 +14,7 @@
--color-warning: #f0a500;
--color-danger: #f85149;
--color-text-pri: #ffffff;
--color-text-sec: #777777;
--color-text-sec: #8a8a8a;
--color-text-dim: #3a3a3a;
--color-caption-bg: #050505;
}
@@ -55,3 +55,51 @@ body {
::-webkit-scrollbar-thumb:hover {
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 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>
<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 -->
<div
class="blob blob-1"
@@ -30,13 +38,15 @@
<svg class="absolute inset-0 h-full w-full" style="opacity: 0.08; mix-blend-mode: overlay;">
<filter id="grain-filter">
<feTurbulence type="fractalNoise" baseFrequency="0.45" numOctaves="3" stitchTiles="stitch">
<animate
attributeName="seed"
from="0"
to="100"
dur="2s"
repeatCount="indefinite"
/>
{#if !reducedMotion}
<animate
attributeName="seed"
from="0"
to="100"
dur="2s"
repeatCount="indefinite"
/>
{/if}
</feTurbulence>
</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 { config } from "../stores/config";
import TimerRing from "./TimerRing.svelte";
import { onMount } from "svelte";
import { scaleIn, fadeIn, pressable } from "../utils/animate";
import { pickRandomActivity, getCategoryIcon, getCategoryLabel, type BreakActivity } from "../utils/activities";
@@ -13,7 +14,7 @@
let { standalone = false }: Props = $props();
const appWindow = standalone ? getCurrentWebviewWindow() : null;
const appWindow = $derived(standalone ? getCurrentWebviewWindow() : null);
let currentActivity = $state<BreakActivity>(pickRandomActivity());
let activityCycleTimer: ReturnType<typeof setInterval> | null = null;
@@ -70,14 +71,36 @@
);
const isModal = $derived(!$config.fullscreen_mode && !standalone);
// Focus trap: keep Tab cycling within break screen
let breakContainer = $state<HTMLElement>(undefined!);
$effect(() => {
if (!breakContainer) return;
function trapFocus(e: KeyboardEvent) {
if (e.key !== "Tab") return;
const focusable = breakContainer.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey) {
if (document.activeElement === first) { e.preventDefault(); last.focus(); }
} else {
if (document.activeElement === last) { e.preventDefault(); first.focus(); }
}
}
breakContainer.addEventListener("keydown", trapFocus);
return () => breakContainer.removeEventListener("keydown", trapFocus);
});
</script>
{#if standalone}
<!-- ── Standalone break window: horizontal card, transparent background ── -->
<div class="standalone-card" use:scaleIn={{ duration: 0.5 }}>
<div class="standalone-card" use:scaleIn={{ duration: 0.5 }} bind:this={breakContainer}>
<!-- Ripples emanate from the ring, visible outside the card -->
<div class="standalone-ring-area">
<div class="ripple-container">
<div class="ripple-container" aria-hidden="true">
<div class="break-ripple ripple-1" style="--ripple-color: {$config.break_color}"></div>
<div class="break-ripple ripple-2" style="--ripple-color: {$config.break_color}"></div>
<div class="break-ripple ripple-3" style="--ripple-color: {$config.break_color}"></div>
@@ -88,6 +111,8 @@
size={140}
strokeWidth={5}
accentColor={$config.break_color}
label="Break timer"
valueText="{formatTime($timer.breakTimeRemaining)} remaining"
>
<div class="break-breathe-counter">
<span
@@ -103,19 +128,19 @@
<!-- Right side: text + buttons -->
<div class="standalone-content">
<h2 class="text-[17px] font-medium text-white mb-1.5">
<h2 class="text-[17px] font-medium text-white mb-1.5" tabindex="-1">
{$timer.breakTitle}
</h2>
<p class="text-[12px] leading-relaxed text-[#666] mb-4 max-w-[240px]">
<p class="text-[12px] leading-relaxed text-[#8a8a8a] mb-4 max-w-[240px]">
{$timer.breakMessage}
</p>
{#if $config.show_break_activities}
<div class="rounded-xl border border-[#ffffff08] bg-[#ffffff06] px-4 py-2.5 mb-4 max-w-[260px]">
<div class="text-[9px] font-medium tracking-[0.15em] text-[#555] uppercase mb-1">
<div class="text-[9px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase mb-1">
{getCategoryLabel(currentActivity.category)}
</div>
<p class="text-[12px] leading-relaxed text-[#999]">
<p class="text-[12px] leading-relaxed text-[#999]" aria-live="polite">
{currentActivity.text}
</p>
</div>
@@ -126,9 +151,9 @@
<button
use:pressable
class="rounded-full border border-[#333] px-5 py-2 text-[11px]
tracking-wider text-[#666] uppercase
tracking-wider text-[#8a8a8a] uppercase
transition-colors duration-200
hover:border-[#444] hover:text-[#aaa]"
hover:border-[#444] hover:text-[#ccc]"
onclick={cancelBreak}
>
{cancelBtnText}
@@ -147,7 +172,7 @@
{/if}
</div>
{#if $config.snooze_limit > 0}
<p class="mt-2 text-[9px] text-[#333]">
<p class="mt-2 text-[9px] text-[#8a8a8a]">
{$timer.snoozesUsed}/{$config.snooze_limit} snoozes used
</p>
{/if}
@@ -155,7 +180,7 @@
</div>
<!-- Bottom progress bar with clip-path -->
<div class="standalone-progress-container">
<div class="standalone-progress-container" role="progressbar" aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round($timer.breakProgress * 100)} aria-label="Break progress">
<div class="standalone-progress-track">
<div
class="h-full transition-[width] duration-1000 ease-linear"
@@ -168,23 +193,20 @@
{:else}
<!-- ── In-app break screen: full-screen fill OR modal with backdrop ── -->
<div
class="relative h-full"
class:flex={isModal}
class:items-center={isModal}
class:justify-center={isModal}
style={isModal ? `background: #000;` : ""}
class="relative h-full flex items-center justify-center"
style="background: #000;"
bind:this={breakContainer}
>
<div
class="relative flex flex-col"
class:h-full={!isModal}
class="relative flex flex-col items-center"
class:break-modal={isModal}
>
<!-- Break ring with breathing pulse + ripples -->
<div class="mb-6 relative" use:scaleIn={{ duration: 0.6, delay: 0.1 }}>
<div class="absolute inset-0 flex items-center justify-center pointer-events-none">
<div class="break-ripple ripple-1" style="--ripple-color: {$config.break_color}"></div>
<div class="break-ripple ripple-2" style="--ripple-color: {$config.break_color}"></div>
<div class="break-ripple ripple-3" style="--ripple-color: {$config.break_color}"></div>
<div class="absolute inset-0 flex items-center justify-center pointer-events-none" aria-hidden="true">
<div class="break-ripple ripple-1" style="--ripple-color: {$config.break_color}; --ripple-size: {isModal ? 160 : 200}px"></div>
<div class="break-ripple ripple-2" style="--ripple-color: {$config.break_color}; --ripple-size: {isModal ? 160 : 200}px"></div>
<div class="break-ripple ripple-3" style="--ripple-color: {$config.break_color}; --ripple-size: {isModal ? 160 : 200}px"></div>
</div>
<div class="break-breathe relative">
@@ -193,6 +215,8 @@
size={isModal ? 160 : 200}
strokeWidth={isModal ? 5 : 6}
accentColor={$config.break_color}
label="Break timer"
valueText="{formatTime($timer.breakTimeRemaining)} remaining"
>
<div class="break-breathe-counter">
<span
@@ -208,12 +232,12 @@
</div>
</div>
<h2 class="mb-2 text-lg font-medium text-white" use:fadeIn={{ delay: 0.25, y: 10 }}>
<h2 class="mb-2 text-lg font-medium text-white" tabindex="-1" use:fadeIn={{ delay: 0.25, y: 10 }}>
{$timer.breakTitle}
</h2>
<p
class="max-w-[300px] text-center text-[13px] leading-relaxed text-[#555]"
class="max-w-[300px] text-center text-[13px] leading-relaxed text-[#8a8a8a]"
class:mb-4={$config.show_break_activities}
class:mb-8={!$config.show_break_activities}
use:fadeIn={{ delay: 0.35, y: 10 }}
@@ -226,10 +250,10 @@
class="mb-8 mx-auto max-w-[320px] rounded-2xl border border-[#1a1a1a] bg-[#0a0a0a] px-5 py-3.5 text-center"
use:fadeIn={{ delay: 0.4, y: 10 }}
>
<div class="mb-1.5 text-[10px] font-medium tracking-[0.15em] text-[#444] uppercase">
<div class="mb-1.5 text-[10px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
{getCategoryLabel(currentActivity.category)}
</div>
<p class="text-[13px] leading-relaxed text-[#999]">
<p class="text-[13px] leading-relaxed text-[#999]" aria-live="polite">
{currentActivity.text}
</p>
</div>
@@ -240,9 +264,9 @@
<button
use:pressable
class="rounded-full border border-[#222] px-6 py-2.5 text-[12px]
tracking-wider text-[#555] uppercase
tracking-wider text-[#8a8a8a] uppercase
transition-colors duration-200
hover:border-[#333] hover:text-[#999]"
hover:border-[#333] hover:text-[#ccc]"
onclick={cancelBreak}
>
{cancelBtnText}
@@ -261,22 +285,36 @@
{/if}
</div>
{#if $config.snooze_limit > 0}
<p class="mt-3 text-[10px] text-[#2a2a2a]">
<p class="mt-3 text-[10px] text-[#8a8a8a]">
{$timer.snoozesUsed}/{$config.snooze_limit} snoozes used
</p>
{/if}
{/if}
<!-- Bottom progress bar for modal - uses clip-path to respect border radius -->
<div class="break-modal-progress-container">
<div class="break-modal-progress-track">
<!-- Bottom progress bar for modal -->
{#if isModal}
<div class="break-modal-progress-container" role="progressbar" aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round($timer.breakProgress * 100)} aria-label="Break progress">
<div class="break-modal-progress-track">
<div
class="h-full transition-[width] duration-1000 ease-linear"
style="width: {$timer.breakProgress * 100}%; background: {barGradient};"
></div>
</div>
</div>
{/if}
</div>
<!-- Fullscreen progress bar - anchored to bottom of screen -->
{#if !isModal}
<div class="absolute bottom-8 left-12 right-12 h-[3px] rounded-full overflow-hidden" role="progressbar" aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round($timer.breakProgress * 100)} aria-label="Break progress">
<div class="w-full h-full" style="background: rgba(255,255,255,0.03);">
<div
class="h-full transition-[width] duration-1000 ease-linear"
style="width: {$timer.breakProgress * 100}%; background: {barGradient};"
></div>
</div>
</div>
</div>
{/if}
</div>
{/if}
@@ -393,8 +431,8 @@
/* ── Ripple circles ── */
.break-ripple {
position: absolute;
width: 140px;
height: 140px;
width: var(--ripple-size, 140px);
height: var(--ripple-size, 140px);
border-radius: 50%;
border: 1.5px solid var(--ripple-color);
opacity: 0;
@@ -407,10 +445,10 @@
@keyframes ripple-expand {
0% {
transform: scale(1);
opacity: 0.3;
opacity: 0.25;
}
100% {
transform: scale(2.2);
transform: scale(2.5);
opacity: 0;
}
}

View File

@@ -182,6 +182,45 @@
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
const slX = $derived(sat);
const slY = $derived(100 - light);
@@ -192,7 +231,7 @@
<div class="flex items-center justify-between">
<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
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'}"
style="background: {color};"
onclick={() => selectPreset(color)}
aria-label="Select {color}"
aria-label="Select {getColorName(color)}"
></button>
{/each}
@@ -235,7 +274,7 @@
transition:slide={{ duration: 280, easing: cubicOut }}
>
<!-- Saturation / Lightness pad -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<!-- svelte-ignore a11y_no_noninteractive_tabindex a11y_no_noninteractive_element_interactions -->
<div
bind:this={slPad}
class="relative h-[120px] w-full cursor-crosshair rounded-lg overflow-hidden touch-none"
@@ -244,9 +283,10 @@
onpointermove={handleSLPointerMove}
onpointerup={handleSLPointerUp}
onpointercancel={handleSLPointerUp}
onkeydown={handleSLKeydown}
role="application"
aria-label="Saturation and lightness"
tabindex="-1"
aria-label="Saturation and lightness. Use arrow keys to adjust."
tabindex="0"
>
<!-- Lightness overlay: white at top, black at bottom -->
<div
@@ -262,7 +302,7 @@
</div>
<!-- Hue bar -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<!-- svelte-ignore a11y_no_noninteractive_tabindex a11y_no_noninteractive_element_interactions -->
<div
bind:this={hueBar}
class="relative h-[16px] w-full cursor-pointer rounded-full overflow-hidden touch-none"
@@ -273,9 +313,10 @@
onpointermove={handleHuePointerMove}
onpointerup={handleHuePointerUp}
onpointercancel={handleHuePointerUp}
onkeydown={handleHueKeydown}
role="application"
aria-label="Hue"
tabindex="-1"
aria-label="Hue. Use arrow keys to adjust."
tabindex="0"
>
<!-- Hue cursor -->
<div
@@ -288,8 +329,9 @@
<!-- Hex input -->
<input
type="text"
aria-label="Hex color value"
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="#ff4d00"
value={hexInput}

View File

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

View File

@@ -52,13 +52,15 @@
<div class="flex items-center justify-between">
<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"}
</div>
</div>
<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"
onclick={() => { expanded = !expanded; }}
>
@@ -75,6 +77,8 @@
{#each fonts as font}
<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
transition-all duration-150
{value === font.family
@@ -88,7 +92,7 @@
>
25:00
</span>
<span class="text-[9px] tracking-wider text-[#555] uppercase">
<span class="text-[9px] tracking-wider text-[#8a8a8a] uppercase">
{font.label}
</span>
</button>

View File

@@ -192,8 +192,7 @@ const fontStyle = $derived(
});
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="w-full h-full flex items-center justify-center overflow-hidden">
<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
style="
width: {100 / zoomScale}%;
@@ -206,8 +205,10 @@ const fontStyle = $derived(
class="flex items-center justify-center w-full h-full"
style="padding: 22px 14px 22px 24px;"
>
<!-- svelte-ignore a11y_no_static_element_interactions a11y_no_noninteractive_element_interactions -->
<div
class="mini-pill flex h-full w-full items-center select-none"
role="application"
class:mini-draggable={draggable}
style="
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;">
<!-- Glow SVG (larger for blur room) -->
<svg
aria-hidden="true"
width={viewSize}
height={viewSize}
class="pointer-events-none absolute"
@@ -295,6 +297,7 @@ const fontStyle = $derived(
<!-- Non-glow SVG: track + crisp ring -->
<svg
aria-hidden="true"
width={ringSize}
height={ringSize}
class="absolute"

View File

@@ -99,10 +99,11 @@
aria-label="Back to dashboard"
use:pressable
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}
>
<svg
aria-hidden="true"
width="16"
height="16"
viewBox="0 0 24 24"
@@ -117,6 +118,7 @@
</button>
<h1
data-tauri-drag-region
tabindex="-1"
class="flex-1 text-lg font-medium text-white"
>
Settings
@@ -132,7 +134,7 @@
<!-- Timer -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0 }}>
<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
</h3>
@@ -140,12 +142,13 @@
<div class="flex items-center">
<div class="flex-1">
<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
</div>
</div>
<Stepper
bind:value={$config.break_frequency}
label="Break frequency"
min={5}
max={120}
onchange={markChanged}
@@ -157,12 +160,13 @@
<div class="flex items-center">
<div class="flex-1">
<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
</div>
</div>
<Stepper
bind:value={$config.break_duration}
label="Break duration"
min={1}
max={60}
onchange={markChanged}
@@ -174,10 +178,11 @@
<div class="flex items-center">
<div class="flex-1">
<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>
<ToggleSwitch
bind:checked={$config.auto_start}
label="Auto-start"
onchange={markChanged}
/>
</div>
@@ -186,7 +191,7 @@
<!-- Break Screen -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.06 }}>
<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
</h3>
@@ -230,12 +235,13 @@
<div class="flex items-center">
<div class="flex-1">
<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"}
</div>
</div>
<ToggleSwitch
bind:checked={$config.fullscreen_mode}
label="Fullscreen break"
onchange={markChanged}
/>
</div>
@@ -245,12 +251,13 @@
<div class="flex items-center">
<div class="flex-1">
<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
</div>
</div>
<ToggleSwitch
bind:checked={$config.show_break_activities}
label="Activity suggestions"
onchange={markChanged}
/>
</div>
@@ -259,7 +266,7 @@
<!-- Behavior -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.12 }}>
<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
</h3>
@@ -267,12 +274,13 @@
<div class="flex items-center">
<div class="flex-1">
<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
</div>
</div>
<ToggleSwitch
bind:checked={$config.strict_mode}
label="Strict mode"
onchange={markChanged}
/>
</div>
@@ -283,10 +291,11 @@
<div class="flex items-center">
<div class="flex-1">
<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>
<ToggleSwitch
bind:checked={$config.allow_end_early}
label="Allow end early"
onchange={markChanged}
/>
</div>
@@ -296,12 +305,13 @@
<div class="flex items-center">
<div class="flex-1">
<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
</div>
</div>
<Stepper
bind:value={$config.snooze_duration}
label="Snooze duration"
min={1}
max={30}
onchange={markChanged}
@@ -313,7 +323,7 @@
<div class="flex items-center">
<div class="flex-1">
<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
? "Unlimited"
: `${$config.snooze_limit} per break`}
@@ -321,6 +331,7 @@
</div>
<Stepper
bind:value={$config.snooze_limit}
label="Snooze limit"
min={0}
max={5}
formatValue={(v) => (v === 0 ? "\u221E" : String(v))}
@@ -334,12 +345,13 @@
<div class="flex items-center">
<div class="flex-1">
<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
</div>
</div>
<ToggleSwitch
bind:checked={$config.immediately_start_breaks}
label="Immediate breaks"
onchange={markChanged}
/>
</div>
@@ -350,12 +362,13 @@
<div class="flex items-center">
<div class="flex-1">
<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
</div>
</div>
<ToggleSwitch
bind:checked={$config.working_hours_enabled}
label="Working hours"
onchange={markChanged}
/>
</div>
@@ -370,6 +383,7 @@
<div class="flex items-center gap-3 mb-3">
<ToggleSwitch
bind:checked={$config.working_hours_schedule[dayIndex].enabled}
label={dayName}
onchange={markChanged}
/>
<span class="text-[13px] text-white w-20">{dayName}</span>
@@ -448,7 +462,7 @@
<!-- Idle Detection -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.21 }}>
<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
</h3>
@@ -456,10 +470,11 @@
<div class="flex items-center">
<div class="flex-1">
<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>
<ToggleSwitch
bind:checked={$config.idle_detection_enabled}
label="Auto-pause when idle"
onchange={markChanged}
/>
</div>
@@ -470,12 +485,13 @@
<div class="flex items-center">
<div class="flex-1">
<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
</div>
</div>
<Stepper
bind:value={$config.idle_timeout}
label="Idle timeout"
min={30}
max={600}
step={30}
@@ -489,7 +505,7 @@
<!-- Smart Breaks -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.24 }}>
<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
</h3>
@@ -497,10 +513,11 @@
<div class="flex items-center">
<div class="flex-1">
<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>
<ToggleSwitch
bind:checked={$config.smart_breaks_enabled}
label="Enable smart breaks"
onchange={markChanged}
/>
</div>
@@ -511,7 +528,7 @@
<div class="flex items-center">
<div class="flex-1">
<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
? `${Math.floor($config.smart_break_threshold / 60)} min`
: `${$config.smart_break_threshold}s`} to count as break
@@ -519,6 +536,7 @@
</div>
<Stepper
bind:value={$config.smart_break_threshold}
label="Minimum away time"
min={120}
max={900}
step={60}
@@ -532,10 +550,11 @@
<div class="flex items-center">
<div class="flex-1">
<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>
<ToggleSwitch
bind:checked={$config.smart_break_count_stats}
label="Count in statistics"
onchange={markChanged}
/>
</div>
@@ -545,7 +564,7 @@
<!-- Notifications -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.27 }}>
<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
</h3>
@@ -553,10 +572,11 @@
<div class="flex items-center">
<div class="flex-1">
<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>
<ToggleSwitch
bind:checked={$config.notification_enabled}
label="Pre-break alert"
onchange={markChanged}
/>
</div>
@@ -567,12 +587,13 @@
<div class="flex items-center">
<div class="flex-1">
<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
</div>
</div>
<Stepper
bind:value={$config.notification_before_break}
label="Alert timing"
min={0}
max={300}
step={10}
@@ -585,7 +606,7 @@
<!-- Sound -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.27 }}>
<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
</h3>
@@ -593,10 +614,11 @@
<div class="flex items-center">
<div class="flex-1">
<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>
<ToggleSwitch
bind:checked={$config.sound_enabled}
label="Sound effects"
onchange={markChanged}
/>
</div>
@@ -607,10 +629,11 @@
<div class="flex items-center">
<div class="flex-1">
<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>
<Stepper
bind:value={$config.sound_volume}
label="Volume"
min={0}
max={100}
step={10}
@@ -649,7 +672,7 @@
<!-- Appearance -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.3 }}>
<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
</h3>
@@ -657,12 +680,13 @@
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">UI zoom</div>
<div class="text-[11px] text-[#777]">
<div class="text-[11px] text-[#8a8a8a]">
{$config.ui_zoom}%
</div>
</div>
<Stepper
bind:value={$config.ui_zoom}
label="UI zoom"
min={50}
max={200}
step={5}
@@ -705,12 +729,13 @@
<div class="flex items-center">
<div class="flex-1">
<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
</div>
</div>
<ToggleSwitch
bind:checked={$config.background_blobs_enabled}
label="Animated background"
onchange={markChanged}
/>
</div>
@@ -719,7 +744,7 @@
<!-- Mini Mode -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.33 }}>
<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
</h3>
@@ -727,12 +752,13 @@
<div class="flex items-center justify-between">
<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
</div>
</div>
<ToggleSwitch
bind:checked={$config.mini_click_through}
label="Click-through"
onchange={markChanged}
/>
</div>
@@ -741,12 +767,13 @@
<div class="mt-4 flex items-center justify-between">
<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
</div>
</div>
<Stepper
bind:value={$config.mini_hover_threshold}
label="Hover delay"
min={1}
max={10}
step={0.5}
@@ -760,7 +787,7 @@
<!-- Shortcuts -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.36 }}>
<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
</h3>
@@ -768,15 +795,15 @@
<div class="space-y-3">
<div class="flex items-center justify-between">
<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 class="flex items-center justify-between">
<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 class="flex items-center justify-between">
<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>
</section>

View File

@@ -109,7 +109,7 @@
}
// Day label
ctx.fillStyle = "#444";
ctx.fillStyle = "#8a8a8a";
ctx.font = "10px -apple-system, sans-serif";
ctx.textAlign = "center";
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(
ctx: CanvasRenderingContext2D,
x: number,
@@ -149,10 +157,11 @@
aria-label="Back to dashboard"
use:pressable
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}
>
<svg
aria-hidden="true"
width="16"
height="16"
viewBox="0 0 24 24"
@@ -167,6 +176,7 @@
</button>
<h1
data-tauri-drag-region
tabindex="-1"
class="flex-1 text-lg font-medium text-white"
>
Statistics
@@ -179,7 +189,7 @@
<!-- Today's summary -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0 }}>
<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
</h3>
@@ -189,7 +199,7 @@
<div class="text-[28px] font-semibold text-white tabular-nums">
{stats?.todayCompleted ?? 0}
</div>
<div class="text-[11px] text-[#777]">Breaks taken</div>
<div class="text-[11px] text-[#8a8a8a]">Breaks taken</div>
</div>
<div class="text-center">
<div class="text-[28px] font-semibold tabular-nums"
@@ -197,19 +207,19 @@
>
{compliancePercent}%
</div>
<div class="text-[11px] text-[#777]">Compliance</div>
<div class="text-[11px] text-[#8a8a8a]">Compliance</div>
</div>
<div class="text-center">
<div class="text-[28px] font-semibold text-white tabular-nums">
{breakTimeFormatted()}
</div>
<div class="text-[11px] text-[#777]">Break time</div>
<div class="text-[11px] text-[#8a8a8a]">Break time</div>
</div>
<div class="text-center">
<div class="text-[28px] font-semibold text-white tabular-nums">
{stats?.todaySkipped ?? 0}
</div>
<div class="text-[11px] text-[#777]">Skipped</div>
<div class="text-[11px] text-[#8a8a8a]">Skipped</div>
</div>
</div>
</section>
@@ -217,7 +227,7 @@
<!-- Streak -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.06 }}>
<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
</h3>
@@ -225,7 +235,7 @@
<div class="flex items-center justify-between">
<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 class="text-[24px] font-semibold tabular-nums" style="color: {$config.accent_color}">
{stats?.currentStreak ?? 0}
@@ -237,7 +247,7 @@
<div class="flex items-center justify-between">
<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 class="text-[24px] font-semibold text-white tabular-nums">
{stats?.bestStreak ?? 0}
@@ -248,17 +258,39 @@
<!-- Weekly chart -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.12 }}>
<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
</h3>
<!-- svelte-ignore a11y_no_interactive_element_to_noninteractive_role -->
<canvas
bind:this={chartCanvas}
class="h-[140px] w-full"
role="img"
aria-label={chartAriaLabel()}
></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="h-2 w-2 rounded-sm" style="background: {$config.accent_color}"></div>
Completed

View File

@@ -6,6 +6,7 @@
step?: number;
formatValue?: (v: number) => string;
onchange?: (value: number) => void;
label?: string;
}
let {
@@ -15,6 +16,7 @@
step = 1,
formatValue = (v: number) => String(v),
onchange,
label = "Value",
}: Props = $props();
let holdTimer: ReturnType<typeof setTimeout> | null = null;
@@ -55,32 +57,36 @@
}
</script>
<div class="flex items-center gap-1.5">
<div class="flex items-center gap-1.5" role="group" aria-label={label}>
<button
type="button"
aria-label="Decrease"
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
disabled:opacity-20"
onmousedown={() => startHold(decrement)}
onmouseup={stopHold}
onmouseleave={stopHold}
onclick={(e) => { if (e.detail === 0) decrement(); }}
disabled={value <= min}
>
&minus;
</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)}
</span>
<button
type="button"
aria-label="Increase"
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
disabled:opacity-20"
onmousedown={() => startHold(increment)}
onmouseup={stopHold}
onmouseleave={stopHold}
onclick={(e) => { if (e.detail === 0) increment(); }}
disabled={value >= max}
>
+

View File

@@ -247,6 +247,31 @@
function format(n: number): string {
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>
<div class="time-spinner" style="font-family: {countdownFont ? `'${countdownFont}', monospace` : 'monospace'};">
@@ -265,6 +290,7 @@
onpointermove={handleHoursPointerMove}
onpointerup={handleHoursPointerUp}
onpointercancel={handleHoursPointerUp}
onkeydown={handleHoursKeydown}
>
<div class="wheel-viewport">
<div class="wheel-cylinder">
@@ -300,6 +326,7 @@
onpointermove={handleMinutesPointerMove}
onpointerup={handleMinutesPointerUp}
onpointercancel={handleMinutesPointerUp}
onkeydown={handleMinutesKeydown}
>
<div class="wheel-viewport">
<div class="wheel-cylinder">
@@ -324,14 +351,14 @@
.time-spinner {
display: inline-flex;
align-items: center;
gap: 4px;
gap: 2px;
user-select: none;
touch-action: none;
}
.wheel-field {
position: relative;
width: 50px;
width: 44px;
height: 36px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.05);
@@ -376,13 +403,13 @@
line-height: 1;
backface-visibility: hidden;
pointer-events: none;
padding-right: 12px;
padding-right: 6px;
}
/* Unit label pinned to the right of the field */
.unit-badge {
position: absolute;
right: 5px;
right: 3px;
top: 50%;
transform: translateY(-50%);
font-size: 10px;

View File

@@ -4,6 +4,8 @@
size?: number;
strokeWidth?: number;
accentColor?: string;
label?: string;
valueText?: string;
children?: import("svelte").Snippet;
}
@@ -12,6 +14,8 @@
size = 280,
strokeWidth = 8,
accentColor = "#ff4d00",
label = "Timer",
valueText = "",
children,
}: Props = $props();
@@ -44,9 +48,16 @@
<div
class="relative flex items-center justify-center"
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 -->
<svg
aria-hidden="true"
width={viewSize}
height={viewSize}
class="pointer-events-none absolute"
@@ -136,6 +147,7 @@
<!-- Non-glow SVG — exact size, draws the track + crisp ring -->
<svg
aria-hidden="true"
width={size}
height={size}
class="absolute"

View File

@@ -9,8 +9,9 @@
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"
>
<!-- Centered app name -->
<!-- Centered app name (decorative — OS window title handles screen readers) -->
<span
aria-hidden="true"
class="pointer-events-none absolute inset-0 flex items-center justify-center
text-[11px] font-semibold tracking-[0.3em] text-[#383838] uppercase"
style="font-family: 'Space Mono', monospace;"
@@ -19,7 +20,7 @@
</span>
<!-- 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) -->
<button
aria-label="Maximize"

View File

@@ -4,9 +4,10 @@
interface Props {
checked: boolean;
onchange?: (value: boolean) => void;
label?: string;
}
let { checked = $bindable(), onchange }: Props = $props();
let { checked = $bindable(), onchange, label = "Toggle" }: Props = $props();
function toggle() {
checked = !checked;
@@ -17,10 +18,10 @@
<button
type="button"
role="switch"
aria-label="Toggle"
aria-label={label}
aria-checked={checked}
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'};"
onclick={toggle}
>

View File

@@ -1,5 +1,15 @@
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
*/
@@ -7,6 +17,11 @@ export function fadeIn(
node: HTMLElement,
options?: { duration?: number; delay?: number; y?: number },
) {
if (prefersReducedMotion()) {
node.style.opacity = "1";
return { destroy() {} };
}
const { duration = 0.5, delay = 0, y = 15 } = options ?? {};
node.style.opacity = "0";
@@ -30,6 +45,11 @@ export function scaleIn(
node: HTMLElement,
options?: { duration?: number; delay?: number },
) {
if (prefersReducedMotion()) {
node.style.opacity = "1";
return { destroy() {} };
}
const { duration = 0.6, delay = 0 } = options ?? {};
node.style.opacity = "0";
@@ -53,6 +73,11 @@ export function inView(
node: HTMLElement,
options?: { delay?: number; y?: number; threshold?: number },
) {
if (prefersReducedMotion()) {
node.style.opacity = "1";
return { destroy() {} };
}
const { delay = 0, y = 20, threshold = 0.05 } = options ?? {};
node.style.opacity = "0";
node.style.transform = `translateY(${y}px)`;
@@ -90,6 +115,10 @@ export function inView(
* Svelte action: spring-scale press feedback on buttons
*/
export function pressable(node: HTMLElement) {
if (prefersReducedMotion()) {
return { destroy() {} };
}
let active: ReturnType<typeof animate> | null = null;
function onDown() {
@@ -137,6 +166,10 @@ export function glowHover(
node: HTMLElement,
options?: { color?: string },
) {
if (prefersReducedMotion()) {
return { update() {}, destroy() {} };
}
let color = options?.color ?? "#ff4d00";
let enterAnim: 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).
*/
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;
let isDown = false;