Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
460bf2c613 | ||
|
|
4cbf4c5bb8 | ||
| d5ad1514d1 | |||
| 925a7d5516 | |||
|
|
87ab035c68 | ||
|
|
6b584efb40 | ||
|
|
b01dbd6c0b | ||
|
|
d93d231a45 | ||
|
|
4f4599c4c9 | ||
|
|
e9021e51e5 | ||
|
|
37d0d638d5 | ||
|
|
6bba2835bb | ||
|
|
3aeb83f69b |
124
README.md
124
README.md
@@ -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/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">
|
||||||
<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>
|
</p>
|
||||||
|
|
||||||
<br />
|
<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
|
## Features
|
||||||
|
|
||||||
### ⏱️ Timer & Breaks
|
### ⏱️ Timer & Breaks
|
||||||
@@ -72,7 +129,7 @@ Core Cooldown is a single portable `.exe`. No installer, no account, no telemetr
|
|||||||
|
|
||||||
### 🧘 Break Activities
|
### 🧘 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 |
|
| Category | Examples |
|
||||||
|:---------|:---------|
|
|:---------|:---------|
|
||||||
@@ -183,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
|
||||||
@@ -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.
|
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 />
|
<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) │
|
│ (dynamic icon · tooltip · menu) │
|
||||||
├──────────────────────────────────────────────────────────────┤
|
├──────────────────────────────────────────────────────────────┤
|
||||||
│ │
|
│ │
|
||||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||||
│ │ Main Window │ │ Break Window│ │ Mini Window │ │
|
│ │ Main Window │ │ Break Window│ │ Mini Window │ │
|
||||||
│ │ (WebView) │ │ (WebView) │ │ (WebView) │ │
|
│ │ (WebView) │ │ (WebView) │ │ (WebView) │ │
|
||||||
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
|
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
|
||||||
│ │ │ │ │
|
│ │ │ │ │
|
||||||
│ └─────────┬───────┴──────────────────┘ │
|
│ └─────────┬───────┴──────────────────┘ │
|
||||||
│ │ Tauri IPC │
|
│ │ Tauri IPC │
|
||||||
│ ┌─────────┴─────────┐ │
|
│ ┌─────────┴─────────┐ │
|
||||||
│ │ Rust Backend │ │
|
│ │ Rust Backend │ │
|
||||||
│ │ │ │
|
│ │ │ │
|
||||||
@@ -306,7 +380,7 @@ A split-architecture desktop app: Rust backend for system integration and timer
|
|||||||
│ │ Config │ JSON persistence (portable) │
|
│ │ Config │ JSON persistence (portable) │
|
||||||
│ │ Stats │ break history tracking │
|
│ │ Stats │ break history tracking │
|
||||||
│ │ IdleDetector │ GetLastInputInfo polling │
|
│ │ IdleDetector │ GetLastInputInfo polling │
|
||||||
│ │ GlobalShortcuts │ Ctrl+Shift+P/B/S │
|
│ │ GlobalShortcuts │ Ctrl+Shift+P/B/S │
|
||||||
│ │ TrayIcon │ RGBA ring rendering │
|
│ │ TrayIcon │ RGBA ring rendering │
|
||||||
│ │ Notifications │ Windows toast │
|
│ │ 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 |
|
| `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 |
|
| `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 |
|
| `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>
|
</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) |
|
| **Windows** | `BreakWindow` (standalone break modal), `MiniTimer` (floating mini mode) |
|
||||||
| **Components** | `TimerRing`, `Titlebar`, `ToggleSwitch`, `Stepper`, `ColorPicker`, `FontSelector`, `TimeSpinner`, `BackgroundBlobs` |
|
| **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) |
|
| **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>
|
</details>
|
||||||
|
|
||||||
@@ -344,10 +419,10 @@ A split-architecture desktop app: Rust backend for system integration and timer
|
|||||||
<summary><strong>IPC contract</strong></summary>
|
<summary><strong>IPC contract</strong></summary>
|
||||||
|
|
||||||
**Commands** (frontend → backend):
|
**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):
|
**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>
|
</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 |
|
| `serde` / `serde_json` | Config and stats serialization |
|
||||||
| `chrono` | Date/time handling for schedules and statistics |
|
| `chrono` | Date/time handling for schedules and statistics |
|
||||||
| `anyhow` | Error handling |
|
| `anyhow` | Error handling |
|
||||||
| `winapi` | Windows idle detection (`GetLastInputInfo`) |
|
| `winapi` | Windows idle detection (`GetLastInputInfo`), WebView2 check dialog |
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -454,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
|
||||||
@@ -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
|
## 📄 License
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "core-cooldown",
|
"name": "core-cooldown",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "0.1.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
BIN
screenshots/01-dashboard.png
Normal file
BIN
screenshots/01-dashboard.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 186 KiB |
BIN
screenshots/02-stats.png
Normal file
BIN
screenshots/02-stats.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
BIN
screenshots/03-settings.png
Normal file
BIN
screenshots/03-settings.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
BIN
screenshots/04-break.png
Normal file
BIN
screenshots/04-break.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 139 KiB |
BIN
screenshots/05-mini.png
Normal file
BIN
screenshots/05-mini.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
113
src-tauri/Cargo.lock
generated
113
src-tauri/Cargo.lock
generated
@@ -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",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "core-cooldown"
|
name = "core-cooldown"
|
||||||
version = "0.1.0"
|
version = "0.1.2"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
@@ -11,15 +11,14 @@ crate-type = ["lib", "staticlib"]
|
|||||||
tauri-build = { version = "2", features = [] }
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tauri = { version = "2", features = ["tray-icon"] }
|
tauri = { version = "2", features = ["tray-icon", "custom-protocol"] }
|
||||||
tauri-plugin-shell = "2"
|
tauri-plugin-shell = "2"
|
||||||
tauri-plugin-notification = "2"
|
tauri-plugin-notification = "2"
|
||||||
tauri-plugin-global-shortcut = "2"
|
tauri-plugin-global-shortcut = "2"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
dirs = "5"
|
|
||||||
chrono = "0.4"
|
chrono = "0.4"
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
|
|
||||||
[target.'cfg(windows)'.dependencies]
|
[target.'cfg(windows)'.dependencies]
|
||||||
winapi = { version = "0.3", features = ["winuser", "sysinfoapi", "windef"] }
|
winapi = { version = "0.3", features = ["winuser", "sysinfoapi", "windef", "shellapi"] }
|
||||||
|
|||||||
@@ -7,5 +7,75 @@ fn main() {
|
|||||||
std::env::set_var("PATH", mingw_bin);
|
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()
|
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
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
mod config;
|
mod config;
|
||||||
|
#[cfg(all(windows, target_env = "gnu"))]
|
||||||
|
mod msvc_compat;
|
||||||
mod stats;
|
mod stats;
|
||||||
mod timer;
|
mod timer;
|
||||||
|
|
||||||
|
|||||||
@@ -2,5 +2,66 @@
|
|||||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
|
#[cfg(windows)]
|
||||||
|
if !webview2_check::is_webview2_installed() {
|
||||||
|
webview2_check::show_missing_dialog();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
core_cooldown_lib::run()
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
98
src-tauri/src/msvc_compat.rs
Normal file
98
src-tauri/src/msvc_compat.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -462,14 +462,6 @@ pub enum IdleCheckResult {
|
|||||||
NaturalBreakDetected(u64), // duration in seconds
|
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).
|
/// Returns the number of seconds since last user input (mouse/keyboard).
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
pub fn get_idle_seconds() -> u64 {
|
pub fn get_idle_seconds() -> u64 {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-config-schema/schema.json",
|
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-config-schema/schema.json",
|
||||||
"productName": "Core Cooldown",
|
"productName": "Core Cooldown",
|
||||||
"version": "0.1.0",
|
"version": "0.1.2",
|
||||||
"identifier": "com.corecooldown.app",
|
"identifier": "com.corecooldown.app",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../dist",
|
"frontendDist": "../dist",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
50
src/app.css
50
src/app.css
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,13 +38,15 @@
|
|||||||
<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">
|
||||||
<animate
|
{#if !reducedMotion}
|
||||||
attributeName="seed"
|
<animate
|
||||||
from="0"
|
attributeName="seed"
|
||||||
to="100"
|
from="0"
|
||||||
dur="2s"
|
to="100"
|
||||||
repeatCount="indefinite"
|
dur="2s"
|
||||||
/>
|
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)" />
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
@@ -13,7 +14,7 @@
|
|||||||
|
|
||||||
let { standalone = false }: Props = $props();
|
let { standalone = false }: Props = $props();
|
||||||
|
|
||||||
const appWindow = standalone ? getCurrentWebviewWindow() : null;
|
const appWindow = $derived(standalone ? getCurrentWebviewWindow() : null);
|
||||||
|
|
||||||
let currentActivity = $state<BreakActivity>(pickRandomActivity());
|
let currentActivity = $state<BreakActivity>(pickRandomActivity());
|
||||||
let activityCycleTimer: ReturnType<typeof setInterval> | null = null;
|
let activityCycleTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
@@ -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"
|
||||||
@@ -168,23 +193,20 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<!-- ── In-app break screen: full-screen fill OR modal with backdrop ── -->
|
<!-- ── In-app break screen: full-screen fill OR modal with backdrop ── -->
|
||||||
<div
|
<div
|
||||||
class="relative h-full"
|
class="relative h-full flex items-center justify-center"
|
||||||
class:flex={isModal}
|
style="background: #000;"
|
||||||
class:items-center={isModal}
|
bind:this={breakContainer}
|
||||||
class:justify-center={isModal}
|
|
||||||
style={isModal ? `background: #000;` : ""}
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="relative flex flex-col"
|
class="relative flex flex-col items-center"
|
||||||
class:h-full={!isModal}
|
|
||||||
class:break-modal={isModal}
|
class:break-modal={isModal}
|
||||||
>
|
>
|
||||||
<!-- 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}"></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}"></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}"></div>
|
<div class="break-ripple ripple-3" style="--ripple-color: {$config.break_color}; --ripple-size: {isModal ? 160 : 200}px"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="break-breathe relative">
|
<div class="break-breathe relative">
|
||||||
@@ -193,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
|
||||||
@@ -208,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 }}
|
||||||
@@ -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"
|
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>
|
||||||
@@ -240,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}
|
||||||
@@ -261,22 +285,36 @@
|
|||||||
{/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}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Bottom progress bar for modal - uses clip-path to respect border radius -->
|
<!-- Bottom progress bar for modal -->
|
||||||
<div class="break-modal-progress-container">
|
{#if isModal}
|
||||||
<div class="break-modal-progress-track">
|
<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
|
<div
|
||||||
class="h-full transition-[width] duration-1000 ease-linear"
|
class="h-full transition-[width] duration-1000 ease-linear"
|
||||||
style="width: {$timer.breakProgress * 100}%; background: {barGradient};"
|
style="width: {$timer.breakProgress * 100}%; background: {barGradient};"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -393,8 +431,8 @@
|
|||||||
/* ── Ripple circles ── */
|
/* ── Ripple circles ── */
|
||||||
.break-ripple {
|
.break-ripple {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 140px;
|
width: var(--ripple-size, 140px);
|
||||||
height: 140px;
|
height: var(--ripple-size, 140px);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border: 1.5px solid var(--ripple-color);
|
border: 1.5px solid var(--ripple-color);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
@@ -407,10 +445,10 @@
|
|||||||
@keyframes ripple-expand {
|
@keyframes ripple-expand {
|
||||||
0% {
|
0% {
|
||||||
transform: scale(1);
|
transform: scale(1);
|
||||||
opacity: 0.3;
|
opacity: 0.25;
|
||||||
}
|
}
|
||||||
100% {
|
100% {
|
||||||
transform: scale(2.2);
|
transform: scale(2.5);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
>
|
>
|
||||||
−
|
−
|
||||||
</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}
|
||||||
>
|
>
|
||||||
+
|
+
|
||||||
|
|||||||
@@ -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">
|
||||||
@@ -324,14 +351,14 @@
|
|||||||
.time-spinner {
|
.time-spinner {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 2px;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wheel-field {
|
.wheel-field {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 50px;
|
width: 44px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: rgba(255, 255, 255, 0.05);
|
background: rgba(255, 255, 255, 0.05);
|
||||||
@@ -376,13 +403,13 @@
|
|||||||
line-height: 1;
|
line-height: 1;
|
||||||
backface-visibility: hidden;
|
backface-visibility: hidden;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
padding-right: 12px;
|
padding-right: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Unit label pinned to the right of the field */
|
/* Unit label pinned to the right of the field */
|
||||||
.unit-badge {
|
.unit-badge {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 5px;
|
right: 3px;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user