28 Commits

Author SHA1 Message Date
lashman 4e5d7a332a tweak contributing section wording 2026-03-13 14:15:37 +02:00
lashman 82cc9c62ac slim down doc comments, tidy inline notes 2026-03-13 14:05:59 +02:00
lashman da066e382e v0.2.0: WCAG 2.2 AAA accessibility + toggle fix + version bump 2026-02-18 19:18:15 +02:00
lashman b8791ecc64 build.rs cfg() checks HOST not TARGET -- use CARGO_CFG_TARGET_ENV 2026-02-18 19:18:15 +02:00
lashman 8be86a550f a11y: final cleanup -- remaining hardcoded colors 2026-02-18 19:18:15 +02:00
lashman 25e0b39043 a11y: Tasks 9,13-17 -- Settings ARIA, MiniTimer, overlays, ColorPicker, ActivityManager 2026-02-18 19:18:15 +02:00
lashman 9f8d2928cd a11y: Tasks 7-12 -- Dashboard, Settings, StatsView, BreakScreen, Celebration 2026-02-18 19:18:15 +02:00
lashman ef936cf22a a11y: Tasks 2-6 -- App shell, Titlebar, ToggleSwitch, Stepper, animate.ts 2026-02-18 19:18:15 +02:00
lashman b9ac818174 a11y: update theme tokens and global styles for WCAG AAA 2026-02-18 19:18:15 +02:00
lashman 23947d1002 Update README.md 2026-02-07 13:27:24 +00:00
lashman 9358b58198 Add emojis to all top-level README section headers 2026-02-07 15:24:03 +02:00
lashman 020b7af861 Add emojis to feature section headers in README 2026-02-07 15:22:11 +02:00
lashman adaf82d839 Add pomodoro, microbreaks, breathing guide, screen dimming, presentation mode, goals, multi-monitor, and activity manager 2026-02-07 15:11:44 +02:00
lashman ccfa03d8d2 Bump version to 0.1.2 2026-02-07 12:16:03 +02:00
lashman 32b17cbd27 Add WCAG 2.1 Level AA accessibility across all components 2026-02-07 12:13:03 +02:00
lashman e67612a1ac Update README.md 2026-02-07 09:35:24 +00:00
lashman 77931f34f2 Update README.md 2026-02-07 09:34:36 +00:00
lashman 651282ae86 Update README - fix download links, activity count, and accuracy 2026-02-07 11:24:16 +02:00
lashman eb1eb6a893 Bump version to 0.1.1 2026-02-07 11:16:29 +02:00
lashman b1ffaca5be Tighten TimeSpinner spacing and fix build warnings 2026-02-07 11:16:29 +02:00
lashman 358f2eab6e Enable custom-protocol for embedded frontend assets 2026-02-07 11:00:45 +02:00
lashman 752c20b1ec Fix WebView2 detection - use loader API instead of registry 2026-02-07 10:51:49 +02:00
lashman d5c02970ae Statically link WebView2Loader - single exe, no DLL needed 2026-02-07 02:32:54 +02:00
lashman 0fa8573c97 Detect missing WebView2 at startup, show download link 2026-02-07 02:08:51 +02:00
lashman df7b9affa3 Fix fullscreen break screen - centering, progress bar, ripple sizing 2026-02-07 01:56:10 +02:00
lashman 3ae1374712 Add screenshots, move philosophy section above features 2026-02-07 01:36:43 +02:00
lashman 7a583a2b27 Revamp README - emojis, better formatting, cleaner layout 2026-02-07 01:18:48 +02:00
lashman 48cbbfa552 Initial commit -- Core Cooldown v0.1.0 2026-02-07 01:12:49 +02:00
32 changed files with 966 additions and 629 deletions
+92 -53
View File
@@ -21,7 +21,7 @@
<img src="https://img.shields.io/badge/svelte-5-FF3E00?style=flat-square&logo=svelte&logoColor=white" alt="Svelte 5" />
<img src="https://img.shields.io/badge/rust-2021-000000?style=flat-square&logo=rust&logoColor=white" alt="Rust" />
<img src="https://img.shields.io/badge/tailwind-v4-06B6D4?style=flat-square&logo=tailwindcss&logoColor=white" alt="Tailwind v4" />
<img src="https://img.shields.io/badge/WCAG_2.1-AA-228B22?style=flat-square" alt="WCAG 2.1 AA" />
<img src="https://img.shields.io/badge/WCAG_2.2-AAA-228B22?style=flat-square&logo=w3c&logoColor=white" alt="WCAG 2.2 AAA" />
</p>
<p align="center">
@@ -32,7 +32,7 @@
---
## Why does this exist?
## 💡 Why does this exist?
Repetitive strain injury and eye strain are not personal failings. They are the predictable result of systems that treat human attention as an extractable resource. Every person at a screen deserves a tool that gently interrupts the grind - one that serves *them*, not a subscription model, not an analytics dashboard, not a corporate wellness KPI.
@@ -42,7 +42,7 @@ Core Cooldown is a single portable `.exe`. No installer, no account, no telemetr
---
## Philosophy
## 🧭 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.
@@ -59,7 +59,7 @@ Tools for human wellbeing should never be enclosed, never be scarce, and never s
---
## Screenshots
## 📸 Screenshots
<p align="center">
<img src="screenshots/01-dashboard.png" alt="Dashboard - Main Timer" width="420" /><br />
@@ -98,9 +98,9 @@ Tools for human wellbeing should never be enclosed, never be scarce, and never s
---
## Features
## Features
### Timer & Breaks
### ⏱️ Timer & Breaks
| | Feature | Description |
|:--|:--------|:------------|
@@ -116,7 +116,7 @@ Tools for human wellbeing should never be enclosed, never be scarce, and never s
<br />
### Pomodoro Mode
### 🍅 Pomodoro Mode
A full Pomodoro timer built in. Alternates short breaks with a longer recovery break after a configurable number of focus sessions.
@@ -128,7 +128,7 @@ A full Pomodoro timer built in. Alternates short breaks with a longer recovery b
<br />
### Microbreaks (20-20-20 Rule)
### 👁️ Microbreaks (20-20-20 Rule)
Quick eye rest reminders between full breaks. The 20-20-20 rule: every 20 minutes, look at something 20 feet away for 20 seconds.
@@ -141,7 +141,7 @@ Quick eye rest reminders between full breaks. The 20-20-20 rule: every 20 minute
<br />
### Breathing Guide
### 🌬️ Breathing Guide
A visual breathing exercise during breaks. The breathing text pulses with the rhythm - scaling up on inhale, holding on hold, scaling down on exhale - with a color gradient that interpolates between your accent and break colors.
@@ -155,7 +155,7 @@ A visual breathing exercise during breaks. The breathing text pulses with the rh
<br />
### Idle Detection & Smart Breaks
### 💤 Idle Detection & Smart Breaks
| | Feature | Description |
|:--|:--------|:------------|
@@ -166,7 +166,7 @@ A visual breathing exercise during breaks. The breathing text pulses with the rh
<br />
### Presentation Mode
### 🎬 Presentation Mode
Detects fullscreen applications (presentations, video calls, games) and defers breaks until you exit.
@@ -177,7 +177,7 @@ Detects fullscreen applications (presentations, video calls, games) and defers b
<br />
### Break Activities
### 🤸 Break Activities
Each break shows a randomized suggestion from a curated library of **71 activities** across four categories:
@@ -198,7 +198,7 @@ Activities cycle every 30 seconds and never repeat consecutively.
<br />
### Goals & Streaks
### 🏆 Goals & Streaks
| | Feature | Description |
|:--|:--------|:------------|
@@ -209,7 +209,7 @@ Activities cycle every 30 seconds and never repeat consecutively.
<br />
### Screen Dimming
### 🌑 Screen Dimming
A gentle pre-break nudge that gradually dims your screen before the break starts.
@@ -219,7 +219,7 @@ A gentle pre-break nudge that gradually dims your screen before the break starts
<br />
### Working Hours Schedule
### 📅 Working Hours Schedule
A per-day schedule with multiple time ranges per day. The timer only runs during your configured hours - outside those hours, it pauses automatically.
@@ -229,7 +229,7 @@ A per-day schedule with multiple time ranges per day. The timer only runs during
<br />
### Statistics & History
### 📊 Statistics & History
| | Metric | Description |
|:--|:-------|:------------|
@@ -243,7 +243,7 @@ All statistics stored locally in a plain JSON file next to the executable.
<br />
### Sound Effects
### 🔊 Sound Effects
Synthesized notification sounds via Web Audio API - no bundled audio files, no network requests.
@@ -253,7 +253,7 @@ Sounds play on break start, pre-break warning, and break completion. Volume conf
<br />
### Global Keyboard Shortcuts
### ⌨️ Global Keyboard Shortcuts
| Shortcut | Action |
|:---------|:-------|
@@ -265,7 +265,7 @@ Works system-wide, even when Core Cooldown is not focused.
<br />
### System Tray
### 🔵 System Tray
- **Dynamic icon** - 32x32 progress arc rendered in real-time (orange = focus, purple = break, dimmed = paused)
- **Countdown tooltip** - hover over tray icon to see time remaining
@@ -273,7 +273,7 @@ Works system-wide, even when Core Cooldown is not focused.
<br />
### Mini Mode
### 📌 Mini Mode
A compact floating timer (200x50px) that sits on top of your other windows.
@@ -284,7 +284,7 @@ A compact floating timer (200x50px) that sits on top of your other windows.
<br />
### Appearance & Customization
### 🎨 Appearance & Customization
| Setting | Range |
|:--------|:------|
@@ -297,7 +297,7 @@ A compact floating timer (200x50px) that sits on top of your other windows.
<br />
### Notifications
### 🔔 Notifications
Native Windows toast notifications for:
- Pre-break warnings (configurable seconds before break)
@@ -308,7 +308,7 @@ Native Windows toast notifications for:
<br />
### Window Behavior
### 🪟 Window Behavior
- **Frameless window** with custom titlebar and drag region
- **Transparent background** with frosted glass effects (backdrop-blur)
@@ -318,27 +318,60 @@ Native Windows toast notifications for:
<br />
### Accessibility
### 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.
<p>
<img src="https://img.shields.io/badge/WCAG_2.2-AAA_Conformance-228B22?style=for-the-badge&logo=w3c&logoColor=white" alt="WCAG 2.2 AAA Conformance" />
<img src="https://img.shields.io/badge/since-v0.2.0-blue?style=for-the-badge" alt="Since v0.2.0" />
</p>
| | Feature | Description |
|:--|:--------|:------------|
| ⌨️ | **Full keyboard navigation** | Every control reachable and operable via keyboard alone. Arrow keys navigate dropdowns, 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, breathing phase, break activities, and status changes. Progress rings use `role="progressbar"` with value text. Accordion panels have `aria-controls` and `aria-expanded`. Custom dropdowns support `role="listbox"` with arrow key navigation. |
| 🎯 | **Focus management** | View transitions move focus to the new view's heading. Break screen traps focus to prevent interaction with obscured content. Dropdown focus returns to trigger on close. |
| 🎨 | **Color contrast** | All text meets 4.5:1 minimum contrast ratio against dark backgrounds. Dynamic breathing text color interpolation validated against threshold. |
| 🖥️ | **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, all JavaScript-driven Web Animations API effects, and momentum scroll physics. No functionality lost - just calmer. |
| 🏷️ | **Descriptive labels** | All toggle switches, steppers, buttons, dropdowns, and form controls have descriptive accessible names |
| 👆 | **Touch targets** | Interactive elements meet minimum 32x32px hit areas for comfortable interaction |
Core Cooldown targets **WCAG 2.2 Level AAA** conformance - the highest level of the Web Content Accessibility Guidelines. A break timer for preventing repetitive strain injury should be usable by everyone, including those who already live with disabilities.
> **Why AAA?** Most applications stop at AA. We went further because the people who benefit most from a break timer - those with repetitive strain injuries, chronic pain, or vision impairments - are the same people who need the strongest accessibility support. AAA isn't a checkbox. It's the right thing to do.
#### Contrast & Visual Design
| | Feature | Standard | Description |
|:--|:--------|:---------|:------------|
| 🎨 | **Enhanced text contrast** | AAA 7:1 | All body text meets 7:1 contrast ratio against dark backgrounds. Secondary text `#a8a8a8` on `#000` = 7.28:1. |
| 🔤 | **Large text contrast** | AAA 4.5:1 | Headings and large text (18px+) meet 4.5:1 minimum. Timer countdown, break titles validated. |
| 🎯 | **Non-text contrast** | AA 3:1 | UI components (toggles, steppers, rings, chart bars, color swatches) all meet 3:1 against adjacent colors. |
| 🖥️ | **Windows High Contrast** | AAA | `forced-colors: active` maps all theme tokens to system colors. Full usability in all Windows contrast themes. |
| 🐢 | **Reduced motion** | AAA | `prefers-reduced-motion` disables all CSS animations, JS Web Animations API effects, and momentum scroll. Zero functionality loss. |
#### Keyboard & Navigation
| | Feature | Standard | Description |
|:--|:--------|:---------|:------------|
| ⌨️ | **Full keyboard access** | AAA 2.1.3 | Every control operable via keyboard alone - no exceptions. Arrow keys for color pickers, steppers, radio groups. Tab/Shift+Tab cycles all interactive elements. |
| 🔍 | **Visible focus indicators** | AAA 2.4.13 | 2px solid white outline with dark shadow fallback on every interactive element. No hidden or suppressed focus rings. |
| ⏭️ | **Skip navigation** | AA 2.4.1 | Skip-to-content link bypasses the titlebar on Tab. |
| 🏠 | **Focus management** | AA 2.4.3 | View transitions auto-focus the new view's heading. Break screen traps focus. Dropdown focus returns to trigger on close. |
| 🏷️ | **Heading structure** | AAA 2.4.10 | Semantic `h1` > `h2` hierarchy across all views. Settings sections use `h2` with `aria-labelledby`. |
#### Screen Readers & Assistive Technology
| | Feature | Standard | Description |
|:--|:--------|:---------|:------------|
| 🗣️ | **Live regions** | AA 4.1.3 | `aria-live` announces timer state, breathing phase, break activities, status changes, and celebration events. |
| 📊 | **Semantic roles** | AA 4.1.2 | `progressbar` on timer rings, `switch` on toggles, `tablist`/`tab`/`tabpanel` on stats view, `radiogroup`/`radio` on breathing patterns, `alertdialog` on overlays. |
| 📋 | **Data tables** | A 1.3.1 | Screen-reader-only data tables behind the 7-day chart provide the same information non-visually. |
| 🏷️ | **Accessible names** | AA 4.1.2 | Every toggle, stepper, button, swatch, and form control has a descriptive `aria-label` or visible label. Sound presets use `aria-pressed`. |
| 📝 | **Page titles** | AAA 2.4.2 | Dynamic `document.title` updates per view ("Core Cooldown - Dashboard", "- Settings", etc.). |
#### Target Sizes & Interaction
| | Feature | Standard | Description |
|:--|:--------|:---------|:------------|
| 👆 | **44px touch targets** | AAA 2.5.8 | All interactive elements (buttons, toggles, steppers, color swatches, titlebar controls) have minimum 44x44px hit areas. Visual size may be smaller - the clickable area extends invisibly. |
| 🎯 | **Celebration persistence** | - | Milestone/goal popups stay visible on hover or focus, with keyboard-accessible dismiss buttons and Escape key support. |
| ⏱️ | **Hold-to-repeat** | - | Stepper +/- buttons support press-and-hold for continuous increment, with keyboard Enter/Space triggering the same behavior. |
<br />
---
## Portability
## 📦 Portability
Core Cooldown is **fully portable**. The executable carries everything it needs and stores everything it creates right next to itself:
@@ -360,7 +393,7 @@ anywhere-you-want/
---
## Installation
## 🚀 Installation
```
1. Download core-cooldown.exe from the Releases page
@@ -376,7 +409,7 @@ That's it. No elevated permissions. No runtime dependencies. The first launch ma
---
## Building from Source
## 🔧 Building from Source
<details>
<summary><strong>Prerequisites</strong></summary>
@@ -435,7 +468,7 @@ Output: `src-tauri/target/x86_64-pc-windows-gnu/release/core-cooldown.exe`
---
## Architecture
## 🏗️ Architecture
A split-architecture desktop app: Rust backend for system integration and timer logic, Svelte frontend rendered in a native WebView.
@@ -445,13 +478,13 @@ A split-architecture desktop app: Rust backend for system integration and timer
│ (dynamic icon · tooltip · menu) │
├──────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Main Window │ │ Break Window│ │ Mini Window │ │
│ │ (WebView) │ │ (WebView) │ │ (WebView) │ │
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
│ │ │ │
│ └─────────┬───────┴──────────────────┘
│ │ Tauri IPC
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Main Window │ │ Break Window│ │ Mini Window │ │
│ │ (WebView) │ │ (WebView) │ │ (WebView) │ │
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
│ │ │ │ │
│ └─────────┬───────┴──────────────────┘ │
│ │ Tauri IPC │
│ ┌─────────┴─────────┐ │
│ │ Rust Backend │ │
│ │ │ │
@@ -459,7 +492,7 @@ A split-architecture desktop app: Rust backend for system integration and timer
│ │ Config │ JSON persistence (portable) │
│ │ Stats │ break history tracking │
│ │ IdleDetector │ GetLastInputInfo polling │
│ │ GlobalShortcuts │ Ctrl+Shift+P/B/S │
│ │ GlobalShortcuts │ Ctrl+Shift+P/B/S
│ │ TrayIcon │ RGBA ring rendering │
│ │ Notifications │ Windows toast │
│ └───────────────────┘ │
@@ -510,7 +543,7 @@ A split-architecture desktop app: Rust backend for system integration and timer
---
## Configuration Reference
## ⚙️ Configuration Reference
All settings stored in `config.json` next to the executable. The settings panel exposes every option with live validation, but the file is plain JSON and can be edited by hand.
@@ -677,7 +710,7 @@ All settings stored in `config.json` next to the executable. The settings panel
---
## Dependencies
## 📚 Dependencies
<details>
<summary><strong>Rust crates</strong></summary>
@@ -713,7 +746,7 @@ All settings stored in `config.json` next to the executable. The settings panel
---
## Contributing
## 🤝 Contributing
This project belongs to no one and everyone. If you find it useful and want to make it better, you are welcome.
@@ -723,7 +756,7 @@ No contribution agreements to sign. No corporate CLAs. No licensing traps. Every
- Report bugs or rough edges
- Suggest new break activities (especially with physiotherapy or ergonomics knowledge)
- Improve accessibility (WCAG 2.1 AA foundation is in place - help us push further)
- Improve accessibility (WCAG 2.2 AAA foundation is in place - help us maintain it)
- Port idle detection to macOS/Linux
- Translate the interface
- Share it with someone who needs it
@@ -734,7 +767,7 @@ The best software is built through mutual aid - people helping people because it
---
## License
## 📜 License
<p align="center">
<a href="https://creativecommons.org/publicdomain/zero/1.0/">
@@ -762,3 +795,9 @@ See [`LICENSE`](LICENSE) for the full legal text.
<em>Rest well.</em>
</sub>
</p>
<p align="center">
<sub>
<em>This project is developed with the help of a locally-run LLM via opencode for scaffolding, planning, and routine code tasks. Architecture and design decisions are my own.</em>
</sub>
</p>
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "core-cooldown",
"private": true,
"version": "0.1.3",
"version": "0.2.0",
"type": "module",
"scripts": {
"dev": "vite",
+1 -1
View File
@@ -480,7 +480,7 @@ dependencies = [
[[package]]
name = "core-cooldown"
version = "0.1.2"
version = "0.2.0"
dependencies = [
"anyhow",
"chrono",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "core-cooldown"
version = "0.1.3"
version = "0.2.0"
edition = "2021"
[lib]
+58 -5
View File
@@ -8,18 +8,71 @@ fn main() {
}
// 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();
// library so the loader is baked into the exe -- no DLL to ship.
if std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("gnu") {
swap_webview2_to_static();
}
tauri_build::build()
tauri_build::build();
// When targeting GNU, embed-resource may find MSVC's rc.exe (via Windows
// SDK) and produce a .res file instead of COFF .o. GNU ld can't link .res
// files. Fix: re-compile with windres if needed.
// Note: cfg() in build scripts checks the HOST, not target. Use env var.
if std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("gnu") {
fix_resource_lib();
}
}
/// If resource.lib is in .res format (produced by MSVC rc.exe), re-compile
/// the .rc source with MinGW windres to produce a COFF object that GNU ld
/// can link.
fn fix_resource_lib() {
use std::path::PathBuf;
use std::process::Command;
let out_dir = std::env::var("OUT_DIR").unwrap_or_default();
let out_path = PathBuf::from(&out_dir);
let rc_file = out_path.join("resource.rc");
let lib_file = out_path.join("resource.lib");
if !rc_file.exists() || !lib_file.exists() {
return;
}
// Check if the file is already COFF (starts with COFF machine type or
// archive signature). A .res file starts with 0x00000000.
if let Ok(header) = std::fs::read(&lib_file) {
if header.len() >= 4 && header[0..4] == [0, 0, 0, 0] {
// This is a .res file, not COFF -- re-compile with windres
let windres = "C:/Users/lashman/mingw-w64/mingw64/bin/windres.exe";
let status = Command::new(windres)
.args([
"-i", &rc_file.to_string_lossy(),
"-o", &lib_file.to_string_lossy(),
"--output-format=coff",
])
.status();
match status {
Ok(s) if s.success() => {
println!("cargo:warning=Re-compiled resource.rc with windres (COFF output)");
}
Ok(s) => {
println!("cargo:warning=windres failed with exit code: {}", s);
}
Err(e) => {
println!("cargo:warning=Failed to run windres: {}", e);
}
}
}
}
}
/// 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;
+1 -15
View File
@@ -3,7 +3,6 @@ use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
/// A custom break activity defined by the user.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CustomActivity {
pub id: String,
@@ -13,7 +12,6 @@ pub struct CustomActivity {
pub enabled: bool,
}
/// A single time range (e.g., 09:00 to 17:00)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TimeRange {
pub start: String, // Format: "HH:MM"
@@ -29,7 +27,6 @@ impl Default for TimeRange {
}
}
/// Schedule for a single day
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DaySchedule {
pub enabled: bool,
@@ -46,7 +43,6 @@ impl Default for DaySchedule {
}
impl DaySchedule {
/// Create a default schedule for weekend days (disabled by default)
fn weekend_default() -> Self {
Self {
enabled: false,
@@ -304,14 +300,13 @@ impl Default for Config {
}
impl Config {
/// Get the path to the config file (portable: next to the exe)
// Portable: next to the exe
fn config_path() -> Result<PathBuf> {
let exe_path = std::env::current_exe().context("Failed to determine exe path")?;
let exe_dir = exe_path.parent().context("Failed to determine exe directory")?;
Ok(exe_dir.join("config.json"))
}
/// Load configuration from file, or return default if it doesn't exist
pub fn load_or_default() -> Self {
match Self::load() {
Ok(config) => config,
@@ -327,7 +322,6 @@ impl Config {
}
}
/// Load configuration from file
pub fn load() -> Result<Self> {
let config_path = Self::config_path()?;
@@ -345,7 +339,6 @@ impl Config {
Ok(config.validate())
}
/// Save configuration to file
pub fn save(&self) -> Result<()> {
let config_path = Self::config_path()?;
@@ -357,7 +350,6 @@ impl Config {
Ok(())
}
/// Validate and sanitize configuration values
pub fn validate(mut self) -> Self {
// Break duration: 1-60 minutes
self.break_duration = self.break_duration.clamp(1, 60);
@@ -511,7 +503,6 @@ impl Config {
self
}
/// Check if a time string is in valid HH:MM format
fn is_valid_time_format(time: &str) -> bool {
let parts: Vec<&str> = time.split(':').collect();
if parts.len() != 2 {
@@ -536,29 +527,24 @@ impl Config {
hours * 60 + minutes
}
/// Check if a string is a valid hex color (#RRGGBB)
fn is_valid_hex_color(color: &str) -> bool {
color.len() == 7
&& color.starts_with('#')
&& color[1..].chars().all(|c| c.is_ascii_hexdigit())
}
/// Get break duration in seconds
pub fn break_duration_seconds(&self) -> u64 {
self.break_duration as u64 * 60
}
/// Get break frequency in seconds
pub fn break_frequency_seconds(&self) -> u64 {
self.break_frequency as u64 * 60
}
/// Get snooze duration in seconds
pub fn snooze_duration_seconds(&self) -> u64 {
self.snooze_duration as u64 * 60
}
/// Reset to default values
pub fn reset_to_default(&mut self) {
*self = Self::default();
}
+6 -15
View File
@@ -310,7 +310,6 @@ fn save_window_position(
// ── Dynamic Tray Icon Rendering ────────────────────────────────────────────
/// Parse a "#RRGGBB" hex color string into (r, g, b). Falls back to fallback on invalid input.
fn parse_hex_color(hex: &str, fallback: (u8, u8, u8)) -> (u8, u8, u8) {
if hex.len() == 7 && hex.starts_with('#') {
let r = u8::from_str_radix(&hex[1..3], 16).unwrap_or(fallback.0);
@@ -322,8 +321,6 @@ fn parse_hex_color(hex: &str, fallback: (u8, u8, u8)) -> (u8, u8, u8) {
}
}
/// Render a 32x32 RGBA icon with a progress arc using the given accent/break colors.
/// F10: Optionally renders a green checkmark when daily goal is met.
fn render_tray_icon(
progress: f64,
is_break: bool,
@@ -401,33 +398,31 @@ fn update_tray(
break_color: (u8, u8, u8),
goal_met: bool,
) {
// Update tooltip
let tooltip = match snapshot.state {
timer::TimerState::Running => {
if snapshot.deferred_break_pending {
"Core Cooldown - Break deferred (fullscreen)".to_string()
"Core Cooldown -- Break deferred (fullscreen)".to_string()
} else {
let m = snapshot.time_remaining / 60;
let s = snapshot.time_remaining % 60;
format!("Core Cooldown - {:02}:{:02} until break", m, s)
format!("Core Cooldown -- {:02}:{:02} until break", m, s)
}
}
timer::TimerState::Paused => {
if snapshot.idle_paused {
"Core Cooldown - Paused (idle)".to_string()
"Core Cooldown -- Paused (idle)".to_string()
} else {
"Core Cooldown - Paused".to_string()
"Core Cooldown -- Paused".to_string()
}
}
timer::TimerState::BreakActive => {
let m = snapshot.break_time_remaining / 60;
let s = snapshot.break_time_remaining % 60;
format!("Core Cooldown - Break {:02}:{:02}", m, s)
format!("Core Cooldown -- Break {:02}:{:02}", m, s)
}
};
let _ = tray.set_tooltip(Some(&tooltip));
// Update icon
let (progress, is_break, is_paused) = match snapshot.state {
timer::TimerState::Running => (snapshot.progress, false, false),
timer::TimerState::Paused => (snapshot.progress, false, true),
@@ -554,7 +549,6 @@ pub fn run() {
let break_c = parse_hex_color(&break_hex, (124, 106, 239));
update_tray(&tray, &snapshot, accent, break_c, goal_met);
// Emit tick event with full snapshot
let _ = handle.emit("timer-tick", &snapshot);
// F5: Screen dim window management
@@ -616,7 +610,6 @@ pub fn run() {
break_deferred_notified = false;
}
TickResult::BreakEnded => {
// Restore normal window state and close break window
handle_break_end(&handle);
// F9: Close multi-monitor overlays
close_multi_monitor_overlays(&handle);
@@ -721,7 +714,7 @@ pub fn run() {
.notification()
.builder()
.title("Break deferred")
.body("Fullscreen app detected - break will start when you exit.")
.body("Fullscreen app detected -- break will start when you exit.")
.show();
}
let _ = handle.emit("break-deferred", &());
@@ -883,7 +876,6 @@ fn close_break_window(app: &AppHandle) {
}
}
/// Handle break start: either fullscreen on main window, or open a separate modal break window.
fn handle_break_start(app: &AppHandle, fullscreen_mode: bool) {
if fullscreen_mode {
// Fullscreen: show break inside the main window
@@ -899,7 +891,6 @@ fn handle_break_start(app: &AppHandle, fullscreen_mode: bool) {
}
}
/// Handle break end: restore main window state and close break window if open.
fn handle_break_end(app: &AppHandle) {
if let Some(window) = app.get_webview_window("main") {
let _ = window.set_always_on_top(false);
+8 -8
View File
@@ -9,7 +9,7 @@ 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
// 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.
@@ -44,15 +44,15 @@ pub unsafe extern "C" fn _Init_thread_header(guard: *mut i32) {
loop {
let val = guard.read_volatile();
if val == 0 {
// Already initialised - tell caller to skip
// Already initialised -- tell caller to skip
return;
}
if val == -1 {
// Not yet initialised - try to claim it
// Not yet initialised -- try to claim it
guard.write_volatile(1);
return;
}
// val == 1: another thread is initialising - yield and retry
// val == 1: another thread is initialising -- yield and retry
std::thread::yield_now();
}
}
@@ -71,22 +71,22 @@ pub unsafe extern "C" fn _Init_thread_footer(guard: *mut i32) {
// 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`
/// `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
/// `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
ptr // null on failure -- nothrow semantics
}
/// `operator delete(void*, size_t)` - sized deallocation
/// `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) {
+1 -11
View File
@@ -3,7 +3,6 @@ use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
/// A single day's break statistics.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct DayRecord {
@@ -16,7 +15,6 @@ pub struct DayRecord {
pub natural_break_time_secs: u64,
}
/// Persistent stats stored as JSON.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct StatsData {
pub days: HashMap<String, DayRecord>,
@@ -24,12 +22,10 @@ pub struct StatsData {
pub best_streak: u32,
}
/// Runtime stats manager.
pub struct Stats {
pub data: StatsData,
}
/// Snapshot sent to the frontend.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct StatsSnapshot {
@@ -47,13 +43,11 @@ pub struct StatsSnapshot {
pub daily_goal_met: bool,
}
/// F10: Result of recording a completed break
pub struct BreakCompletedResult {
pub milestone_reached: Option<u32>,
pub daily_goal_just_met: bool,
}
/// F7: Weekly summary for reports
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct WeekSummary {
@@ -68,7 +62,7 @@ pub struct WeekSummary {
const MILESTONES: &[u32] = &[3, 5, 7, 14, 21, 30, 50, 100, 365];
impl Stats {
/// Portable: stats file lives next to the exe
// Portable: next to the exe
fn stats_path() -> Option<PathBuf> {
let exe_path = std::env::current_exe().ok()?;
let exe_dir = exe_path.parent()?;
@@ -114,7 +108,6 @@ impl Stats {
})
}
/// Record a completed break. Returns milestone/goal info for gamification.
pub fn record_break_completed(&mut self, duration_secs: u64, daily_goal: u32) -> BreakCompletedResult {
let day = self.today_mut();
let was_below_goal = day.breaks_completed < daily_goal;
@@ -182,7 +175,6 @@ impl Stats {
}
}
/// F10: Check if current streak exactly matches a milestone
fn check_milestone(&self) -> Option<u32> {
let streak = self.data.current_streak;
if MILESTONES.contains(&streak) {
@@ -225,7 +217,6 @@ impl Stats {
}
}
/// Get recent N days of history, sorted chronologically.
pub fn recent_days(&self, n: u32) -> Vec<DayRecord> {
let today = chrono::Local::now().date_naive();
let mut records = Vec::new();
@@ -243,7 +234,6 @@ impl Stats {
records
}
/// F7: Get weekly summaries for the past N weeks
pub fn weekly_summary(&self, weeks: u32) -> Vec<WeekSummary> {
let today = chrono::Local::now().date_naive();
let mut summaries = Vec::new();
+2 -15
View File
@@ -26,7 +26,6 @@ pub enum AppView {
Stats,
}
/// Snapshot of the full timer state, sent to the frontend on every tick
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TimerSnapshot {
@@ -74,7 +73,6 @@ pub struct TimerSnapshot {
pub is_long_break: bool,
}
/// Events emitted by the timer to the frontend
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BreakStartedPayload {
@@ -161,8 +159,6 @@ impl TimerManager {
}
}
/// Check idle state and auto-pause/resume accordingly.
/// Returns IdleCheckResult indicating what happened.
pub fn check_idle(&mut self) -> IdleCheckResult {
if !self.config.idle_detection_enabled {
// If idle detection disabled but we were idle-paused, resume
@@ -218,7 +214,6 @@ impl TimerManager {
}
}
/// F2: Check if the foreground window is fullscreen (presentation mode)
pub fn check_presentation_mode(&mut self) -> bool {
if !self.config.presentation_mode_enabled {
self.presentation_mode_active = false;
@@ -230,7 +225,6 @@ impl TimerManager {
fs
}
/// Called every second. Returns what events should be emitted.
pub fn tick(&mut self) -> TickResult {
// Idle detection and natural break detection
let idle_result = self.check_idle();
@@ -322,7 +316,6 @@ impl TimerManager {
}
}
/// F1: Called every second for microbreak logic. Returns microbreak events.
pub fn tick_microbreak(&mut self) -> MicrobreakTickResult {
if !self.config.microbreak_enabled {
return MicrobreakTickResult::None;
@@ -416,7 +409,6 @@ impl TimerManager {
self.snoozes_used = 0;
}
/// F3: Advance the Pomodoro cycle position after a break completes
fn advance_pomodoro_cycle(&mut self) {
if !self.config.pomodoro_enabled {
return;
@@ -472,14 +464,14 @@ impl TimerManager {
let past_half = total > 0 && elapsed * 2 >= total;
if past_half && self.config.allow_end_early {
// "End break" - counts as completed
// "End break" -- counts as completed
self.has_had_break = true;
self.seconds_since_last_break = 0;
self.advance_pomodoro_cycle();
self.reset_timer();
true
} else if !past_half {
// "Cancel break" - doesn't count
// "Cancel break" -- doesn't count
// F3: Pomodoro reset-on-skip
if self.config.pomodoro_enabled && self.config.pomodoro_reset_on_skip {
self.pomodoro_cycle_position = 0;
@@ -672,14 +664,12 @@ pub enum TickResult {
BreakDeferred, // F2
}
/// F1: Microbreak tick result
pub enum MicrobreakTickResult {
None,
MicrobreakStarted,
MicrobreakEnded,
}
/// Result of checking idle state
pub enum IdleCheckResult {
None,
JustPaused,
@@ -687,7 +677,6 @@ pub enum IdleCheckResult {
NaturalBreakDetected(u64), // duration in seconds
}
/// Returns the number of seconds since last user input (mouse/keyboard).
#[cfg(windows)]
pub fn get_idle_seconds() -> u64 {
use std::mem;
@@ -712,7 +701,6 @@ pub fn get_idle_seconds() -> u64 {
0
}
/// F2: Check if the foreground window is a fullscreen application
#[cfg(windows)]
pub fn is_foreground_fullscreen() -> bool {
use std::mem;
@@ -756,7 +744,6 @@ pub fn is_foreground_fullscreen() -> bool {
false
}
/// F9: Get all monitor rects for multi-monitor break enforcement
#[cfg(windows)]
pub fn get_all_monitors() -> Vec<MonitorInfo> {
use winapi::shared::windef::{HMONITOR, HDC, LPRECT};
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-config-schema/schema.json",
"productName": "Core Cooldown",
"version": "0.1.3",
"version": "0.2.0",
"identifier": "com.corecooldown.app",
"build": {
"frontendDist": "../dist",
+14 -1
View File
@@ -62,7 +62,7 @@
return () => mq.removeEventListener("change", handler);
});
// Transition parameters - zero when reduced motion active
// Transition parameters -- zero when reduced motion active
const DURATION = $derived(reducedMotion ? 0 : 700);
const easing = cubicOut;
@@ -75,6 +75,17 @@
});
});
// WCAG 2.4.2: Document title reflects current view
$effect(() => {
const viewNames: Record<string, string> = {
dashboard: "Dashboard",
breakScreen: "Break",
settings: "Settings",
stats: "Statistics",
};
document.title = `Core Cooldown -- ${viewNames[effectiveView] ?? "Dashboard"}`;
});
// When fullscreen_mode is OFF, the separate break window handles breaks,
// so the main window should keep showing whatever view it was on (dashboard).
const effectiveView = $derived(
@@ -85,11 +96,13 @@
</script>
<main class="relative h-full bg-black">
<a href="#main-content" class="skip-link">Skip to content</a>
{#if $config.background_blobs_enabled}
<BackgroundBlobs accentColor={$config.accent_color} breakColor={$config.break_color} />
{/if}
<Titlebar />
<div
id="main-content"
class="relative h-full overflow-hidden origin-top-left"
style="
transform: scale({zoomScale});
+32 -4
View File
@@ -5,17 +5,19 @@
--color-surface: #0e0e0e;
--color-card: #141414;
--color-card-lt: #1c1c1c;
--color-border: #222222;
--color-border: #3a3a3a;
--color-accent: #ff4d00;
--color-accent-lt: #ff7733;
--color-accent-dim: #ff4d0018;
--color-accent-glow: #ff4d0040;
--color-success: #3fb950;
--color-warning: #f0a500;
--color-danger: #f85149;
--color-danger: #ff6b6b;
--color-text-pri: #ffffff;
--color-text-sec: #8a8a8a;
--color-text-dim: #3a3a3a;
--color-text-sec: #a8a8a8;
--color-text-dim: #5c5c5c;
--color-input-border: #444444;
--color-surface-lt: #1e1e1e;
--color-caption-bg: #050505;
}
@@ -30,6 +32,11 @@ body {
Arial, sans-serif;
overflow: hidden;
height: 100%;
line-height: 1.625;
}
[data-tauri-drag-region],
[data-tauri-drag-region] * {
user-select: none;
-webkit-user-select: none;
}
@@ -69,10 +76,31 @@ body {
border-width: 0;
}
/* ── Accessibility: Skip link ── */
.skip-link {
position: absolute;
top: -100%;
left: 50%;
transform: translateX(-50%);
z-index: 10000;
padding: 8px 16px;
background: var(--color-accent);
color: #000;
font-size: 13px;
font-weight: 600;
border-radius: 0 0 8px 8px;
text-decoration: none;
transition: top 0.15s ease;
}
.skip-link:focus {
top: 0;
}
/* ── Accessibility: Focus indicators ── */
:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.3);
}
/* ── Accessibility: Reduced motion ── */
+13 -13
View File
@@ -409,7 +409,7 @@
<button
bind:this={dropdownTriggerRef}
use:pressable
class="flex items-center gap-1.5 rounded-xl border border-[#161616] bg-black px-3 py-2.5 text-[13px] text-[#8a8a8a]
class="flex items-center gap-1.5 rounded-xl border border-[#161616] bg-black px-3 py-2.5 text-[13px] text-text-sec
hover:border-[#333] hover:text-white transition-colors"
onclick={() => { dropdownOpen = !dropdownOpen; }}
aria-haspopup="listbox"
@@ -422,12 +422,12 @@
</svg>
</button>
{#if dropdownOpen}
<div class="absolute top-full left-0 mt-1 z-50 min-w-[140px] rounded-xl border border-[#222] bg-[#111] shadow-xl shadow-black/50 overflow-hidden"
<div class="absolute top-full left-0 mt-1 z-50 min-w-[140px] rounded-xl border border-border bg-[#111] shadow-xl shadow-black/50 overflow-hidden"
role="listbox" aria-label="Activity category">
{#each categories as cat, i}
<button
class="w-full text-left px-3.5 py-2.5 text-[13px] transition-colors outline-none
{newActivityCategory === cat ? 'text-white bg-[#1a1a1a]' : 'text-[#8a8a8a] hover:bg-[#1a1a1a] hover:text-white'}
{newActivityCategory === cat ? 'text-white bg-[#1a1a1a]' : 'text-text-sec hover:bg-[#1a1a1a] hover:text-white'}
{focusedOptionIndex === i ? 'bg-[#1a1a1a] text-white' : ''}"
role="option"
aria-selected={newActivityCategory === cat}
@@ -442,7 +442,7 @@
</div>
<button
use:pressable
class="flex items-center justify-center w-10 h-10 rounded-xl border border-[#161616] text-[18px] text-[#8a8a8a]
class="flex items-center justify-center w-10 h-10 rounded-xl border border-[#161616] text-[18px] text-text-sec
hover:border-[#333] hover:text-white transition-colors disabled:opacity-30"
onclick={addCustomActivity}
disabled={!newActivityText.trim()}
@@ -463,13 +463,13 @@
class="rounded-xl px-3 py-2 text-[11px] tracking-wider transition-all duration-200
{isExpanded
? 'bg-[#1a1a1a] text-white border border-[#333]'
: 'bg-[#0a0a0a] text-[#8a8a8a] border border-[#161616] hover:border-[#333] hover:text-white'}"
: 'bg-[#0a0a0a] text-text-sec border border-[#161616] hover:border-[#333] hover:text-white'}"
onclick={() => toggleCategory(cat)}
aria-expanded={isExpanded}
aria-controls="activity-panel-{cat}"
>
<span class="uppercase">{getCategoryLabel(cat)}</span>
<span class="ml-1.5 text-[10px] {isExpanded ? 'text-[#8a8a8a]' : 'text-[#8a8a8a] opacity-60'}">
<span class="ml-1.5 text-[10px] {isExpanded ? 'text-text-sec' : 'text-text-sec opacity-60'}">
{total - disabled}/{total}
</span>
</button>
@@ -486,7 +486,7 @@
<div class="rounded-xl border border-[#161616] bg-[#0a0a0a] overflow-hidden" use:blockParentDrag>
{#if catCustoms.length > 0}
<div class="px-3 pt-2.5 pb-1">
<div class="text-[9px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">Custom</div>
<div class="text-[9px] font-medium tracking-[0.15em] text-text-sec uppercase">Custom</div>
</div>
{/if}
@@ -497,17 +497,17 @@
{#each catCustoms as activity (activity.id)}
<div class="flex items-center gap-1 px-1.5 py-0.5 group hover:bg-[#111]">
<button
class="flex items-center justify-center min-w-[32px] min-h-[32px] text-[13px] transition-opacity {activity.is_favorite ? 'opacity-100' : 'opacity-30 hover:opacity-60'}"
class="flex items-center justify-center w-9 h-9 min-w-[44px] min-h-[44px] text-[13px] transition-opacity {activity.is_favorite ? 'opacity-100' : 'opacity-30 hover:opacity-60'}"
onclick={() => toggleCustomFavorite(activity.id)}
aria-label="{activity.is_favorite ? 'Remove from favorites' : 'Add to favorites'}"
>
</button>
<span class="flex-1 text-[12px] {activity.enabled ? 'text-white' : 'text-[#8a8a8a] line-through'}">
<span class="flex-1 text-[12px] {activity.enabled ? 'text-white' : 'text-text-sec line-through'}">
{activity.text}
</span>
<button
class="flex items-center justify-center min-w-[32px] min-h-[32px] text-[#8a8a8a] hover:text-[#f85149] opacity-0 group-hover:opacity-100 focus-visible:opacity-100 transition-all text-[11px]"
class="flex items-center justify-center w-9 h-9 min-w-[44px] min-h-[44px] text-text-sec hover:text-[#ff6b6b] opacity-0 group-hover:opacity-100 focus-visible:opacity-100 transition-all text-[11px]"
onclick={() => removeCustomActivity(activity.id)}
aria-label="Remove {activity.text}"
>
@@ -523,20 +523,20 @@
{#if catCustoms.length > 0 && catBuiltins.length > 0}
<div class="px-3 pt-2 pb-1">
<div class="text-[9px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">Built-in</div>
<div class="text-[9px] font-medium tracking-[0.15em] text-text-sec uppercase">Built-in</div>
</div>
{/if}
{#each catBuiltins as activity (activity.text)}
<div class="flex items-center gap-1 px-1.5 py-0.5 hover:bg-[#111]">
<button
class="flex items-center justify-center min-w-[32px] min-h-[32px] text-[13px] transition-opacity {isBuiltinFavorite(activity.text) ? 'opacity-100' : 'opacity-30 hover:opacity-60'}"
class="flex items-center justify-center w-9 h-9 min-w-[44px] min-h-[44px] text-[13px] transition-opacity {isBuiltinFavorite(activity.text) ? 'opacity-100' : 'opacity-30 hover:opacity-60'}"
onclick={() => toggleBuiltinFavorite(activity.text)}
aria-label="{isBuiltinFavorite(activity.text) ? 'Remove from favorites' : 'Add to favorites'}"
>
</button>
<span class="flex-1 text-[12px] {!isBuiltinDisabled(activity.text) ? 'text-[#8a8a8a]' : 'text-[#8a8a8a] line-through opacity-60'}">
<span class="flex-1 text-[12px] {!isBuiltinDisabled(activity.text) ? 'text-text-sec' : 'text-text-sec line-through opacity-60'}">
{activity.text}
</span>
<ToggleSwitch
+3
View File
@@ -37,9 +37,12 @@
</script>
<div
role="alertdialog"
aria-label="Break in progress"
class="fixed inset-0 flex flex-col items-center justify-center"
style="background: rgba(0, 0, 0, {$config.backdrop_opacity});"
>
<h2 class="sr-only">Break in Progress</h2>
<p class="text-[16px] font-medium text-white mb-4">Break in progress</p>
<span class="text-[42px] font-semibold tabular-nums text-white leading-none mb-6">
+38 -16
View File
@@ -74,6 +74,18 @@
let breathCountdown = $state(4);
let breathScale = $state(0.6);
// Only announce phase name changes (not countdown ticks) to screen readers
let breathAnnouncement = $state("");
let lastBreathPhase = $state("");
$effect(() => {
// Extract just the phase name (e.g., "Inhale" from "Inhale 4")
const phaseName = breathPhase?.split(' ')[0] ?? "";
if (phaseName && phaseName !== lastBreathPhase) {
lastBreathPhase = phaseName;
breathAnnouncement = phaseName;
}
});
// Map raw 0.6-1.0 scale to 0.9-1.6 range for visible breathing text
const textScale = $derived(0.9 + (breathScale - 0.6) * (0.7 / 0.4));
@@ -169,11 +181,11 @@
<span
class="block mt-1.5 text-[9px] tracking-wider uppercase text-center font-medium"
style="transform: scale({textScale}); color: {breathColor}; opacity: {0.5 + breathT * 0.5}; transition: transform 0.15s ease-out, opacity 0.15s ease-out, color 0.15s ease-out;"
aria-live="polite" aria-atomic="true"
aria-label="Breathing guide: {breathPhase} {breathCountdown > 0 ? breathCountdown + ' seconds' : ''}"
aria-hidden="true"
>
{breathPhase}{breathCountdown > 0 ? ` ${breathCountdown}s` : ""}
</span>
<span class="sr-only" aria-live="polite" aria-atomic="true">{breathAnnouncement}</span>
{/if}
</div>
</TimerRing>
@@ -185,16 +197,16 @@
<h2 class="text-[17px] font-medium text-white mb-1.5" tabindex="-1">
{$timer.breakTitle}
</h2>
<p class="text-[12px] leading-relaxed text-[#8a8a8a] mb-4 max-w-[240px]">
<p class="text-[12px] leading-relaxed text-text-sec mb-4 max-w-[240px]">
{$timer.breakMessage}
</p>
{#if $config.show_break_activities}
<div class="rounded-xl border border-[#ffffff08] bg-[#ffffff06] px-4 py-2.5 mb-4 max-w-[260px]">
<div class="text-[9px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase mb-1">
<div class="text-[9px] font-medium tracking-[0.15em] text-text-sec uppercase mb-1">
{getCategoryLabel(currentActivity.category)}
</div>
<p class="text-[12px] leading-relaxed text-[#8a8a8a]" aria-live="polite">
<p class="text-[12px] leading-relaxed text-text-sec" aria-live="polite">
{currentActivity.text}
</p>
</div>
@@ -205,7 +217,7 @@
<button
use:pressable
class="rounded-full border border-[#333] px-5 py-2 text-[11px]
tracking-wider text-[#8a8a8a] uppercase
tracking-wider text-text-sec uppercase
transition-colors duration-200
hover:border-[#444] hover:text-[#ccc]"
onclick={cancelBreak}
@@ -226,10 +238,15 @@
{/if}
</div>
{#if $config.snooze_limit > 0}
<p class="mt-2 text-[9px] text-[#8a8a8a]">
<p class="mt-2 text-[9px] text-text-sec">
{$timer.snoozesUsed}/{$config.snooze_limit} snoozes used
</p>
{/if}
{:else}
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<span tabindex="0" class="sr-only" aria-live="polite">
Break in progress, please wait
</span>
{/if}
</div>
@@ -302,11 +319,11 @@
class:text-[10px]={!isModal}
class:text-[9px]={isModal}
style="transform: scale({textScale}); color: {breathColor}; opacity: {0.5 + breathT * 0.5}; transition: transform 0.15s ease-out, opacity 0.15s ease-out, color 0.15s ease-out;"
aria-live="polite" aria-atomic="true"
aria-label="Breathing guide: {breathPhase} {breathCountdown > 0 ? breathCountdown + ' seconds' : ''}"
aria-hidden="true"
>
{breathPhase}{breathCountdown > 0 ? ` ${breathCountdown}s` : ""}
</span>
<span class="sr-only" aria-live="polite" aria-atomic="true">{breathAnnouncement}</span>
{/if}
</div>
</TimerRing>
@@ -328,7 +345,7 @@
</h2>
<p
class="max-w-[300px] text-center text-[13px] leading-relaxed text-[#8a8a8a]"
class="max-w-[300px] text-center text-[13px] leading-relaxed text-text-sec"
class:mb-4={$config.show_break_activities}
class:mb-8={!$config.show_break_activities}
use:fadeIn={{ delay: 0.35, y: 10 }}
@@ -341,10 +358,10 @@
class="mb-8 mx-auto max-w-[320px] rounded-2xl border border-[#1a1a1a] bg-[#0a0a0a] px-5 py-3.5 text-center"
use:fadeIn={{ delay: 0.4, y: 10 }}
>
<div class="mb-1.5 text-[10px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
<div class="mb-1.5 text-[10px] font-medium tracking-[0.15em] text-text-sec uppercase">
{getCategoryLabel(currentActivity.category)}
</div>
<p class="text-[13px] leading-relaxed text-[#8a8a8a]" aria-live="polite">
<p class="text-[13px] leading-relaxed text-text-sec" aria-live="polite">
{currentActivity.text}
</p>
</div>
@@ -354,8 +371,8 @@
<div class="flex items-center gap-3" use:fadeIn={{ delay: 0.45, y: 10 }}>
<button
use:pressable
class="rounded-full border border-[#222] px-6 py-2.5 text-[12px]
tracking-wider text-[#8a8a8a] uppercase
class="rounded-full border border-border px-6 py-2.5 text-[12px]
tracking-wider text-text-sec uppercase
transition-colors duration-200
hover:border-[#333] hover:text-[#ccc]"
onclick={cancelBreak}
@@ -376,10 +393,15 @@
{/if}
</div>
{#if $config.snooze_limit > 0}
<p class="mt-3 text-[10px] text-[#8a8a8a]">
<p class="mt-3 text-[10px] text-text-sec">
{$timer.snoozesUsed}/{$config.snooze_limit} snoozes used
</p>
{/if}
{:else}
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<span tabindex="0" class="sr-only" aria-live="polite">
Break in progress, please wait
</span>
{/if}
<!-- Bottom progress bar for modal -->
@@ -461,7 +483,7 @@
background: rgba(255, 255, 255, 0.03);
}
/* Ripple container - sits behind the ring, overflows the card */
/* Ripple container -- sits behind the ring, overflows the card */
.ripple-container {
position: absolute;
inset: 0;
+156 -15
View File
@@ -2,10 +2,99 @@
import { milestoneEvent, dailyGoalEvent } from "../stores/timer";
import { config } from "../stores/config";
const showMilestone = $derived($milestoneEvent !== null && $config.milestone_celebrations);
const showGoal = $derived($dailyGoalEvent && $config.milestone_celebrations);
const storeMilestone = $derived($milestoneEvent !== null && $config.milestone_celebrations);
const storeGoal = $derived($dailyGoalEvent && $config.milestone_celebrations);
const streakDays = $derived($milestoneEvent ?? 0);
// Local visibility state (decoupled from store for hover persistence)
let showMilestone = $state(false);
let showGoal = $state(false);
let milestoneHovering = $state(false);
let goalHovering = $state(false);
let milestoneFading = $state(false);
let goalFading = $state(false);
// Timeout handles for auto-dismiss
let milestoneTimeout: ReturnType<typeof setTimeout> | null = null;
let goalTimeout: ReturnType<typeof setTimeout> | null = null;
const DISMISS_DELAY = 3500; // matches original animation duration
const FADE_DURATION = 600; // fade-out transition time
function dismissMilestone() {
milestoneFading = true;
setTimeout(() => {
showMilestone = false;
milestoneFading = false;
}, FADE_DURATION);
}
function dismissGoal() {
goalFading = true;
setTimeout(() => {
showGoal = false;
goalFading = false;
}, FADE_DURATION);
}
function startMilestoneTimer() {
if (milestoneTimeout) clearTimeout(milestoneTimeout);
milestoneTimeout = setTimeout(() => {
milestoneTimeout = null;
dismissMilestone();
}, DISMISS_DELAY);
}
function startGoalTimer() {
if (goalTimeout) clearTimeout(goalTimeout);
goalTimeout = setTimeout(() => {
goalTimeout = null;
dismissGoal();
}, DISMISS_DELAY);
}
// When the store signals a milestone, show locally and start auto-dismiss
$effect(() => {
if (storeMilestone) {
showMilestone = true;
milestoneFading = false;
startMilestoneTimer();
}
});
// When the store signals daily goal, show locally and start auto-dismiss
$effect(() => {
if (storeGoal) {
showGoal = true;
goalFading = false;
startGoalTimer();
}
});
// Pause/resume milestone dismiss on hover/focus
$effect(() => {
if (milestoneHovering) {
if (milestoneTimeout) {
clearTimeout(milestoneTimeout);
milestoneTimeout = null;
}
} else if (showMilestone && !milestoneFading) {
startMilestoneTimer();
}
});
// Pause/resume goal dismiss on hover/focus
$effect(() => {
if (goalHovering) {
if (goalTimeout) {
clearTimeout(goalTimeout);
goalTimeout = null;
}
} else if (showGoal && !goalFading) {
startGoalTimer();
}
});
// Generate confetti particles on milestone
const confettiColors = ["#ff4d00", "#7c6aef", "#3fb950", "#fca311", "#f72585", "#4361ee"];
const confettiParticles = $derived(
@@ -23,7 +112,27 @@
</script>
{#if showMilestone}
<div class="celebration-overlay" role="alert" aria-live="assertive">
<!-- svelte-ignore a11y_no_static_element_interactions a11y_no_noninteractive_element_interactions -->
<div
class="celebration-overlay"
class:fading={milestoneFading}
role="alert"
aria-live="assertive"
tabindex="-1"
onmouseenter={() => milestoneHovering = true}
onmouseleave={() => milestoneHovering = false}
onfocusin={() => milestoneHovering = true}
onfocusout={() => milestoneHovering = false}
onkeydown={(e) => { if (e.key === 'Escape') dismissMilestone(); }}
>
<button
onclick={() => dismissMilestone()}
class="absolute top-3 right-3 w-8 h-8 flex items-center justify-center text-white/50 hover:text-white rounded-full"
aria-label="Dismiss notification"
>
&times;
</button>
<!-- Confetti burst -->
<div class="confetti-container">
{#each confettiParticles as p (p.id)}
@@ -51,12 +160,31 @@
{/if}
{#if showGoal && !showMilestone}
<div class="goal-overlay" role="alert" aria-live="assertive">
<!-- svelte-ignore a11y_no_static_element_interactions a11y_no_noninteractive_element_interactions -->
<div
class="goal-overlay"
class:fading={goalFading}
role="alert"
aria-live="assertive"
tabindex="-1"
onmouseenter={() => goalHovering = true}
onmouseleave={() => goalHovering = false}
onfocusin={() => goalHovering = true}
onfocusout={() => goalHovering = false}
onkeydown={(e) => { if (e.key === 'Escape') dismissGoal(); }}
>
<div class="goal-badge">
<svg aria-hidden="true" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#3fb950" stroke-width="2.5">
<path d="M20 6L9 17l-5-5"/>
</svg>
<span class="text-[14px] font-medium text-[#3fb950] ml-2">Daily goal reached!</span>
<button
onclick={() => dismissGoal()}
class="w-6 h-6 flex items-center justify-center text-[#3fb950]/50 hover:text-[#3fb950] rounded-full ml-2"
aria-label="Dismiss notification"
>
&times;
</button>
</div>
</div>
{/if}
@@ -69,13 +197,18 @@
align-items: center;
justify-content: center;
z-index: 9999;
pointer-events: none;
animation: celebration-fade 3.5s ease forwards;
opacity: 1;
transition: opacity 0.6s ease;
animation: celebration-enter 0.3s ease forwards;
}
@keyframes celebration-fade {
0%, 70% { opacity: 1; }
100% { opacity: 0; }
.celebration-overlay.fading {
opacity: 0;
}
@keyframes celebration-enter {
0% { opacity: 0; }
100% { opacity: 1; }
}
.confetti-container {
@@ -130,15 +263,18 @@
left: 50%;
transform: translateX(-50%);
z-index: 9999;
pointer-events: none;
animation: goal-slide 3.5s ease forwards;
opacity: 1;
transition: opacity 0.6s ease;
animation: goal-enter 0.35s ease forwards;
}
@keyframes goal-slide {
.goal-overlay.fading {
opacity: 0;
}
@keyframes goal-enter {
0% { transform: translateX(-50%) translateY(-20px); opacity: 0; }
10% { transform: translateX(-50%) translateY(0); opacity: 1; }
75% { opacity: 1; }
100% { transform: translateX(-50%) translateY(-10px); opacity: 0; }
100% { transform: translateX(-50%) translateY(0); opacity: 1; }
}
.goal-badge {
@@ -158,6 +294,11 @@
.goal-overlay {
animation: none;
opacity: 1;
transition: none;
}
.celebration-overlay.fading,
.goal-overlay.fading {
opacity: 0;
}
.confetti-particle {
display: none;
+26 -16
View File
@@ -231,7 +231,7 @@
<div class="flex items-center justify-between">
<div>
<div class="text-[13px] text-white">{label}</div>
<div class="font-mono text-[11px] text-[#8a8a8a]">{value}</div>
<div class="font-mono text-[11px] text-text-sec">{value}</div>
</div>
<div
class="h-6 w-6 rounded-full border border-[#333] shadow-[0_0_8px_0px] transition-shadow duration-300"
@@ -240,34 +240,44 @@
</div>
<!-- Preset swatches -->
<div class="flex flex-wrap gap-[6px]">
<div class="flex flex-wrap gap-[8px]">
{#each presets as color}
<button
type="button"
class="h-[22px] w-[22px] rounded-full transition-all duration-150
{value === color
? 'ring-[1.5px] ring-white ring-offset-1 ring-offset-black scale-110'
: 'hover:scale-110 opacity-80 hover:opacity-100'}"
style="background: {color};"
class="group flex items-center justify-center min-h-[44px] min-w-[44px] bg-transparent border-none p-0"
onclick={() => selectPreset(color)}
aria-label="Select {getColorName(color)}"
></button>
aria-pressed={value === color}
>
<span
class="block h-[28px] w-[28px] rounded-full transition-all duration-150 pointer-events-none
{value === color
? 'ring-[1.5px] ring-white ring-offset-1 ring-offset-black scale-110'
: 'group-hover:scale-110 opacity-80 group-hover:opacity-100'}"
style="background: {color};"
></span>
</button>
{/each}
<!-- Custom toggle swatch - ring shows when picker open OR value is custom -->
<!-- Custom toggle swatch -- ring shows when picker open OR value is custom -->
<button
type="button"
class="h-[22px] w-[22px] rounded-full transition-all duration-150
{showCustom || !isPreset
? 'ring-[1.5px] ring-white ring-offset-1 ring-offset-black scale-110'
: 'hover:scale-110 opacity-80 hover:opacity-100'}"
style="background: conic-gradient(from 0deg, #f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00);"
class="group flex items-center justify-center min-h-[44px] min-w-[44px] bg-transparent border-none p-0"
onclick={() => { showCustom = !showCustom; }}
aria-label="Custom color"
></button>
aria-pressed={showCustom || !isPreset}
>
<span
class="block h-[28px] w-[28px] rounded-full transition-all duration-150 pointer-events-none
{showCustom || !isPreset
? 'ring-[1.5px] ring-white ring-offset-1 ring-offset-black scale-110'
: 'opacity-80 group-hover:opacity-100 group-hover:scale-110'}"
style="background: conic-gradient(from 0deg, #f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00);"
></span>
</button>
</div>
<!-- Inline custom picker - slides open/closed -->
<!-- Inline custom picker -- slides open/closed -->
{#if showCustom}
<div
class="flex flex-col gap-3 rounded-xl bg-[#0f0f0f] p-3 border border-[#1a1a1a]"
+114 -92
View File
@@ -104,7 +104,7 @@
// Gap between ring and button, compensating for CSS transform phantom space.
// transform: scale() doesn't affect layout, so the 280px box stays full-size
// even when visually shrunk - creating phantom space below the visual ring.
// even when visually shrunk -- creating phantom space below the visual ring.
const ringSize = 280;
const phantomBelow = $derived((ringSize * (1 - ringScale)) / 2);
const targetGap = $derived(Math.max(16, Math.round((windowH - 400) * 0.05 + 16)));
@@ -112,6 +112,7 @@
// Natural break notification
let showNaturalBreakToast = $state(false);
let toastHovering = $state(false);
let naturalBreakToastTimeout: ReturnType<typeof setTimeout> | null = null;
// Watch for natural break detection
@@ -120,7 +121,9 @@
showNaturalBreakToast = true;
if (naturalBreakToastTimeout) clearTimeout(naturalBreakToastTimeout);
naturalBreakToastTimeout = setTimeout(() => {
showNaturalBreakToast = false;
if (!toastHovering) {
showNaturalBreakToast = false;
}
}, 5000);
}
});
@@ -177,7 +180,7 @@
<!-- Status label -->
<span
class="block text-center text-[11px] font-medium tracking-[0.25em]"
class:text-[#8a8a8a]={!$timer.prebreakWarning && !$timer.deferredBreakPending}
class:text-text-sec={!$timer.prebreakWarning && !$timer.deferredBreakPending}
class:text-warning={$timer.prebreakWarning}
class:text-[#fca311]={$timer.deferredBreakPending}
>
@@ -205,15 +208,16 @@
></div>
{/each}
</div>
<span class="text-[9px] text-[#8a8a8a] tabular-nums">
<span class="text-[9px] text-text-sec tabular-nums">
{$timer.pomodoroCyclePosition + 1}/{$timer.pomodoroTotalInCycle}
</span>
</div>
<span class="sr-only">Pomodoro cycle: session {$timer.pomodoroCyclePosition + 1} of {$timer.pomodoroTotalInCycle}</span>
{/if}
<!-- Microbreak countdown -->
{#if $timer.microbreakEnabled && !$timer.microbreakActive && $timer.state === "running"}
<div class="flex items-center gap-1 text-[9px] text-[#8a8a8a]">
<div class="flex items-center gap-1 text-[9px] text-text-sec">
<svg aria-hidden="true" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z"/>
<circle cx="12" cy="12" r="3"/>
@@ -231,14 +235,22 @@
</svg>
<span class="text-[9px] text-[#3fb950]">Goal met</span>
{:else}
<span class="text-[9px] text-[#8a8a8a]">Goal</span>
<div class="w-16 h-[2px] rounded-full overflow-hidden" style="background: #161616;">
<span class="text-[9px] text-text-sec">Goal</span>
<div
class="w-16 h-[2px] rounded-full overflow-hidden"
style="background: #161616;"
role="progressbar"
aria-label="Daily goal progress"
aria-valuemin={0}
aria-valuemax={$config.daily_goal_breaks}
aria-valuenow={dailyGoalProgress}
>
<div
class="h-full rounded-full transition-[width] duration-500"
style="width: {Math.min(100, (dailyGoalProgress / $config.daily_goal_breaks) * 100)}%; background: {$config.accent_color};"
></div>
</div>
<span class="text-[9px] text-[#8a8a8a] tabular-nums">
<span class="text-[9px] text-text-sec tabular-nums">
{dailyGoalProgress}/{$config.daily_goal_breaks}
</span>
{/if}
@@ -253,7 +265,7 @@
<!-- Last break info -->
<div use:fadeIn={{ delay: 0.4, y: 10 }}>
{#if $timer.hasHadBreak}
<p style="margin-bottom: {ringMargin}px;" class="text-[12px] text-[#8a8a8a]">
<p style="margin-bottom: {ringMargin}px;" class="text-[12px] text-text-sec">
Last break {formatDurationAgo($timer.secondsSinceLastBreak)}
</p>
{:else}
@@ -263,17 +275,24 @@
<!-- Natural break notification toast -->
{#if showNaturalBreakToast}
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
role="alert"
class="absolute top-6 left-1/2 -translate-x-1/2 rounded-2xl border px-5 py-3 text-center backdrop-blur-xl transition-all duration-500"
style="border-color: rgba(63, 185, 80, 0.3); background: rgba(63, 185, 80, 0.1);"
use:scaleIn={{ duration: 0.3, delay: 0 }}
onmouseenter={() => toastHovering = true}
onmouseleave={() => { toastHovering = false; showNaturalBreakToast = false; }}
onfocusin={() => toastHovering = true}
onfocusout={() => { toastHovering = false; showNaturalBreakToast = false; }}
onkeydown={(e) => { if (e.key === 'Escape') showNaturalBreakToast = false; }}
>
<div class="flex items-center gap-2">
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#3fb950" stroke-width="2">
<path d="M20 6L9 17l-5-5"/>
</svg>
<span class="text-[13px] text-[#3fb950]">Natural break detected - timer reset</span>
<button onclick={() => showNaturalBreakToast = false} class="ml-2 text-text-sec hover:text-white" aria-label="Dismiss notification">&times;</button>
</div>
</div>
{/if}
@@ -292,94 +311,97 @@
{toggleBtnText}
</button>
<!-- Bottom left: start break now -->
<div class="absolute bottom-5 left-5" use:fadeIn={{ delay: 0.5, y: 8 }}>
<button
aria-label="Start break now"
use:pressable
class="flex h-11 w-11 items-center justify-center rounded-full
border border-[#222] text-[#8a8a8a]
transition-colors duration-200
hover:border-[#333] hover:text-[#aaa]"
onclick={startBreakNow}
>
<svg
aria-hidden="true"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
<!-- Bottom navigation buttons -->
<nav aria-label="Main actions" class="contents">
<!-- Bottom left: start break now -->
<div class="absolute bottom-5 left-5" use:fadeIn={{ delay: 0.5, y: 8 }}>
<button
aria-label="Start break now"
use:pressable
class="flex h-11 w-11 items-center justify-center rounded-full
border border-border text-text-sec
transition-colors duration-200
hover:border-[#333] hover:text-[#aaa]"
onclick={startBreakNow}
>
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
</button>
</div>
<svg
aria-hidden="true"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
>
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
</button>
</div>
<!-- Bottom center: stats -->
<div class="absolute bottom-5 left-1/2 -translate-x-1/2" use:fadeIn={{ delay: 0.52, y: 8 }}>
<button
aria-label="Statistics"
use:pressable
class="flex h-11 w-11 items-center justify-center rounded-full
border border-[#222] text-[#8a8a8a]
transition-colors duration-200
hover:border-[#333] hover:text-[#aaa]"
onclick={() => {
invoke("set_view", { view: "stats" });
currentView.set("stats");
}}
>
<svg
aria-hidden="true"
width="17"
height="17"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
<!-- Bottom center: stats -->
<div class="absolute bottom-5 left-1/2 -translate-x-1/2" use:fadeIn={{ delay: 0.52, y: 8 }}>
<button
aria-label="Statistics"
use:pressable
class="flex h-11 w-11 items-center justify-center rounded-full
border border-border text-text-sec
transition-colors duration-200
hover:border-[#333] hover:text-[#aaa]"
onclick={() => {
invoke("set_view", { view: "stats" });
currentView.set("stats");
}}
>
<rect x="3" y="12" width="4" height="9" rx="1" />
<rect x="10" y="7" width="4" height="14" rx="1" />
<rect x="17" y="3" width="4" height="18" rx="1" />
</svg>
</button>
</div>
<svg
aria-hidden="true"
width="17"
height="17"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="3" y="12" width="4" height="9" rx="1" />
<rect x="10" y="7" width="4" height="14" rx="1" />
<rect x="17" y="3" width="4" height="18" rx="1" />
</svg>
</button>
</div>
<!-- Bottom right: settings -->
<div class="absolute bottom-5 right-5" use:fadeIn={{ delay: 0.55, y: 8 }}>
<button
aria-label="Settings"
use:pressable
class="flex h-11 w-11 items-center justify-center rounded-full
border border-[#222] text-[#8a8a8a]
transition-colors duration-200
hover:border-[#333] hover:text-[#aaa]"
onclick={openSettings}
>
<svg
aria-hidden="true"
width="17"
height="17"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
<!-- Bottom right: settings -->
<div class="absolute bottom-5 right-5" use:fadeIn={{ delay: 0.55, y: 8 }}>
<button
aria-label="Settings"
use:pressable
class="flex h-11 w-11 items-center justify-center rounded-full
border border-border text-text-sec
transition-colors duration-200
hover:border-[#333] hover:text-[#aaa]"
onclick={openSettings}
>
<path
d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"
/>
<circle cx="12" cy="12" r="3" />
</svg>
</button>
</div>
<svg
aria-hidden="true"
width="17"
height="17"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"
/>
<circle cx="12" cy="12" r="3" />
</svg>
</button>
</div>
</nav>
</div>
<style>
+4 -4
View File
@@ -52,7 +52,7 @@
<div class="flex items-center justify-between">
<div>
<div class="text-[13px] text-white">Countdown font</div>
<div class="text-[11px] text-[#8a8a8a]">
<div class="text-[11px] text-text-sec">
{value || "System default"}
</div>
</div>
@@ -60,7 +60,7 @@
type="button"
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]
class="rounded-lg border border-[#1a1a1a] bg-black px-3 py-1.5 text-[12px] text-text-sec
transition-colors hover:border-[#333] hover:text-white"
onclick={() => { expanded = !expanded; }}
>
@@ -83,7 +83,7 @@
transition-all duration-150
{value === font.family
? 'border-white/30 bg-[#141414]'
: 'border-[#141414] bg-[#0a0a0a] hover:border-[#222] hover:bg-[#0f0f0f]'}"
: 'border-[#141414] bg-[#0a0a0a] hover:border-border hover:bg-[#0f0f0f]'}"
onclick={() => selectFont(font.family)}
>
<span
@@ -92,7 +92,7 @@
>
25:00
</span>
<span class="text-[9px] tracking-wider text-[#8a8a8a] uppercase">
<span class="text-[9px] tracking-wider text-text-sec uppercase">
{font.label}
</span>
</button>
+4 -3
View File
@@ -38,17 +38,18 @@
});
</script>
<div class="microbreak-card">
<div class="microbreak-card" role="alertdialog" aria-label="Microbreak" aria-describedby="microbreak-msg">
<h2 class="sr-only">Microbreak</h2>
<div class="flex items-center gap-3 mb-2">
<svg aria-hidden="true" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#7c6aef" stroke-width="1.5">
<path d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
<span class="text-[15px] font-medium text-white">Look away - 20 feet for {timeRemaining}s</span>
<span id="microbreak-msg" class="text-[15px] font-medium text-white">Look away -- 20 feet for {timeRemaining}s</span>
</div>
{#if activity && $config.microbreak_show_activity}
<p class="text-[12px] text-[#8a8a8a] mb-3 ml-[34px]">
<p class="text-[12px] text-text-sec mb-3 ml-[34px]">
{activity.text}
</p>
{/if}
+2 -2
View File
@@ -335,13 +335,13 @@ const fontStyle = $derived(
<!-- Countdown text -->
<span
class="ml-2.5 text-[18px] font-semibold leading-none tabular-nums"
style="color: {state === 'paused' ? '#555' : '#fff'}; {fontStyle}"
style="color: {state === 'paused' ? '#a8a8a8' : '#fff'}; {fontStyle}"
>
{timeText}
</span>
<!-- F3: Pomodoro cycle indicator -->
{#if pomodoroEnabled}
<span class="ml-1.5 text-[10px] tabular-nums" style="color: #8a8a8a;">
<span class="ml-1.5 text-[10px] tabular-nums" style="color: #a8a8a8;">
{pomodoroCyclePosition + 1}/{pomodoroTotalInCycle}
</span>
{/if}
+174 -165
View File
@@ -124,8 +124,8 @@
<button
aria-label="Back to dashboard"
use:pressable
class="mr-3 flex h-8 w-8 items-center justify-center rounded-full
text-[#8a8a8a] transition-colors hover:text-white"
class="mr-3 flex h-10 w-10 min-h-[44px] min-w-[44px] items-center justify-center rounded-full
text-text-sec transition-colors hover:text-white"
onclick={goBack}
>
<svg
@@ -159,16 +159,16 @@
<div class="space-y-3">
<!-- 1. Timer -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0 }}>
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
<section aria-labelledby="settings-timer" class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0 }}>
<h2 id="settings-timer" class="mb-4 text-[11px] font-medium tracking-[0.15em] text-text-sec uppercase">
Timer
</h3>
</h2>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Break frequency</div>
<div class="text-[11px] text-[#8a8a8a]">
Every {$config.break_frequency} min
<div class="text-[11px] text-text-sec">
Every {$config.break_frequency} <abbr title="minutes">min</abbr>
</div>
</div>
<Stepper
@@ -180,12 +180,12 @@
/>
</div>
<div class="my-4 h-px bg-[#161616]"></div>
<div class="my-4 h-px bg-border"></div>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Break duration</div>
<div class="text-[11px] text-[#8a8a8a]">
<div class="text-[11px] text-text-sec">
{$config.break_duration} min
</div>
</div>
@@ -198,12 +198,12 @@
/>
</div>
<div class="my-4 h-px bg-[#161616]"></div>
<div class="my-4 h-px bg-border"></div>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Auto-start</div>
<div class="text-[11px] text-[#8a8a8a]">Start timer on launch</div>
<div class="text-[11px] text-text-sec">Start timer on launch</div>
</div>
<ToggleSwitch
bind:checked={$config.auto_start}
@@ -214,61 +214,61 @@
</section>
<!-- 2. Pomodoro Mode -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.03 }}>
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
<section aria-labelledby="settings-pomodoro" class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.03 }}>
<h2 id="settings-pomodoro" title="Pomodoro technique alternates focused work sessions with short and long breaks" class="mb-4 text-[11px] font-medium tracking-[0.15em] text-text-sec uppercase">
Pomodoro Mode
</h3>
</h2>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Enable Pomodoro</div>
<div class="text-[11px] text-[#8a8a8a]">Short breaks then a long break</div>
<div class="text-[11px] text-text-sec">Short breaks then a long break</div>
</div>
<ToggleSwitch bind:checked={$config.pomodoro_enabled} label="Pomodoro mode" onchange={markChanged} />
</div>
{#if $config.pomodoro_enabled}
<div class="my-4 h-px bg-[#161616]"></div>
<div class="my-4 h-px bg-border"></div>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Short breaks before long</div>
<div class="text-[11px] text-[#8a8a8a]">{$config.pomodoro_short_breaks} short + 1 long</div>
<div class="text-[11px] text-text-sec">{$config.pomodoro_short_breaks} short + 1 long</div>
</div>
<Stepper bind:value={$config.pomodoro_short_breaks} label="Short breaks" min={1} max={10} onchange={markChanged} />
</div>
<div class="my-4 h-px bg-[#161616]"></div>
<div class="my-4 h-px bg-border"></div>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Long break duration</div>
<div class="text-[11px] text-[#8a8a8a]">{$config.pomodoro_long_break_duration} min</div>
<div class="text-[11px] text-text-sec">{$config.pomodoro_long_break_duration} min</div>
</div>
<Stepper bind:value={$config.pomodoro_long_break_duration} label="Long break duration" min={5} max={60} onchange={markChanged} />
</div>
<div class="my-4 h-px bg-[#161616]"></div>
<div class="my-4 h-px bg-border"></div>
<div class="flex flex-col gap-1.5">
<label class="text-[13px] text-white" for="pomo-title">Long break title</label>
<input id="pomo-title" type="text" maxlength={100}
class="rounded-xl border border-[#161616] bg-black px-3.5 py-2.5 text-[13px] text-white outline-none transition-colors placeholder:text-[#2a2a2a] focus:border-[#333]"
class="rounded-xl border border-border bg-black px-3.5 py-2.5 text-[13px] text-white outline-none transition-colors placeholder:text-[#555] focus:border-[#333]"
bind:value={$config.pomodoro_long_break_title} oninput={markChanged}
/>
</div>
<div class="my-4 h-px bg-[#161616]"></div>
<div class="my-4 h-px bg-border"></div>
<div class="flex flex-col gap-1.5">
<label class="text-[13px] text-white" for="pomo-msg">Long break message</label>
<input id="pomo-msg" type="text" maxlength={500}
class="rounded-xl border border-[#161616] bg-black px-3.5 py-2.5 text-[13px] text-white outline-none transition-colors placeholder:text-[#2a2a2a] focus:border-[#333]"
class="rounded-xl border border-border bg-black px-3.5 py-2.5 text-[13px] text-white outline-none transition-colors placeholder:text-[#555] focus:border-[#333]"
bind:value={$config.pomodoro_long_break_message} oninput={markChanged}
/>
</div>
<div class="my-4 h-px bg-[#161616]"></div>
<div class="my-4 h-px bg-border"></div>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Reset on skip</div>
<div class="text-[11px] text-[#8a8a8a]">Reset cycle when skipping a break</div>
<div class="text-[11px] text-text-sec">Reset cycle when skipping a break</div>
</div>
<ToggleSwitch bind:checked={$config.pomodoro_reset_on_skip} label="Reset on skip" onchange={markChanged} />
</div>
@@ -276,15 +276,15 @@
</section>
<!-- 3. Microbreaks -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.06 }}>
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
<section aria-labelledby="settings-microbreaks" class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.06 }}>
<h2 id="settings-microbreaks" title="20-20-20 rule: every 20 minutes, look 20 feet away for 20 seconds" class="mb-4 text-[11px] font-medium tracking-[0.15em] text-text-sec uppercase">
Microbreaks
</h3>
</h2>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">20-20-20 eye breaks</div>
<div class="text-[11px] text-[#8a8a8a]">Quick eye rest reminders</div>
<div class="text-[11px] text-text-sec">Quick eye rest reminders</div>
</div>
<ToggleSwitch
bind:checked={$config.microbreak_enabled}
@@ -294,47 +294,47 @@
</div>
{#if $config.microbreak_enabled}
<div class="my-4 h-px bg-[#161616]"></div>
<div class="my-4 h-px bg-border"></div>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Frequency</div>
<div class="text-[11px] text-[#8a8a8a]">Every {$config.microbreak_frequency} min</div>
<div class="text-[11px] text-text-sec">Every {$config.microbreak_frequency} min</div>
</div>
<Stepper bind:value={$config.microbreak_frequency} label="Microbreak frequency" min={5} max={60} onchange={markChanged} />
</div>
<div class="my-4 h-px bg-[#161616]"></div>
<div class="my-4 h-px bg-border"></div>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Duration</div>
<div class="text-[11px] text-[#8a8a8a]">{$config.microbreak_duration} seconds</div>
<div class="text-[11px] text-text-sec">{$config.microbreak_duration} seconds</div>
</div>
<Stepper bind:value={$config.microbreak_duration} label="Microbreak duration" min={10} max={60} step={5} onchange={markChanged} />
</div>
<div class="my-4 h-px bg-[#161616]"></div>
<div class="my-4 h-px bg-border"></div>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Sound</div>
<div class="text-[11px] text-[#8a8a8a]">Play sound on eye break</div>
<div class="text-[11px] text-text-sec">Play sound on eye break</div>
</div>
<ToggleSwitch bind:checked={$config.microbreak_sound_enabled} label="Microbreak sound" onchange={markChanged} />
</div>
<div class="my-4 h-px bg-[#161616]"></div>
<div class="my-4 h-px bg-border"></div>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Show activity</div>
<div class="text-[11px] text-[#8a8a8a]">Activity suggestion during eye break</div>
<div class="text-[11px] text-text-sec">Activity suggestion during eye break</div>
</div>
<ToggleSwitch bind:checked={$config.microbreak_show_activity} label="Microbreak activity" onchange={markChanged} />
</div>
<div class="my-4 h-px bg-[#161616]"></div>
<div class="my-4 h-px bg-border"></div>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Pause during breaks</div>
<div class="text-[11px] text-[#8a8a8a]">No eye breaks during main breaks</div>
<div class="text-[11px] text-text-sec">No eye breaks during main breaks</div>
</div>
<ToggleSwitch bind:checked={$config.microbreak_pause_during_break} label="Pause during breaks" onchange={markChanged} />
</div>
@@ -342,10 +342,10 @@
</section>
<!-- 4. Break Screen (stripped down) -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.09 }}>
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
<section aria-labelledby="settings-breakscreen" class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.09 }}>
<h2 id="settings-breakscreen" class="mb-4 text-[11px] font-medium tracking-[0.15em] text-text-sec uppercase">
Break Screen
</h3>
</h2>
<div class="flex flex-col gap-1.5">
<label class="text-[13px] text-white" for="break-title">
@@ -354,16 +354,16 @@
<input
id="break-title"
type="text"
class="rounded-xl border border-[#161616] bg-black px-3.5 py-2.5 text-[13px]
class="rounded-xl border border-border bg-black px-3.5 py-2.5 text-[13px]
text-white outline-none transition-colors
placeholder:text-[#2a2a2a] focus:border-[#333]"
placeholder:text-[#555] focus:border-[#333]"
placeholder="Enter break title..."
bind:value={$config.break_title}
oninput={markChanged}
/>
</div>
<div class="my-4 h-px bg-[#161616]"></div>
<div class="my-4 h-px bg-border"></div>
<div class="flex flex-col gap-1.5">
<label class="text-[13px] text-white" for="break-message">
@@ -372,21 +372,21 @@
<input
id="break-message"
type="text"
class="rounded-xl border border-[#161616] bg-black px-3.5 py-2.5 text-[13px]
class="rounded-xl border border-border bg-black px-3.5 py-2.5 text-[13px]
text-white outline-none transition-colors
placeholder:text-[#2a2a2a] focus:border-[#333]"
placeholder:text-[#555] focus:border-[#333]"
placeholder="Enter break message..."
bind:value={$config.break_message}
oninput={markChanged}
/>
</div>
<div class="my-4 h-px bg-[#161616]"></div>
<div class="my-4 h-px bg-border"></div>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Fullscreen break</div>
<div class="text-[11px] text-[#8a8a8a]">
<div class="text-[11px] text-text-sec">
{$config.fullscreen_mode ? "Fills entire screen" : "Centered modal"}
</div>
</div>
@@ -397,12 +397,12 @@
/>
</div>
<div class="my-4 h-px bg-[#161616]"></div>
<div class="my-4 h-px bg-border"></div>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Activity suggestions</div>
<div class="text-[11px] text-[#8a8a8a]">
<div class="text-[11px] text-text-sec">
Exercise ideas during breaks
</div>
</div>
@@ -414,11 +414,11 @@
</div>
{#if $config.fullscreen_mode}
<div class="my-4 h-px bg-[#161616]"></div>
<div class="my-4 h-px bg-border"></div>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Block all monitors</div>
<div class="text-[11px] text-[#8a8a8a]">Show overlay on all screens during breaks</div>
<div class="text-[11px] text-text-sec">Show overlay on all screens during breaks</div>
</div>
<ToggleSwitch
bind:checked={$config.multi_monitor_break}
@@ -431,24 +431,24 @@
<!-- 5. Break Activities (own card, conditional) -->
{#if $config.show_break_activities}
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.12 }}>
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
<section aria-labelledby="settings-activities" class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.12 }}>
<h2 id="settings-activities" class="mb-4 text-[11px] font-medium tracking-[0.15em] text-text-sec uppercase">
Break Activities
</h3>
</h2>
<ActivityManager />
</section>
{/if}
<!-- 6. Breathing Guide (own card) -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.15 }}>
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
<section aria-labelledby="settings-breathing" class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.15 }}>
<h2 id="settings-breathing" title="Visual breathing exercise during breaks to reduce stress" class="mb-4 text-[11px] font-medium tracking-[0.15em] text-text-sec uppercase">
Breathing Guide
</h3>
</h2>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Guided breathing</div>
<div class="text-[11px] text-[#8a8a8a]">Visual breathing guide during breaks</div>
<div class="text-[11px] text-text-sec">Visual breathing guide during breaks</div>
</div>
<ToggleSwitch
bind:checked={$config.breathing_guide_enabled}
@@ -458,18 +458,20 @@
</div>
{#if $config.breathing_guide_enabled}
<div class="my-4 h-px bg-[#161616]"></div>
<div class="my-4 h-px bg-border"></div>
<div>
<div class="mb-3 text-[13px] text-white">Breathing pattern</div>
<div class="flex flex-col gap-1.5">
<div class="mb-3 text-[13px] text-white" id="breathing-pattern-label">Breathing pattern</div>
<div class="flex flex-col gap-1.5" role="radiogroup" aria-label="Breathing pattern">
{#each breathingPatternMeta as bp}
<button
use:pressable
role="radio"
aria-checked={$config.breathing_pattern === bp.id}
class="flex items-center gap-3 rounded-xl px-3.5 py-2.5 text-left
transition-all duration-200
{$config.breathing_pattern === bp.id
? 'bg-[#1a1a1a] border border-[#333]'
: 'bg-[#0a0a0a] border border-[#161616] hover:border-[#333]'}"
: 'bg-[#0a0a0a] border border-border hover:border-[#333]'}"
onclick={() => {
$config.breathing_pattern = bp.id;
markChanged();
@@ -480,10 +482,10 @@
style="border-color: {$config.breathing_pattern === bp.id ? $config.accent_color : '#333'};
background: {$config.breathing_pattern === bp.id ? $config.accent_color : 'transparent'};"
></div>
<span class="text-[12px] font-medium {$config.breathing_pattern === bp.id ? 'text-white' : 'text-[#8a8a8a]'}">
<span class="text-[12px] font-medium {$config.breathing_pattern === bp.id ? 'text-white' : 'text-text-sec'}">
{bp.label}
</span>
<span class="ml-auto text-[11px] text-[#8a8a8a] opacity-60 tabular-nums">
<span class="ml-auto text-[11px] text-text-sec opacity-60 tabular-nums">
{bp.desc}
</span>
</button>
@@ -494,15 +496,15 @@
</section>
<!-- 7. Behavior -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.18 }}>
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
<section aria-labelledby="settings-behavior" class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.18 }}>
<h2 id="settings-behavior" class="mb-4 text-[11px] font-medium tracking-[0.15em] text-text-sec uppercase">
Behavior
</h3>
</h2>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Strict mode</div>
<div class="text-[11px] text-[#8a8a8a]">
<div class="text-[11px] text-text-sec">
Disable skip and snooze
</div>
</div>
@@ -514,12 +516,12 @@
</div>
{#if !$config.strict_mode}
<div class="my-4 h-px bg-[#161616]"></div>
<div class="my-4 h-px bg-border"></div>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Allow end early</div>
<div class="text-[11px] text-[#8a8a8a]">After 50% of break</div>
<div class="text-[11px] text-text-sec">After 50% of break</div>
</div>
<ToggleSwitch
bind:checked={$config.allow_end_early}
@@ -528,12 +530,12 @@
/>
</div>
<div class="my-4 h-px bg-[#161616]"></div>
<div class="my-4 h-px bg-border"></div>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Snooze duration</div>
<div class="text-[11px] text-[#8a8a8a]">
<div class="text-[11px] text-text-sec">
{$config.snooze_duration} min
</div>
</div>
@@ -546,12 +548,12 @@
/>
</div>
<div class="my-4 h-px bg-[#161616]"></div>
<div class="my-4 h-px bg-border"></div>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Snooze limit</div>
<div class="text-[11px] text-[#8a8a8a]">
<div class="text-[11px] text-text-sec">
{$config.snooze_limit === 0
? "Unlimited"
: `${$config.snooze_limit} per break`}
@@ -568,12 +570,12 @@
</div>
{/if}
<div class="my-4 h-px bg-[#161616]"></div>
<div class="my-4 h-px bg-border"></div>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Immediate breaks</div>
<div class="text-[11px] text-[#8a8a8a]">
<div class="text-[11px] text-text-sec">
Skip pre-break warning
</div>
</div>
@@ -586,15 +588,15 @@
</section>
<!-- 8. Alerts (MERGED: Notifications + Pre-Break Nudge) -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.21 }}>
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
<section aria-labelledby="settings-alerts" class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.21 }}>
<h2 id="settings-alerts" class="mb-4 text-[11px] font-medium tracking-[0.15em] text-text-sec uppercase">
Alerts
</h3>
</h2>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Pre-break alert</div>
<div class="text-[11px] text-[#8a8a8a]">Warn before breaks</div>
<div class="text-[11px] text-text-sec">Warn before breaks</div>
</div>
<ToggleSwitch
bind:checked={$config.notification_enabled}
@@ -604,13 +606,13 @@
</div>
{#if $config.notification_enabled}
<div class="my-4 h-px bg-[#161616]"></div>
<div class="my-4 h-px bg-border"></div>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Alert timing</div>
<div class="text-[11px] text-[#8a8a8a]">
{$config.notification_before_break}s before
<div class="text-[11px] text-text-sec">
{$config.notification_before_break}<abbr title="seconds">s</abbr> before
</div>
</div>
<Stepper
@@ -624,31 +626,31 @@
</div>
{/if}
<div class="my-4 h-px bg-[#161616]"></div>
<div class="my-4 h-px bg-border"></div>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Screen dimming</div>
<div class="text-[11px] text-[#8a8a8a]">Gradually dim screen before breaks</div>
<div class="text-[11px] text-text-sec">Gradually dim screen before breaks</div>
</div>
<ToggleSwitch bind:checked={$config.screen_dim_enabled} label="Screen dimming" onchange={markChanged} />
</div>
{#if $config.screen_dim_enabled}
<div class="my-4 h-px bg-[#161616]"></div>
<div class="my-4 h-px bg-border"></div>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Start dimming</div>
<div class="text-[11px] text-[#8a8a8a]">{$config.screen_dim_seconds}s before break</div>
<div class="text-[11px] text-text-sec">{$config.screen_dim_seconds}s before break</div>
</div>
<Stepper bind:value={$config.screen_dim_seconds} label="Dim start" min={3} max={60} onchange={markChanged} />
</div>
<div class="my-4 h-px bg-[#161616]"></div>
<div class="my-4 h-px bg-border"></div>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Max dimming</div>
<div class="text-[11px] text-[#8a8a8a]">{Math.round($config.screen_dim_max_opacity * 100)}%</div>
<div class="text-[11px] text-text-sec">{Math.round($config.screen_dim_max_opacity * 100)}%</div>
</div>
<Stepper
bind:value={$config.screen_dim_max_opacity}
@@ -664,15 +666,15 @@
</section>
<!-- 9. Sound -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.24 }}>
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
<section aria-labelledby="settings-sound" class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.24 }}>
<h2 id="settings-sound" class="mb-4 text-[11px] font-medium tracking-[0.15em] text-text-sec uppercase">
Sound
</h3>
</h2>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Sound effects</div>
<div class="text-[11px] text-[#8a8a8a]">Play sounds on break events</div>
<div class="text-[11px] text-text-sec">Play sounds on break events</div>
</div>
<ToggleSwitch
bind:checked={$config.sound_enabled}
@@ -682,12 +684,12 @@
</div>
{#if $config.sound_enabled}
<div class="my-4 h-px bg-[#161616]"></div>
<div class="my-4 h-px bg-border"></div>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Volume</div>
<div class="text-[11px] text-[#8a8a8a]">{$config.sound_volume}%</div>
<div class="text-[11px] text-text-sec">{$config.sound_volume}%</div>
</div>
<Stepper
bind:value={$config.sound_volume}
@@ -700,7 +702,7 @@
/>
</div>
<div class="my-4 h-px bg-[#161616]"></div>
<div class="my-4 h-px bg-border"></div>
<div>
<div class="mb-3 text-[13px] text-white">Sound preset</div>
@@ -708,11 +710,12 @@
{#each soundPresets as preset}
<button
use:pressable
aria-pressed={$config.sound_preset === preset}
class="rounded-xl py-2.5 text-[11px] tracking-wider uppercase
transition-all duration-200
{$config.sound_preset === preset
? 'bg-[#1a1a1a] text-white border border-[#333]'
: 'bg-[#0a0a0a] text-[#8a8a8a] border border-[#161616] hover:border-[#333] hover:text-white'}"
: 'bg-[#0a0a0a] text-text-sec border border-border hover:border-[#333] hover:text-white'}"
onclick={() => {
$config.sound_preset = preset;
markChanged();
@@ -728,15 +731,15 @@
</section>
<!-- 10. Idle & Smart Breaks (MERGED) -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.27 }}>
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
<section aria-labelledby="settings-idle" class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.27 }}>
<h2 id="settings-idle" class="mb-4 text-[11px] font-medium tracking-[0.15em] text-text-sec uppercase">
Idle & Smart Breaks
</h3>
</h2>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Auto-pause when idle</div>
<div class="text-[11px] text-[#8a8a8a]">Pause timer when away</div>
<div class="text-[11px] text-text-sec">Pause timer when away</div>
</div>
<ToggleSwitch
bind:checked={$config.idle_detection_enabled}
@@ -746,12 +749,12 @@
</div>
{#if $config.idle_detection_enabled}
<div class="my-4 h-px bg-[#161616]"></div>
<div class="my-4 h-px bg-border"></div>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Idle timeout</div>
<div class="text-[11px] text-[#8a8a8a]">
<div class="text-[11px] text-text-sec">
{$config.idle_timeout}s of inactivity
</div>
</div>
@@ -767,12 +770,12 @@
</div>
{/if}
<div class="my-4 h-px bg-[#161616]"></div>
<div class="my-4 h-px bg-border"></div>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Smart breaks</div>
<div class="text-[11px] text-[#8a8a8a]">Auto-reset timer when you step away</div>
<div class="text-[11px] text-text-sec">Auto-reset timer when you step away</div>
</div>
<ToggleSwitch
bind:checked={$config.smart_breaks_enabled}
@@ -782,12 +785,12 @@
</div>
{#if $config.smart_breaks_enabled}
<div class="my-4 h-px bg-[#161616]"></div>
<div class="my-4 h-px bg-border"></div>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Minimum away time</div>
<div class="text-[11px] text-[#8a8a8a]">
<div class="text-[11px] text-text-sec">
{$config.smart_break_threshold >= 60
? `${Math.floor($config.smart_break_threshold / 60)} min`
: `${$config.smart_break_threshold}s`} to count as break
@@ -804,12 +807,12 @@
/>
</div>
<div class="my-4 h-px bg-[#161616]"></div>
<div class="my-4 h-px bg-border"></div>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Count in statistics</div>
<div class="text-[11px] text-[#8a8a8a]">Track natural breaks in stats</div>
<div class="text-[11px] text-text-sec">Track natural breaks in stats</div>
</div>
<ToggleSwitch
bind:checked={$config.smart_break_count_stats}
@@ -821,36 +824,36 @@
</section>
<!-- 11. Presentation Mode -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.30 }}>
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
<section aria-labelledby="settings-presentation" class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.30 }}>
<h2 id="settings-presentation" class="mb-4 text-[11px] font-medium tracking-[0.15em] text-text-sec uppercase">
Presentation Mode
</h3>
</h2>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Auto-detect fullscreen</div>
<div class="text-[11px] text-[#8a8a8a]">Defer breaks during fullscreen apps</div>
<div class="text-[11px] text-text-sec">Defer breaks during fullscreen apps</div>
</div>
<ToggleSwitch bind:checked={$config.presentation_mode_enabled} label="Presentation mode" onchange={markChanged} />
</div>
{#if $config.presentation_mode_enabled}
{#if $config.microbreak_enabled}
<div class="my-4 h-px bg-[#161616]"></div>
<div class="my-4 h-px bg-border"></div>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Defer microbreaks</div>
<div class="text-[11px] text-[#8a8a8a]">Also pause eye breaks</div>
<div class="text-[11px] text-text-sec">Also pause eye breaks</div>
</div>
<ToggleSwitch bind:checked={$config.presentation_mode_defer_microbreaks} label="Defer microbreaks" onchange={markChanged} />
</div>
{/if}
<div class="my-4 h-px bg-[#161616]"></div>
<div class="my-4 h-px bg-border"></div>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Notification</div>
<div class="text-[11px] text-[#8a8a8a]">Show toast when break is deferred</div>
<div class="text-[11px] text-text-sec">Show toast when break is deferred</div>
</div>
<ToggleSwitch bind:checked={$config.presentation_mode_notification} label="Deferral notification" onchange={markChanged} />
</div>
@@ -858,59 +861,59 @@
</section>
<!-- 12. Goals & Streaks -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.33 }}>
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
<section aria-labelledby="settings-goals" class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.33 }}>
<h2 id="settings-goals" class="mb-4 text-[11px] font-medium tracking-[0.15em] text-text-sec uppercase">
Goals & Streaks
</h3>
</h2>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Daily goal</div>
<div class="text-[11px] text-[#8a8a8a]">Track daily break target</div>
<div class="text-[11px] text-text-sec">Track daily break target</div>
</div>
<ToggleSwitch bind:checked={$config.daily_goal_enabled} label="Daily goal" onchange={markChanged} />
</div>
{#if $config.daily_goal_enabled}
<div class="my-4 h-px bg-[#161616]"></div>
<div class="my-4 h-px bg-border"></div>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Target breaks</div>
<div class="text-[11px] text-[#8a8a8a]">{$config.daily_goal_breaks} per day</div>
<div class="text-[11px] text-text-sec">{$config.daily_goal_breaks} per day</div>
</div>
<Stepper bind:value={$config.daily_goal_breaks} label="Daily goal breaks" min={1} max={30} onchange={markChanged} />
</div>
{/if}
<div class="my-4 h-px bg-[#161616]"></div>
<div class="my-4 h-px bg-border"></div>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Celebrations</div>
<div class="text-[11px] text-[#8a8a8a]">Confetti on milestones and goals</div>
<div class="text-[11px] text-text-sec">Confetti on milestones and goals</div>
</div>
<ToggleSwitch bind:checked={$config.milestone_celebrations} label="Celebrations" onchange={markChanged} />
</div>
<div class="my-4 h-px bg-[#161616]"></div>
<div class="my-4 h-px bg-border"></div>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Streak notifications</div>
<div class="text-[11px] text-[#8a8a8a]">Toast on streak milestones</div>
<div class="text-[11px] text-text-sec">Toast on streak milestones</div>
</div>
<ToggleSwitch bind:checked={$config.streak_notifications} label="Streak notifications" onchange={markChanged} />
</div>
</section>
<!-- 13. Appearance -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.36 }}>
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
<section aria-labelledby="settings-appearance" class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.36 }}>
<h2 id="settings-appearance" class="mb-4 text-[11px] font-medium tracking-[0.15em] text-text-sec uppercase">
Appearance
</h3>
</h2>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">UI zoom</div>
<div class="text-[11px] text-[#8a8a8a]">
<div class="text-[11px] text-text-sec">
{$config.ui_zoom}%
</div>
</div>
@@ -925,7 +928,7 @@
/>
</div>
<div class="my-4 h-px bg-[#161616]"></div>
<div class="my-4 h-px bg-border"></div>
<ColorPicker
label="Accent color"
@@ -933,7 +936,7 @@
onchange={markChanged}
/>
<div class="my-4 h-px bg-[#161616]"></div>
<div class="my-4 h-px bg-border"></div>
<ColorPicker
label="Break screen color"
@@ -947,19 +950,19 @@
onchange={markChanged}
/>
<div class="my-4 h-px bg-[#161616]"></div>
<div class="my-4 h-px bg-border"></div>
<FontSelector
bind:value={$config.countdown_font}
onchange={markChanged}
/>
<div class="my-4 h-px bg-[#161616]"></div>
<div class="my-4 h-px bg-border"></div>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Animated background</div>
<div class="text-[11px] text-[#8a8a8a]">
<div class="text-[11px] text-text-sec">
Gradient blobs with film grain
</div>
</div>
@@ -972,11 +975,15 @@
</section>
<!-- 14. Working Hours -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.39 }}>
<section aria-labelledby="settings-workinghours" class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.39 }}>
<h2 id="settings-workinghours" class="mb-4 text-[11px] font-medium tracking-[0.15em] text-text-sec uppercase">
Working Hours
</h2>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Working hours</div>
<div class="text-[11px] text-[#8a8a8a]">
<div class="text-[11px] text-text-sec">
Only show breaks during your configured work schedule
</div>
</div>
@@ -988,7 +995,7 @@
</div>
{#if $config.working_hours_enabled}
<div class="my-4 h-px bg-[#161616]"></div>
<div class="my-4 h-px bg-border"></div>
{#each $config.working_hours_schedule as daySchedule, dayIndex}
{@const dayName = daysOfWeek[dayIndex]}
@@ -1013,7 +1020,7 @@
countdownFont={$config.countdown_font}
onchange={(v) => updateTimeRange(dayIndex, rangeIndex, "start", v)}
/>
<span class="text-[#8a8a8a] text-[13px]">to</span>
<span class="text-text-sec text-[13px]">to</span>
<TimeSpinner
value={range.end}
countdownFont={$config.countdown_font}
@@ -1024,7 +1031,7 @@
{#if rangeIndex === daySchedule.ranges.length - 1}
<button
use:pressable
class="ml-2 w-8 h-8 flex items-center justify-center rounded-full text-[#8a8a8a] hover:text-white hover:bg-[#1a1a1a] transition-colors"
class="ml-2 w-8 h-8 flex items-center justify-center rounded-full text-text-sec hover:text-white hover:bg-[#1a1a1a] transition-colors"
onclick={() => addTimeRange(dayIndex)}
aria-label="Add time range"
>
@@ -1037,7 +1044,7 @@
<!-- Clone button -->
<button
use:pressable
class="w-8 h-8 flex items-center justify-center rounded-full text-[#8a8a8a] hover:text-white hover:bg-[#1a1a1a] transition-colors"
class="w-8 h-8 flex items-center justify-center rounded-full text-text-sec hover:text-white hover:bg-[#1a1a1a] transition-colors"
onclick={() => cloneTimeRange(dayIndex, rangeIndex)}
aria-label="Clone time range"
>
@@ -1051,7 +1058,7 @@
{#if rangeIndex > 0}
<button
use:pressable
class="w-8 h-8 flex items-center justify-center rounded-full text-[#8a8a8a] hover:text-[#f85149] hover:bg-[#f8514915] transition-colors"
class="w-8 h-8 flex items-center justify-center rounded-full text-text-sec hover:text-[#ff6b6b] hover:bg-[#ff6b6b15] transition-colors"
onclick={() => removeTimeRange(dayIndex, rangeIndex)}
aria-label="Remove time range"
>
@@ -1067,22 +1074,22 @@
</div>
{#if dayIndex < 6}
<div class="my-4 h-px bg-[#161616]"></div>
<div class="my-4 h-px bg-border"></div>
{/if}
{/each}
{/if}
</section>
<!-- 15. Mini Mode -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.42 }}>
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
<section aria-labelledby="settings-minimode" class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.42 }}>
<h2 id="settings-minimode" class="mb-4 text-[11px] font-medium tracking-[0.15em] text-text-sec uppercase">
Mini Mode
</h3>
</h2>
<div class="flex items-center justify-between">
<div>
<div class="text-[13px] text-white">Click-through</div>
<div class="text-[11px] text-[#8a8a8a]">
<div class="text-[11px] text-text-sec">
Mini timer ignores clicks until you hover over it
</div>
</div>
@@ -1097,7 +1104,7 @@
<div class="mt-4 flex items-center justify-between">
<div>
<div class="text-[13px] text-white">Hover delay</div>
<div class="text-[11px] text-[#8a8a8a]">
<div class="text-[11px] text-text-sec">
Seconds to hover before it becomes draggable
</div>
</div>
@@ -1115,15 +1122,15 @@
</section>
<!-- 16. General -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.45 }}>
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
<section aria-labelledby="settings-general" class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.45 }}>
<h2 id="settings-general" class="mb-4 text-[11px] font-medium tracking-[0.15em] text-text-sec uppercase">
General
</h3>
</h2>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Start on Windows login</div>
<div class="text-[11px] text-[#8a8a8a]">Launch automatically at startup</div>
<div class="text-[11px] text-text-sec">Launch automatically at startup</div>
</div>
<ToggleSwitch
checked={autoStartEnabled}
@@ -1134,42 +1141,44 @@
</section>
<!-- 17. Keyboard Shortcuts -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.48 }}>
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
<section aria-labelledby="settings-shortcuts" class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.48 }}>
<h2 id="settings-shortcuts" class="mb-4 text-[11px] font-medium tracking-[0.15em] text-text-sec uppercase">
Keyboard Shortcuts
</h3>
</h2>
<div class="space-y-3">
<div class="flex items-center justify-between">
<span class="text-[13px] text-white">Pause / Resume</span>
<kbd class="rounded-lg bg-[#161616] px-2.5 py-1 text-[11px] text-[#8a8a8a]">Ctrl+Shift+P</kbd>
<kbd class="rounded-lg bg-border px-2.5 py-1 text-[11px] text-text-sec">Ctrl+Shift+P</kbd>
</div>
<div class="flex items-center justify-between">
<span class="text-[13px] text-white">Start break now</span>
<kbd class="rounded-lg bg-[#161616] px-2.5 py-1 text-[11px] text-[#8a8a8a]">Ctrl+Shift+B</kbd>
<kbd class="rounded-lg bg-border px-2.5 py-1 text-[11px] text-text-sec">Ctrl+Shift+B</kbd>
</div>
<div class="flex items-center justify-between">
<span class="text-[13px] text-white">Show / Hide window</span>
<kbd class="rounded-lg bg-[#161616] px-2.5 py-1 text-[11px] text-[#8a8a8a]">Ctrl+Shift+S</kbd>
<kbd class="rounded-lg bg-border px-2.5 py-1 text-[11px] text-text-sec">Ctrl+Shift+S</kbd>
</div>
</div>
</section>
<!-- 18. Reset -->
<div class="pt-2 pb-6" use:inView={{ delay: 0.51 }}>
<section aria-labelledby="settings-reset" class="pt-2 pb-6" use:inView={{ delay: 0.51 }}>
<h2 id="settings-reset" class="sr-only">Reset</h2>
<button
use:pressable
aria-live="polite"
class="w-full rounded-full border py-3 text-[12px]
tracking-wider uppercase
transition-all duration-200
{resetConfirming
? 'border-[#f85149] text-[#f85149] hover:bg-[#f85149] hover:text-white'
: 'border-[#1a1a1a] text-[#8a8a8a] hover:border-[#333] hover:text-white'}"
? 'border-[#ff6b6b] text-[#ff6b6b] hover:bg-[#ff6b6b] hover:text-white'
: 'border-[#1a1a1a] text-text-sec hover:border-[#333] hover:text-white'}"
onclick={handleReset}
>
{resetConfirming ? "Tap again to confirm reset" : "Reset to defaults"}
</button>
</div>
</section>
</div>
</div>
</div>
+86 -48
View File
@@ -88,7 +88,7 @@
return milestones.find((m) => m > current) ?? null;
});
// Chart rendering - 7-day
// Chart rendering -- 7-day
let chartCanvas: HTMLCanvasElement | undefined = $state();
$effect(() => {
@@ -96,7 +96,7 @@
drawChart(chartCanvas, history);
});
// Chart rendering - 30-day
// Chart rendering -- 30-day
let monthChartCanvas: HTMLCanvasElement | undefined = $state();
$effect(() => {
@@ -154,9 +154,9 @@
ctx.fill();
}
// Day label - show every Nth for 30-day
// Day label -- show every Nth for 30-day
if (data.length <= 7 || i % 5 === 0) {
ctx.fillStyle = "#8a8a8a";
ctx.fillStyle = "#a8a8a8";
ctx.font = "10px -apple-system, sans-serif";
ctx.textAlign = "center";
const label = day.date.slice(5);
@@ -266,8 +266,8 @@
<button
aria-label="Back to dashboard"
use:pressable
class="mr-3 flex h-8 w-8 items-center justify-center rounded-full
text-[#8a8a8a] transition-colors hover:text-white"
class="mr-3 flex h-10 w-10 min-h-[44px] min-w-[44px] items-center justify-center rounded-full
text-text-sec transition-colors hover:text-white"
onclick={goBack}
>
<svg
@@ -294,14 +294,18 @@
</div>
<!-- Tab navigation -->
<div class="flex gap-1 px-5 mb-3" use:fadeIn={{ duration: 0.3, y: 6 }}>
<div class="flex gap-1 px-5 mb-3" role="tablist" aria-label="Statistics time range" use:fadeIn={{ duration: 0.3, y: 6 }}>
{#each [["today", "Today"], ["weekly", "Weekly"], ["monthly", "Monthly"]] as [tab, label]}
<button
use:pressable
class="rounded-lg px-4 py-1.5 text-[11px] tracking-wider uppercase transition-all duration-200
role="tab"
id="tab-{tab}"
aria-selected={activeTab === tab}
aria-controls="tabpanel-{tab}"
class="min-h-[44px] rounded-lg px-4 py-1.5 text-[11px] tracking-wider uppercase transition-all duration-200
{activeTab === tab
? 'bg-[#1a1a1a] text-white'
: 'text-[#8a8a8a] hover:text-white'}"
: 'text-text-sec hover:text-white'}"
onclick={() => activeTab = tab as any}
>
{label}
@@ -314,18 +318,19 @@
<div class="space-y-3">
{#if activeTab === "today"}
<div role="tabpanel" id="tabpanel-today" aria-labelledby="tab-today">
<!-- Today's summary -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0 }}>
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
<h2 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-text-sec uppercase">
Today
</h3>
</h2>
<div class="grid grid-cols-2 gap-4">
<div class="text-center">
<div class="text-[28px] font-semibold text-white tabular-nums">
{stats?.todayCompleted ?? 0}
</div>
<div class="text-[11px] text-[#8a8a8a]">Breaks taken</div>
<div class="text-[11px] text-text-sec">Breaks taken</div>
</div>
<div class="text-center">
<div class="text-[28px] font-semibold tabular-nums"
@@ -333,19 +338,19 @@
>
{compliancePercent}%
</div>
<div class="text-[11px] text-[#8a8a8a]">Compliance</div>
<div class="text-[11px] text-text-sec">Compliance</div>
</div>
<div class="text-center">
<div class="text-[28px] font-semibold text-white tabular-nums">
{breakTimeFormatted()}
</div>
<div class="text-[11px] text-[#8a8a8a]">Break time</div>
<div class="text-[11px] text-text-sec">Break time</div>
</div>
<div class="text-center">
<div class="text-[28px] font-semibold text-white tabular-nums">
{stats?.todaySkipped ?? 0}
</div>
<div class="text-[11px] text-[#8a8a8a]">Skipped</div>
<div class="text-[11px] text-text-sec">Skipped</div>
</div>
</div>
</section>
@@ -353,9 +358,9 @@
<!-- F10: Daily goal -->
{#if $config.daily_goal_enabled}
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.04 }}>
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
<h2 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-text-sec uppercase">
Daily Goal
</h3>
</h2>
<div class="flex items-center gap-4">
<div class="relative w-16 h-16">
<svg width="64" height="64" viewBox="0 0 64 64" style="transform: rotate(-90deg);">
@@ -375,7 +380,7 @@
<div class="text-[14px] text-white font-medium">
{stats?.dailyGoalProgress ?? 0} / {$config.daily_goal_breaks} breaks
</div>
<div class="text-[11px] text-[#8a8a8a]">
<div class="text-[11px] text-text-sec">
{stats?.dailyGoalMet ? "Goal reached!" : `${$config.daily_goal_breaks - (stats?.dailyGoalProgress ?? 0)} more to go`}
</div>
</div>
@@ -385,26 +390,26 @@
<!-- Streak -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.06 }}>
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
<h2 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-text-sec uppercase">
Streak
</h3>
</h2>
<div class="flex items-center justify-between">
<div>
<div class="text-[13px] text-white">Current streak</div>
<div class="text-[11px] text-[#8a8a8a]">Consecutive days with breaks</div>
<div class="text-[11px] text-text-sec">Consecutive days with breaks</div>
</div>
<div class="text-[24px] font-semibold tabular-nums" style="color: {$config.accent_color}">
{stats?.currentStreak ?? 0}
</div>
</div>
<div class="my-4 h-px bg-[#161616]"></div>
<div class="my-4 h-px bg-border"></div>
<div class="flex items-center justify-between">
<div>
<div class="text-[13px] text-white">Best streak</div>
<div class="text-[11px] text-[#8a8a8a]">All-time record</div>
<div class="text-[11px] text-text-sec">All-time record</div>
</div>
<div class="text-[24px] font-semibold text-white tabular-nums">
{stats?.bestStreak ?? 0}
@@ -413,13 +418,13 @@
<!-- F10: Next milestone -->
{#if nextMilestone()}
<div class="my-4 h-px bg-[#161616]"></div>
<div class="my-4 h-px bg-border"></div>
<div class="flex items-center justify-between">
<div>
<div class="text-[13px] text-white">Next milestone</div>
<div class="text-[11px] text-[#8a8a8a]">{nextMilestone()} day streak</div>
<div class="text-[11px] text-text-sec">{nextMilestone()} day streak</div>
</div>
<div class="text-[13px] text-[#8a8a8a] tabular-nums">
<div class="text-[13px] text-text-sec tabular-nums">
{nextMilestone()! - (stats?.currentStreak ?? 0)} days away
</div>
</div>
@@ -428,9 +433,9 @@
<!-- Weekly chart -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.12 }}>
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
<h2 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-text-sec uppercase">
Last 7 Days
</h3>
</h2>
<!-- svelte-ignore a11y_no_interactive_element_to_noninteractive_role -->
<canvas
@@ -458,7 +463,7 @@
</table>
{/if}
<div class="mt-3 flex items-center justify-center gap-4 text-[10px] text-[#8a8a8a]">
<div class="mt-3 flex items-center justify-center gap-4 text-[10px] text-text-sec">
<div class="flex items-center gap-1.5">
<div class="h-2 w-2 rounded-sm" style="background: {$config.accent_color}"></div>
Completed
@@ -470,38 +475,40 @@
</div>
</section>
</div>
{:else if activeTab === "weekly"}
<div role="tabpanel" id="tabpanel-weekly" aria-labelledby="tab-weekly">
<!-- Weekly summaries -->
{#each weeklySummaries as week, i}
{@const prevWeek = weeklySummaries[i + 1]}
{@const trend = prevWeek ? week.complianceRate - prevWeek.complianceRate : 0}
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: i * 0.06 }}>
<h3 class="mb-3 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
<h2 class="mb-3 text-[11px] font-medium tracking-[0.15em] text-text-sec uppercase">
Week of {week.weekStart}
</h3>
</h2>
<div class="grid grid-cols-3 gap-3 mb-3">
<div class="text-center">
<div class="text-[22px] font-semibold text-white tabular-nums">{week.totalCompleted}</div>
<div class="text-[10px] text-[#8a8a8a]">Completed</div>
<div class="text-[10px] text-text-sec">Completed</div>
</div>
<div class="text-center">
<div class="text-[22px] font-semibold text-white tabular-nums">{week.totalSkipped}</div>
<div class="text-[10px] text-[#8a8a8a]">Skipped</div>
<div class="text-[10px] text-text-sec">Skipped</div>
</div>
<div class="text-center">
<div class="text-[22px] font-semibold tabular-nums" style="color: {$config.accent_color}">
{Math.round(week.complianceRate * 100)}%
</div>
<div class="text-[10px] text-[#8a8a8a]">Compliance</div>
<div class="text-[10px] text-text-sec">Compliance</div>
</div>
</div>
<div class="flex items-center justify-between text-[11px]">
<span class="text-[#8a8a8a]">Avg {week.avgDailyCompleted.toFixed(1)} breaks/day</span>
<span class="text-text-sec">Avg {week.avgDailyCompleted.toFixed(1)} breaks/day</span>
{#if prevWeek}
<span class="flex items-center gap-1"
style="color: {trend > 0 ? '#3fb950' : trend < 0 ? '#f85149' : '#8a8a8a'};"
style="color: {trend > 0 ? '#3fb950' : trend < 0 ? '#ff6b6b' : '#a8a8a8'};"
>
{#if trend > 0}
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M18 15l-6-6-6 6"/></svg>
@@ -517,12 +524,14 @@
</section>
{/each}
</div>
{:else}
<div role="tabpanel" id="tabpanel-monthly" aria-labelledby="tab-monthly">
<!-- Monthly: 30-day chart -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0 }}>
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
<h2 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-text-sec uppercase">
Last 30 Days
</h3>
</h2>
<!-- svelte-ignore a11y_no_interactive_element_to_noninteractive_role -->
<canvas
@@ -532,7 +541,21 @@
aria-label="30-day break history chart"
></canvas>
<div class="mt-3 flex items-center justify-center gap-4 text-[10px] text-[#8a8a8a]">
{#if monthHistory.length > 0}
<table class="sr-only">
<caption>Break history for the last {monthHistory.length} days</caption>
<thead>
<tr><th>Date</th><th>Completed</th><th>Skipped</th></tr>
</thead>
<tbody>
{#each monthHistory 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-text-sec">
<div class="flex items-center gap-1.5">
<div class="h-2 w-2 rounded-sm" style="background: {$config.accent_color}"></div>
Completed
@@ -546,9 +569,9 @@
<!-- Heatmap -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.06 }}>
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
<h2 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-text-sec uppercase">
Activity Heatmap
</h3>
</h2>
<div class="flex justify-center">
<!-- svelte-ignore a11y_no_interactive_element_to_noninteractive_role -->
@@ -559,7 +582,21 @@
></canvas>
</div>
<div class="mt-3 flex items-center justify-center gap-2 text-[10px] text-[#8a8a8a]">
{#if monthHistory.length > 0}
<table class="sr-only">
<caption>Activity heatmap for the last {monthHistory.length} days</caption>
<thead>
<tr><th>Date</th><th>Breaks completed</th></tr>
</thead>
<tbody>
{#each monthHistory as day}
<tr><td>{day.date}</td><td>{day.breaksCompleted}</td></tr>
{/each}
</tbody>
</table>
{/if}
<div class="mt-3 flex items-center justify-center gap-2 text-[10px] text-text-sec">
<span>Less</span>
<div class="flex gap-1">
<div class="w-3 h-3 rounded-sm" style="background: #161616;"></div>
@@ -574,35 +611,36 @@
<!-- Monthly totals -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.12 }}>
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
<h2 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-text-sec uppercase">
Monthly Summary
</h3>
</h2>
<div class="grid grid-cols-2 gap-4">
<div class="text-center">
<div class="text-[22px] font-semibold text-white tabular-nums">{monthTotalCompleted}</div>
<div class="text-[10px] text-[#8a8a8a]">Total breaks</div>
<div class="text-[10px] text-text-sec">Total breaks</div>
</div>
<div class="text-center">
<div class="text-[22px] font-semibold tabular-nums" style="color: {$config.accent_color}">
{monthAvgCompliance()}%
</div>
<div class="text-[10px] text-[#8a8a8a]">Avg compliance</div>
<div class="text-[10px] text-text-sec">Avg compliance</div>
</div>
<div class="text-center">
<div class="text-[22px] font-semibold text-white tabular-nums">
{Math.floor(monthTotalTime / 60)} min
</div>
<div class="text-[10px] text-[#8a8a8a]">Total break time</div>
<div class="text-[10px] text-text-sec">Total break time</div>
</div>
<div class="text-center">
<div class="text-[22px] font-semibold text-white tabular-nums">
{(monthTotalCompleted / 30).toFixed(1)}
</div>
<div class="text-[10px] text-[#8a8a8a]">Avg daily breaks</div>
<div class="text-[10px] text-text-sec">Avg daily breaks</div>
</div>
</div>
</section>
</div>
{/if}
</div>
+21 -6
View File
@@ -55,20 +55,34 @@
if (holdTimer) { clearTimeout(holdTimer); holdTimer = null; }
if (holdInterval) { clearTimeout(holdInterval as unknown as ReturnType<typeof setTimeout>); holdInterval = null; }
}
function handleKeydown(fn: () => void, e: KeyboardEvent) {
if (["Enter", " "].includes(e.key)) {
e.preventDefault();
startHold(fn);
}
}
function handleKeyup(e: KeyboardEvent) {
if (["Enter", " "].includes(e.key)) {
stopHold();
}
}
</script>
<div class="flex items-center gap-1.5" role="group" aria-label={label}>
<button
type="button"
aria-label="Decrease"
class="flex h-7 w-7 items-center justify-center rounded-lg
bg-[#141414] text-[#8a8a8a] transition-colors
class="flex h-9 w-9 min-h-[44px] min-w-[44px] items-center justify-center rounded-lg
border border-[#3a3a3a] bg-[#1a1a1a] text-text-sec transition-colors
hover:bg-[#1c1c1c] hover:text-white
disabled:opacity-20"
onmousedown={() => startHold(decrement)}
onmouseup={stopHold}
onmouseleave={stopHold}
onclick={(e) => { if (e.detail === 0) decrement(); }}
onkeydown={(e) => handleKeydown(decrement, e)}
onkeyup={handleKeyup}
disabled={value <= min}
>
&minus;
@@ -79,14 +93,15 @@
<button
type="button"
aria-label="Increase"
class="flex h-7 w-7 items-center justify-center rounded-lg
bg-[#141414] text-[#8a8a8a] transition-colors
class="flex h-9 w-9 min-h-[44px] min-w-[44px] items-center justify-center rounded-lg
border border-[#3a3a3a] bg-[#1a1a1a] text-text-sec transition-colors
hover:bg-[#1c1c1c] hover:text-white
disabled:opacity-20"
onmousedown={() => startHold(increment)}
onmouseup={stopHold}
onmouseleave={stopHold}
onclick={(e) => { if (e.detail === 0) increment(); }}
onkeydown={(e) => handleKeydown(increment, e)}
onkeyup={handleKeyup}
disabled={value >= max}
>
+
+2 -2
View File
@@ -7,7 +7,7 @@
let { value, onchange, countdownFont = "" }: Props = $props();
// Local display values - driven by prop normally, overridden during drag/momentum
// Local display values -- driven by prop normally, overridden during drag/momentum
let displayHours = $state(0);
let displayMinutes = $state(0);
let isAnimating = $state(false); // true during drag OR momentum
@@ -373,7 +373,7 @@
border-color: rgba(255, 255, 255, 0.18);
}
/* Perspective container - looking into the cylinder from outside */
/* Perspective container -- looking into the cylinder from outside */
.wheel-viewport {
width: 100%;
height: 100%;
+2 -2
View File
@@ -55,7 +55,7 @@
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
aria-hidden="true"
width={viewSize}
@@ -145,7 +145,7 @@
{/if}
</svg>
<!-- Non-glow SVG - exact size, draws the track + crisp ring -->
<!-- Non-glow SVG -- exact size, draws the track + crisp ring -->
<svg
aria-hidden="true"
width={size}
+44 -44
View File
@@ -5,11 +5,11 @@
</script>
<!-- Invisible drag region - traffic lights on the right -->
<div
<header
data-tauri-drag-region
class="group absolute top-0 left-0 right-0 z-50 flex h-10 items-center justify-end pr-3.5 select-none"
>
<!-- Centered app name (decorative - OS window title handles screen readers) -->
<!-- Centered app name (decorative -- OS window title handles screen readers) -->
<span
aria-hidden="true"
class="pointer-events-none absolute inset-0 flex items-center justify-center
@@ -20,65 +20,65 @@
</span>
<!-- Traffic light buttons (order: maximize, minimize, close → close is rightmost) -->
<div class="flex items-center gap-[8px] opacity-10 transition-opacity duration-300 group-hover:opacity-100 group-focus-within:opacity-100">
<div class="flex items-center gap-0 opacity-10 transition-opacity duration-300 group-hover:opacity-100 group-focus-within:opacity-100">
<!-- Maximize (green) -->
<button
aria-label="Maximize"
class="traffic-btn group/btn relative flex h-[15px] w-[15px] items-center justify-center
rounded-full bg-[#27C93F] transition-all duration-150
hover:brightness-110"
class="group/btn relative flex h-[44px] w-[44px] items-center justify-center"
onclick={() => appWindow.toggleMaximize()}
>
<svg
class="absolute opacity-0 transition-opacity duration-150 group-hover/btn:opacity-100"
width="8"
height="8"
viewBox="0 0 8 8"
fill="none"
>
<polyline points="1,5 1,7 3,7" stroke="#006500" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" fill="none" />
<polyline points="7,3 7,1 5,1" stroke="#006500" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" fill="none" />
<line x1="1" y1="7" x2="7" y2="1" stroke="#006500" stroke-width="1.2" stroke-linecap="round" />
</svg>
<span class="flex h-[20px] w-[20px] items-center justify-center rounded-full bg-[#27C93F] transition-all duration-150 group-hover/btn:brightness-110">
<svg
class="absolute opacity-0 transition-opacity duration-150 group-hover/btn:opacity-100"
width="8"
height="8"
viewBox="0 0 8 8"
fill="none"
>
<polyline points="1,5 1,7 3,7" stroke="#006500" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" fill="none" />
<polyline points="7,3 7,1 5,1" stroke="#006500" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" fill="none" />
<line x1="1" y1="7" x2="7" y2="1" stroke="#006500" stroke-width="1.2" stroke-linecap="round" />
</svg>
</span>
</button>
<!-- Minimize (yellow) -->
<button
aria-label="Minimize"
class="traffic-btn group/btn relative flex h-[15px] w-[15px] items-center justify-center
rounded-full bg-[#FFBD2E] transition-all duration-150
hover:brightness-110"
class="group/btn relative flex h-[44px] w-[44px] items-center justify-center"
onclick={() => appWindow.minimize()}
>
<svg
class="absolute opacity-0 transition-opacity duration-150 group-hover/btn:opacity-100"
width="8"
height="2"
viewBox="0 0 8 2"
fill="none"
>
<line x1="1" y1="1" x2="7" y2="1" stroke="#995700" stroke-width="1.3" stroke-linecap="round" />
</svg>
<span class="flex h-[20px] w-[20px] items-center justify-center rounded-full bg-[#FFBD2E] transition-all duration-150 group-hover/btn:brightness-110">
<svg
class="absolute opacity-0 transition-opacity duration-150 group-hover/btn:opacity-100"
width="8"
height="2"
viewBox="0 0 8 2"
fill="none"
>
<line x1="1" y1="1" x2="7" y2="1" stroke="#995700" stroke-width="1.3" stroke-linecap="round" />
</svg>
</span>
</button>
<!-- Close (red) - rightmost -->
<!-- Close (red) -- rightmost -->
<button
aria-label="Close"
class="traffic-btn group/btn relative flex h-[15px] w-[15px] items-center justify-center
rounded-full bg-[#FF5F57] transition-all duration-150
hover:brightness-110"
class="group/btn relative flex h-[44px] w-[44px] items-center justify-center"
onclick={() => appWindow.close()}
>
<svg
class="absolute opacity-0 transition-opacity duration-150 group-hover/btn:opacity-100"
width="8"
height="8"
viewBox="0 0 8 8"
fill="none"
>
<line x1="1.5" y1="1.5" x2="6.5" y2="6.5" stroke="#4a0002" stroke-width="1.3" stroke-linecap="round" />
<line x1="6.5" y1="1.5" x2="1.5" y2="6.5" stroke="#4a0002" stroke-width="1.3" stroke-linecap="round" />
</svg>
<span class="flex h-[20px] w-[20px] items-center justify-center rounded-full bg-[#FF5F57] transition-all duration-150 group-hover/btn:brightness-110">
<svg
class="absolute opacity-0 transition-opacity duration-150 group-hover/btn:opacity-100"
width="8"
height="8"
viewBox="0 0 8 8"
fill="none"
>
<line x1="1.5" y1="1.5" x2="6.5" y2="6.5" stroke="#4a0002" stroke-width="1.3" stroke-linecap="round" />
<line x1="6.5" y1="1.5" x2="1.5" y2="6.5" stroke="#4a0002" stroke-width="1.3" stroke-linecap="round" />
</svg>
</span>
</button>
</div>
</div>
</header>
+12 -7
View File
@@ -15,19 +15,24 @@
}
</script>
<!-- 44px hit area wrapper (WCAG 2.5.8) with compact visual toggle inside -->
<button
type="button"
role="switch"
aria-label={label}
aria-checked={checked}
class="relative inline-flex h-[24px] w-[48px] shrink-0 cursor-pointer rounded-full
transition-colors duration-200 ease-in-out"
style="background: {checked ? $config.accent_color : '#1a1a1a'};"
class="relative inline-flex min-h-[44px] min-w-[52px] shrink-0 cursor-pointer items-center justify-center
bg-transparent border-none p-0"
onclick={toggle}
>
<span
class="pointer-events-none inline-block h-[19px] w-[19px] rounded-full
shadow-sm transition-transform duration-200 ease-in-out
{checked ? 'translate-x-[26px] bg-white' : 'translate-x-[3px] bg-[#444]'} mt-[2.5px]"
></span>
class="inline-flex h-[28px] w-[52px] items-center rounded-full transition-colors duration-200 ease-in-out"
style="background: {checked ? $config.accent_color : '#1a1a1a'};"
>
<span
class="pointer-events-none inline-block h-[22px] w-[22px] rounded-full
shadow-sm transition-transform duration-200 ease-in-out
{checked ? 'translate-x-[27px] bg-white' : 'translate-x-[3px] bg-[#666]'}"
></span>
</span>
</button>
+14 -14
View File
@@ -8,9 +8,9 @@ export const breakActivities: BreakActivity[] = [
{ category: "eyes", text: "Look at something 20 feet away for 20 seconds" },
{ category: "eyes", text: "Close your eyes and gently press your palms over them" },
{ category: "eyes", text: "Slowly roll your eyes in circles, 5 times each direction" },
{ category: "eyes", text: "Focus on a distant object, then a near one repeat 5 times" },
{ category: "eyes", text: "Focus on a distant object, then a near one -- repeat 5 times" },
{ category: "eyes", text: "Blink rapidly 20 times to refresh your eyes" },
{ category: "eyes", text: "Look up, down, left, right hold each for 2 seconds" },
{ category: "eyes", text: "Look up, down, left, right -- hold each for 2 seconds" },
{ category: "eyes", text: "Cup your hands over closed eyes and breathe deeply" },
{ category: "eyes", text: "Trace a figure-8 with your eyes, slowly" },
{ category: "eyes", text: "Look out the nearest window and find the farthest point" },
@@ -19,9 +19,9 @@ export const breakActivities: BreakActivity[] = [
{ category: "eyes", text: "Warm your palms by rubbing them together, then rest them over closed eyes" },
{ category: "eyes", text: "Slowly shift your gaze along the horizon line outside" },
{ category: "eyes", text: "Focus on the tip of your nose for 5 seconds, then a far wall" },
{ category: "eyes", text: "Look at something green plants reduce eye strain naturally" },
{ category: "eyes", text: "Look at something green -- plants reduce eye strain naturally" },
{ category: "eyes", text: "Close your eyes and gently press your eyebrows outward from center" },
{ category: "eyes", text: "Squeeze your eyes shut for 3 seconds, then open wide repeat 5 times" },
{ category: "eyes", text: "Squeeze your eyes shut for 3 seconds, then open wide -- repeat 5 times" },
{ category: "eyes", text: "Trace the outline of a window frame with your eyes, slowly" },
// Stretches
@@ -35,7 +35,7 @@ export const breakActivities: BreakActivity[] = [
{ category: "stretch", text: "Drop your chin to your chest and slowly roll your neck" },
{ category: "stretch", text: "Extend each arm across your chest, hold for 15 seconds" },
{ category: "stretch", text: "Open and close your hands, spreading fingers wide" },
{ category: "stretch", text: "Place your right hand on your left knee and twist gently switch sides" },
{ category: "stretch", text: "Place your right hand on your left knee and twist gently -- switch sides" },
{ category: "stretch", text: "Reach one arm overhead and lean to the opposite side, hold 10 seconds each" },
{ category: "stretch", text: "Interlace your fingers and flip your palms to the ceiling, push up" },
{ category: "stretch", text: "Pull each finger gently backward for 3 seconds to stretch your forearms" },
@@ -46,20 +46,20 @@ export const breakActivities: BreakActivity[] = [
// Breathing
{ category: "breathing", text: "Inhale for 4 seconds, hold for 4, exhale for 6" },
{ category: "breathing", text: "Take 5 deep belly breaths feel your diaphragm expand" },
{ category: "breathing", text: "Take 5 deep belly breaths -- feel your diaphragm expand" },
{ category: "breathing", text: "Box breathing: inhale 4s, hold 4s, exhale 4s, hold 4s" },
{ category: "breathing", text: "Breathe in through your nose, out through your mouth 5 times" },
{ category: "breathing", text: "Breathe in through your nose, out through your mouth -- 5 times" },
{ category: "breathing", text: "Sigh deeply 3 times to release tension" },
{ category: "breathing", text: "Place a hand on your chest and breathe so only your belly moves" },
{ category: "breathing", text: "Alternate nostril breathing: 3 cycles each side" },
{ category: "breathing", text: "Exhale completely, then take the deepest breath you can" },
{ category: "breathing", text: "Count your breaths backward from 10 to 1" },
{ category: "breathing", text: "Breathe in calm, breathe out tension 5 rounds" },
{ category: "breathing", text: "Breathe in calm, breathe out tension -- 5 rounds" },
{ category: "breathing", text: "4-7-8 breathing: inhale 4s, hold 7s, exhale slowly for 8s" },
{ category: "breathing", text: "Take 3 breaths making your exhale twice as long as your inhale" },
{ category: "breathing", text: "Breathe in for 2 counts, out for 2 gradually increase to 6 each" },
{ category: "breathing", text: "Hum gently on each exhale for 5 breaths feel the vibration in your chest" },
{ category: "breathing", text: "Imagine breathing in cool blue air and exhaling warm red air 5 rounds" },
{ category: "breathing", text: "Breathe in for 2 counts, out for 2 -- gradually increase to 6 each" },
{ category: "breathing", text: "Hum gently on each exhale for 5 breaths -- feel the vibration in your chest" },
{ category: "breathing", text: "Imagine breathing in cool blue air and exhaling warm red air -- 5 rounds" },
{ category: "breathing", text: "Place both hands on your ribs and breathe so you feel them expand sideways" },
{ category: "breathing", text: "Breathe in through pursed lips slowly, then exhale in a long, steady stream" },
@@ -75,11 +75,11 @@ export const breakActivities: BreakActivity[] = [
{ category: "movement", text: "Shake out your hands and arms for 10 seconds" },
{ category: "movement", text: "Stand up, touch your toes, and slowly rise" },
{ category: "movement", text: "Walk to the farthest room in your home and back" },
{ category: "movement", text: "Do 5 wall push-ups hands on the wall, lean in and push back" },
{ category: "movement", text: "Do 5 wall push-ups -- hands on the wall, lean in and push back" },
{ category: "movement", text: "Stand up and slowly shift your weight side to side for 20 seconds" },
{ category: "movement", text: "Walk in a small circle, paying attention to each footstep" },
{ category: "movement", text: "Rise onto your tiptoes, hold for 5 seconds, lower slowly repeat 5 times" },
{ category: "movement", text: "Do a gentle standing forward fold let your arms hang loose" },
{ category: "movement", text: "Rise onto your tiptoes, hold for 5 seconds, lower slowly -- repeat 5 times" },
{ category: "movement", text: "Do a gentle standing forward fold -- let your arms hang loose" },
{ category: "movement", text: "Step away from your desk and do 10 hip circles in each direction" },
{ category: "movement", text: "Stand with your back against a wall and slide down into a gentle wall sit for 15 seconds" },
];
+26 -31
View File
@@ -1,6 +1,6 @@
import { animate } from "motion";
// Module-level reduced motion query shared across all actions
// Module-level reduced motion query -- shared across all actions
const reducedMotionQuery =
typeof window !== "undefined"
? window.matchMedia("(prefers-reduced-motion: reduce)")
@@ -10,9 +10,6 @@ function prefersReducedMotion(): boolean {
return reducedMotionQuery?.matches ?? false;
}
/**
* Svelte action: fade in + slide up on mount
*/
export function fadeIn(
node: HTMLElement,
options?: { duration?: number; delay?: number; y?: number },
@@ -38,9 +35,6 @@ export function fadeIn(
};
}
/**
* Svelte action: scale in + fade on mount
*/
export function scaleIn(
node: HTMLElement,
options?: { duration?: number; delay?: number },
@@ -66,9 +60,6 @@ export function scaleIn(
};
}
/**
* Svelte action: animate when scrolled into view (IntersectionObserver)
*/
export function inView(
node: HTMLElement,
options?: { delay?: number; y?: number; threshold?: number },
@@ -111,9 +102,6 @@ export function inView(
};
}
/**
* Svelte action: spring-scale press feedback on buttons
*/
export function pressable(node: HTMLElement) {
if (prefersReducedMotion()) {
return { destroy() {} };
@@ -140,28 +128,38 @@ export function pressable(node: HTMLElement) {
);
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onDown();
}
}
function onKeyUp(e: KeyboardEvent) {
if (e.key === "Enter" || e.key === " ") {
onUp();
}
}
node.addEventListener("mousedown", onDown);
node.addEventListener("mouseup", onUp);
node.addEventListener("mouseleave", onUp);
node.addEventListener("keydown", onKeyDown);
node.addEventListener("keyup", onKeyUp);
return {
destroy() {
node.removeEventListener("mousedown", onDown);
node.removeEventListener("mouseup", onUp);
node.removeEventListener("mouseleave", onUp);
node.removeEventListener("keydown", onKeyDown);
node.removeEventListener("keyup", onKeyUp);
active?.cancel();
},
};
}
/**
* Svelte action: animated glow band on hover.
* Creates a crisp ring "band" plus a soft atmospheric glow.
* Pass a hex color (e.g. "#ff4d00").
*
* Uses same-hue zero-alpha as the "off" state so the Web Animations API
* interpolates through the correct color channel instead of through black.
*/
// Animated glow band on hover. Pass a hex color (e.g. "#ff4d00").
export function glowHover(
node: HTMLElement,
options?: { color?: string },
@@ -200,6 +198,8 @@ export function glowHover(
node.addEventListener("mouseenter", onEnter);
node.addEventListener("mouseleave", onLeave);
node.addEventListener("focusin", onEnter);
node.addEventListener("focusout", onLeave);
return {
update(newOptions?: { color?: string }) {
@@ -209,20 +209,15 @@ export function glowHover(
destroy() {
node.removeEventListener("mouseenter", onEnter);
node.removeEventListener("mouseleave", onLeave);
node.removeEventListener("focusin", onEnter);
node.removeEventListener("focusout", onLeave);
enterAnim?.cancel();
leaveAnim?.cancel();
},
};
}
/**
* Svelte action: momentum-based grab-and-drag scrolling
* with elastic overscroll and spring-back at boundaries.
*
* IMPORTANT: The node must have exactly one child element (a wrapper div).
* Overscroll transforms are applied to that child, NOT to the scroll
* container itself (which would break overflow clipping).
*/
// Momentum grab-and-drag scrolling. Node must have exactly one child wrapper div.
export function dragScroll(node: HTMLElement) {
if (prefersReducedMotion()) {
// Allow normal scrolling without the momentum/elastic physics
@@ -272,7 +267,7 @@ export function dragScroll(node: HTMLElement) {
// Ensure DOM is clean after animation completes
springAnim.finished.then(() => {
if (content) content.style.transform = "";
}).catch(() => { /* cancelled onDown handles cleanup */ });
}).catch(() => { /* cancelled -- onDown handles cleanup */ });
}
function forceReset() {
@@ -349,7 +344,7 @@ export function dragScroll(node: HTMLElement) {
// Time-based exponential decay (iOS-style scroll physics).
// position(t) = start + v0 * tau * (1 - e^(-t/tau))
// velocity(t) = v0 * e^(-t/tau) → naturally asymptotes to zero
const tau = 325; // time constant in ms iOS UIScrollView feel
const tau = 325; // time constant in ms -- iOS UIScrollView feel
const coastStart = performance.now();
const scrollStart2 = node.scrollTop;
const totalDist = v0 * tau;
+7 -18
View File
@@ -1,8 +1,3 @@
/**
* Synthesized notification sounds using the Web Audio API.
* No external audio files needed all sounds are generated programmatically.
*/
let audioCtx: AudioContext | null = null;
function getAudioContext(): AudioContext {
@@ -14,11 +9,6 @@ function getAudioContext(): AudioContext {
type SoundPreset = "bell" | "chime" | "soft" | "digital" | "harp" | "bowl" | "rain" | "whistle";
/**
* Play a notification sound with the given preset and volume.
* @param preset - One of: "bell", "chime", "soft", "digital"
* @param volume - 0 to 100
*/
export function playSound(preset: SoundPreset, volume: number): void {
const ctx = getAudioContext();
const gain = ctx.createGain();
@@ -53,7 +43,7 @@ export function playSound(preset: SoundPreset, volume: number): void {
}
}
/** Warm bell two sine tones with harmonics and slow decay */
/** Warm bell -- two sine tones with harmonics and slow decay */
function playBell(ctx: AudioContext, destination: GainNode, vol: number) {
const now = ctx.currentTime;
@@ -103,7 +93,7 @@ function playChime(ctx: AudioContext, destination: GainNode, vol: number) {
});
}
/** Gentle soft ping filtered triangle wave */
/** Gentle soft ping -- filtered triangle wave */
function playSoft(ctx: AudioContext, destination: GainNode, vol: number) {
const now = ctx.currentTime;
@@ -129,7 +119,7 @@ function playSoft(ctx: AudioContext, destination: GainNode, vol: number) {
osc.stop(now + 1.2);
}
/** Digital blip short square wave burst */
/** Digital blip -- short square wave burst */
function playDigital(ctx: AudioContext, destination: GainNode, vol: number) {
const now = ctx.currentTime;
@@ -149,7 +139,7 @@ function playDigital(ctx: AudioContext, destination: GainNode, vol: number) {
}
}
/** Harp cascading arpeggiated sine tones (C5-E5-G5-C6) */
/** Harp -- cascading arpeggiated sine tones (C5-E5-G5-C6) */
function playHarp(ctx: AudioContext, destination: GainNode, vol: number) {
const now = ctx.currentTime;
const notes = [523.25, 659.25, 783.99, 1046.5]; // C5, E5, G5, C6
@@ -170,7 +160,7 @@ function playHarp(ctx: AudioContext, destination: GainNode, vol: number) {
});
}
/** Singing bowl low sine with slow beating from detuned pair */
/** Singing bowl -- low sine with slow beating from detuned pair */
function playBowl(ctx: AudioContext, destination: GainNode, vol: number) {
const now = ctx.currentTime;
@@ -201,7 +191,7 @@ function playBowl(ctx: AudioContext, destination: GainNode, vol: number) {
osc3.stop(now + 1.5);
}
/** Rain filtered noise burst with gentle decay */
/** Rain -- filtered noise burst with gentle decay */
function playRain(ctx: AudioContext, destination: GainNode, vol: number) {
const now = ctx.currentTime;
const bufferSize = ctx.sampleRate * 1;
@@ -233,7 +223,7 @@ function playRain(ctx: AudioContext, destination: GainNode, vol: number) {
noise.stop(now + 1.0);
}
/** Whistle gentle two-note sine glide */
/** Whistle -- gentle two-note sine glide */
function playWhistle(ctx: AudioContext, destination: GainNode, vol: number) {
const now = ctx.currentTime;
@@ -256,7 +246,6 @@ function playWhistle(ctx: AudioContext, destination: GainNode, vol: number) {
osc.stop(now + 1.0);
}
/** Play a completion sound — slightly different from start (descending) */
export function playBreakEndSound(preset: SoundPreset, volume: number): void {
const ctx = getAudioContext();
const gain = ctx.createGain();