Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f7aa074bc | ||
|
|
d26b73288d | ||
|
|
8a04edc2bc | ||
|
|
aadc1eaac0 | ||
|
|
acf06c8d32 | ||
|
|
95f684450c | ||
|
|
3ae9db3be0 | ||
| 51541c9b66 | |||
|
|
743477cd4e | ||
|
|
666b2418b9 | ||
|
|
a339dd1bb3 |
78
CHANGELOG.md
Normal file
78
CHANGELOG.md
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## v0.1.3
|
||||||
|
|
||||||
|
### New Features
|
||||||
|
|
||||||
|
- **Pomodoro Mode** — Alternates short breaks with a longer recovery break after configurable focus sessions. Cycle indicator on dashboard, custom long break titles/messages, reset-on-skip option.
|
||||||
|
- **Microbreaks (20-20-20 Rule)** — Independent short eye breaks between main breaks. Subtle non-blocking overlay with activity suggestion and optional sound. Configurable frequency (5-60 min) and duration (10-60 sec).
|
||||||
|
- **Breathing Guide** — Visual breathing exercise during breaks with 5 patterns (Box, Relaxing, Energizing, Calm, Deep). Animated pulsing halo behind countdown ring with color interpolation between accent and break colors. Phase labels (Inhale/Hold/Exhale) with countdown.
|
||||||
|
- **Screen Dimming** — Gradual pre-break screen dimming. Configurable timing (3-60 sec before break) and maximum intensity (10-70%).
|
||||||
|
- **Presentation Mode** — Detects fullscreen applications and defers breaks until exit. Optional microbreak deferral and toast notification when breaks are deferred.
|
||||||
|
- **Goals & Streaks** — Daily break target with progress indicator inside dashboard ring. Confetti celebrations on milestones and goal completion. Streak tracking with toast notifications.
|
||||||
|
- **Multi-Monitor Breaks** — Fullscreen break overlay spans all connected monitors.
|
||||||
|
- **Working Hours Schedule** — Per-day schedule with multiple time ranges. Timer automatically pauses outside configured hours.
|
||||||
|
- **Activity Manager** — Browse, search, favorite, and disable built-in activities. Add custom activities with category assignment. Favorites appear 3x more often. Momentum drag scroll with elastic overscroll.
|
||||||
|
- **Celebration Animations** — Confetti particle effects on streak milestones and daily goal completion.
|
||||||
|
- **Smart Breaks** — Recognizes natural away-from-desk breaks (idle periods exceeding configurable threshold) and optionally counts them toward daily goal.
|
||||||
|
- **Break Window (Standalone)** — When fullscreen mode is disabled, breaks open in a separate centered transparent modal window instead of taking over the main window.
|
||||||
|
- **Break Overlay** — Multi-monitor break enforcement overlay that covers all screens during breaks.
|
||||||
|
- **Microbreak Overlay** — Subtle non-blocking overlay for 20-20-20 eye breaks with activity suggestion.
|
||||||
|
- **Dim Overlay** — Smooth pre-break screen dimming with gradual opacity transition.
|
||||||
|
|
||||||
|
### UI Polish
|
||||||
|
|
||||||
|
- **Settings Reordered** — 18 logically grouped cards: Timer, Pomodoro, Microbreaks, Break Screen, Break Activities (conditional), Breathing Guide, Behavior, Alerts (merged notifications + screen dimming), Sound, Idle & Smart Breaks (merged), Presentation Mode, Goals & Streaks, Appearance, Working Hours, Mini Mode, General, Keyboard Shortcuts, Reset.
|
||||||
|
- **Breathing Pattern Selector Redesigned** — Replaced cramped 5-column grid with vertical radio-button list showing timing descriptions (e.g., "4s in - 4s hold - 4s out - 4s hold").
|
||||||
|
- **Dashboard Indicators Inside Ring** — Pomodoro cycle dots, microbreak countdown, and daily goal progress bar moved inside the timer ring for cleaner layout.
|
||||||
|
- **Daily Goal Label** — Progress bar now includes a target icon and "Goal" label.
|
||||||
|
- **Break Screen Breathing Halo** — Breathing guide renders as a pulsing circle behind the countdown ring with dynamic color gradient, in both fullscreen and standalone modes.
|
||||||
|
|
||||||
|
### Improvements
|
||||||
|
|
||||||
|
- Expanded break activity library from 40 to 71 curated activities
|
||||||
|
- Added 4 new sound presets: Harp, Bowl, Rain, Whistle (now 8 total)
|
||||||
|
- Stats view now includes weekly summary, natural break tracking, and daily goal progress
|
||||||
|
- Timer store now handles microbreak, screen dim, celebration, and presentation mode events
|
||||||
|
- Config store expanded from ~30 to 71 keys with validation for all new features
|
||||||
|
- Timer state machine now tracks pomodoro cycles, microbreak scheduling, presentation mode deferral, and smart break detection
|
||||||
|
- Stats backend tracks natural breaks, daily goals, and weekly summaries
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- README comprehensively updated with all new features, accurate component/activity/config counts
|
||||||
|
- CLAUDE.md updated with current architecture (17 commands, 12 events, 20 components, 71 config keys)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0.1.2
|
||||||
|
|
||||||
|
- WCAG 2.1 Level AA accessibility across all components
|
||||||
|
- Focus indicators, reduced motion support, forced colors, screen reader support
|
||||||
|
- Keyboard navigation, focus trapping, aria-live regions
|
||||||
|
|
||||||
|
## v0.1.1
|
||||||
|
|
||||||
|
- Tighten TimeSpinner spacing and fix build warnings
|
||||||
|
- Enable custom-protocol for embedded frontend assets
|
||||||
|
- Fix WebView2 detection using loader API instead of registry
|
||||||
|
- Statically link WebView2Loader for single portable exe
|
||||||
|
- Fix fullscreen break screen centering and sizing
|
||||||
|
|
||||||
|
## v0.1.0
|
||||||
|
|
||||||
|
- Initial release
|
||||||
|
- Dashboard with timer ring and status pill
|
||||||
|
- Break screen with activity suggestions
|
||||||
|
- Settings panel with sound, idle detection, and activity configuration
|
||||||
|
- Statistics view with 7-day bar chart
|
||||||
|
- System tray with dynamic icon, tooltip, and context menu
|
||||||
|
- Config and stats persistence (portable JSON files)
|
||||||
|
- Global keyboard shortcuts (Ctrl+Shift+P/B/S)
|
||||||
|
- Mini mode floating timer
|
||||||
|
- Toast notifications
|
||||||
|
- Always-on-top break enforcement
|
||||||
|
- Animated view transitions
|
||||||
|
- Idle detection via Windows API
|
||||||
|
- Custom titlebar with frosted glass effects
|
||||||
|
- Background gradient blobs with film grain
|
||||||
441
README.md
441
README.md
@@ -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/svelte-5-FF3E00?style=flat-square&logo=svelte&logoColor=white" alt="Svelte 5" />
|
||||||
<img src="https://img.shields.io/badge/rust-2021-000000?style=flat-square&logo=rust&logoColor=white" alt="Rust" />
|
<img src="https://img.shields.io/badge/rust-2021-000000?style=flat-square&logo=rust&logoColor=white" alt="Rust" />
|
||||||
<img src="https://img.shields.io/badge/tailwind-v4-06B6D4?style=flat-square&logo=tailwindcss&logoColor=white" alt="Tailwind v4" />
|
<img src="https://img.shields.io/badge/tailwind-v4-06B6D4?style=flat-square&logo=tailwindcss&logoColor=white" alt="Tailwind v4" />
|
||||||
<img src="https://img.shields.io/badge/WCAG_2.1-AA-228B22?style=flat-square" alt="WCAG 2.1 AA" />
|
<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>
|
||||||
|
|
||||||
<p align="center">
|
<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.
|
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.
|
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,11 +59,11 @@ Tools for human wellbeing should never be enclosed, never be scarce, and never s
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🖼️ Screenshots
|
## 📸 Screenshots
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="screenshots/01-dashboard.png" alt="Dashboard - Main Timer" width="420" /><br />
|
<img src="screenshots/01-dashboard.png" alt="Dashboard - Main Timer" width="420" /><br />
|
||||||
<sub><strong>Dashboard</strong> - Focus timer with countdown ring, status pill, and quick controls</sub>
|
<sub><strong>Dashboard</strong> - Focus timer with countdown ring, status pill, pomodoro dots, and quick controls</sub>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<br />
|
<br />
|
||||||
@@ -77,14 +77,14 @@ Tools for human wellbeing should never be enclosed, never be scarce, and never s
|
|||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="screenshots/03-settings.png" alt="Settings" width="420" /><br />
|
<img src="screenshots/03-settings.png" alt="Settings" width="420" /><br />
|
||||||
<sub><strong>Settings</strong> - Grouped configuration cards with live preview</sub>
|
<sub><strong>Settings</strong> - 18 grouped configuration cards with live preview</sub>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<br />
|
<br />
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="screenshots/04-break.png" alt="Break Screen" width="600" /><br />
|
<img src="screenshots/04-break.png" alt="Break Screen" width="600" /><br />
|
||||||
<sub><strong>Break Screen</strong> - Always-on-top break overlay with activity suggestions</sub>
|
<sub><strong>Break Screen</strong> - Always-on-top break overlay with breathing guide and activity suggestions</sub>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<br />
|
<br />
|
||||||
@@ -98,7 +98,7 @@ Tools for human wellbeing should never be enclosed, never be scarce, and never s
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Features
|
## ✨ Features
|
||||||
|
|
||||||
### ⏱️ Timer & Breaks
|
### ⏱️ Timer & Breaks
|
||||||
|
|
||||||
@@ -107,16 +107,55 @@ Tools for human wellbeing should never be enclosed, never be scarce, and never s
|
|||||||
| 🔄 | **Configurable intervals** | Work sessions from 5-120 min, breaks from 1-60 min |
|
| 🔄 | **Configurable intervals** | Work sessions from 5-120 min, breaks from 1-60 min |
|
||||||
| 🔔 | **Pre-break warnings** | Toast notification + optional sound alert before each break |
|
| 🔔 | **Pre-break warnings** | Toast notification + optional sound alert before each break |
|
||||||
| 🛡️ | **Break enforcement** | Always-on-top break window with optional fullscreen mode |
|
| 🛡️ | **Break enforcement** | Always-on-top break window with optional fullscreen mode |
|
||||||
|
| 🖥️ | **Multi-monitor** | Fullscreen break overlay spans all connected monitors |
|
||||||
| 🔒 | **Strict mode** | Removes skip and cancel buttons entirely |
|
| 🔒 | **Strict mode** | Removes skip and cancel buttons entirely |
|
||||||
| ⏩ | **Early end** | Optionally allow ending a break after 50% completion |
|
| ⏩ | **Early end** | Optionally allow ending a break after 50% completion |
|
||||||
| 😴 | **Snooze** | Delay breaks by a configurable duration (with limits) |
|
| 😴 | **Snooze** | Delay breaks by a configurable duration (with limits) |
|
||||||
| ⏳ | **Skip cooldown** | Prevents rapid-fire skipping with a cooldown timer |
|
|
||||||
| ⚡ | **Immediate breaks** | Skip pre-break notification, go straight into break |
|
| ⚡ | **Immediate breaks** | Skip pre-break notification, go straight into break |
|
||||||
| 🎯 | **Manual break** | Start a break anytime from the dashboard or tray menu |
|
| 🎯 | **Manual break** | Start a break anytime from the dashboard or tray menu |
|
||||||
|
|
||||||
<br />
|
<br />
|
||||||
|
|
||||||
### 🧠 Idle Detection & Smart Breaks
|
### 🍅 Pomodoro Mode
|
||||||
|
|
||||||
|
A full Pomodoro timer built in. Alternates short breaks with a longer recovery break after a configurable number of focus sessions.
|
||||||
|
|
||||||
|
- **Short breaks before long** - 1-10 short breaks then one long break (default: 3 + 1)
|
||||||
|
- **Long break duration** - independently configurable (5-60 min, default: 15 min)
|
||||||
|
- **Custom titles and messages** - personalize the long break screen
|
||||||
|
- **Cycle indicator** - dashboard shows dot progress through the current cycle
|
||||||
|
- **Reset on skip** - optionally restart the cycle when skipping a break
|
||||||
|
|
||||||
|
<br />
|
||||||
|
|
||||||
|
### 👁️ 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.
|
||||||
|
|
||||||
|
- **Independent timer** - runs alongside the main break timer
|
||||||
|
- **Configurable frequency** - 5-60 minutes (default: 20)
|
||||||
|
- **Configurable duration** - 10-60 seconds (default: 20)
|
||||||
|
- **Subtle overlay** - non-blocking overlay with activity suggestion
|
||||||
|
- **Sound notification** - optional audio cue
|
||||||
|
- **Pauses during breaks** - no microbreak interruptions during main breaks
|
||||||
|
|
||||||
|
<br />
|
||||||
|
|
||||||
|
### 🌬️ 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.
|
||||||
|
|
||||||
|
| Pattern | Timing |
|
||||||
|
|:--------|:-------|
|
||||||
|
| **Box** | 4s in · 4s hold · 4s out · 4s hold |
|
||||||
|
| **Relaxing** | 4s in · 7s hold · 8s out |
|
||||||
|
| **Energizing** | 6s in · 2s hold · 6s out · 2s hold |
|
||||||
|
| **Calm** | 4s in · 4s hold · 6s out |
|
||||||
|
| **Deep** | 5s in · 5s out |
|
||||||
|
|
||||||
|
<br />
|
||||||
|
|
||||||
|
### 💤 Idle Detection & Smart Breaks
|
||||||
|
|
||||||
| | Feature | Description |
|
| | Feature | Description |
|
||||||
|:--|:--------|:------------|
|
|:--|:--------|:------------|
|
||||||
@@ -127,9 +166,20 @@ Tools for human wellbeing should never be enclosed, never be scarce, and never s
|
|||||||
|
|
||||||
<br />
|
<br />
|
||||||
|
|
||||||
### 🧘 Break Activities
|
### 🎬 Presentation Mode
|
||||||
|
|
||||||
Each break shows a randomized suggestion from a curated library of **72 activities** across four categories:
|
Detects fullscreen applications (presentations, video calls, games) and defers breaks until you exit.
|
||||||
|
|
||||||
|
- **Auto-detection** - monitors for fullscreen windows
|
||||||
|
- **Microbreak deferral** - optionally defer microbreaks too
|
||||||
|
- **Toast notification** - alerts you when a break is deferred
|
||||||
|
- **Queued breaks** - deferred breaks trigger when the fullscreen app closes
|
||||||
|
|
||||||
|
<br />
|
||||||
|
|
||||||
|
### 🤸 Break Activities
|
||||||
|
|
||||||
|
Each break shows a randomized suggestion from a curated library of **71 activities** across four categories:
|
||||||
|
|
||||||
| Category | Examples |
|
| Category | Examples |
|
||||||
|:---------|:---------|
|
|:---------|:---------|
|
||||||
@@ -140,6 +190,33 @@ Each break shows a randomized suggestion from a curated library of **72 activiti
|
|||||||
|
|
||||||
Activities cycle every 30 seconds and never repeat consecutively.
|
Activities cycle every 30 seconds and never repeat consecutively.
|
||||||
|
|
||||||
|
**Activity Manager** - customize your break experience from settings:
|
||||||
|
- **Custom activities** - add your own with category assignment
|
||||||
|
- **Favorites** - star activities to increase their appearance frequency (3x weight)
|
||||||
|
- **Enable/disable** - toggle any built-in or custom activity
|
||||||
|
- **Momentum scroll** - iOS-style drag scrolling with elastic overscroll in the activity list
|
||||||
|
|
||||||
|
<br />
|
||||||
|
|
||||||
|
### 🏆 Goals & Streaks
|
||||||
|
|
||||||
|
| | Feature | Description |
|
||||||
|
|:--|:--------|:------------|
|
||||||
|
| 🎯 | **Daily goal** | Set a target number of breaks per day (1-30, shown on dashboard) |
|
||||||
|
| 🎉 | **Celebrations** | Confetti animation on milestones and goal completion |
|
||||||
|
| 🔥 | **Streak tracking** | Current and best consecutive-day streaks |
|
||||||
|
| 🔔 | **Streak notifications** | Toast notification on streak milestones |
|
||||||
|
|
||||||
|
<br />
|
||||||
|
|
||||||
|
### 🌑 Screen Dimming
|
||||||
|
|
||||||
|
A gentle pre-break nudge that gradually dims your screen before the break starts.
|
||||||
|
|
||||||
|
- **Configurable timing** - start dimming 3-60 seconds before break
|
||||||
|
- **Adjustable intensity** - maximum opacity from 10% to 70%
|
||||||
|
- **Smooth transition** - gradual linear fade, not a sudden jump
|
||||||
|
|
||||||
<br />
|
<br />
|
||||||
|
|
||||||
### 📅 Working Hours Schedule
|
### 📅 Working Hours Schedule
|
||||||
@@ -188,11 +265,11 @@ Works system-wide, even when Core Cooldown is not focused.
|
|||||||
|
|
||||||
<br />
|
<br />
|
||||||
|
|
||||||
### 🔲 System Tray
|
### 🔵 System Tray
|
||||||
|
|
||||||
- 🎨 **Dynamic icon** - 32x32 progress arc rendered in real-time (orange = focus, purple = break, dimmed = paused)
|
- **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
|
- **Countdown tooltip** - hover over tray icon to see time remaining
|
||||||
- 📋 **Context menu** - pause/resume, start break, toggle mini mode, show/hide, quit
|
- **Context menu** - pause/resume, start break, toggle mini mode, show/hide, quit
|
||||||
|
|
||||||
<br />
|
<br />
|
||||||
|
|
||||||
@@ -200,10 +277,10 @@ Works system-wide, even when Core Cooldown is not focused.
|
|||||||
|
|
||||||
A compact floating timer (200x50px) that sits on top of your other windows.
|
A compact floating timer (200x50px) that sits on top of your other windows.
|
||||||
|
|
||||||
- 👻 **Click-through** - completely transparent to mouse events, never blocks what's underneath
|
- **Click-through** - completely transparent to mouse events, never blocks what's underneath
|
||||||
- ✋ **Hover to grab** - hover for a configurable number of seconds and it becomes draggable
|
- **Hover to grab** - hover for a configurable number of seconds and it becomes draggable
|
||||||
- 🖱️ **Double-click** - opens the main window
|
- **Double-click** - opens the main window
|
||||||
- 🔀 **Togglable** - enable/disable from the tray menu
|
- **Togglable** - enable/disable from the tray menu
|
||||||
|
|
||||||
<br />
|
<br />
|
||||||
|
|
||||||
@@ -211,49 +288,84 @@ A compact floating timer (200x50px) that sits on top of your other windows.
|
|||||||
|
|
||||||
| Setting | Range |
|
| Setting | Range |
|
||||||
|:--------|:------|
|
|:--------|:------|
|
||||||
| 🔍 **UI zoom** | 50-200% with live preview |
|
| **UI zoom** | 50-200% with live preview |
|
||||||
| 🎯 **Accent color** | Hex color picker for the main UI accent |
|
| **Accent color** | Full color picker (SL pad + hue bar) for the main UI accent |
|
||||||
| 💜 **Break color** | Separate hex for the break screen ring |
|
| **Break color** | Separate color for the break screen ring and breathing guide |
|
||||||
| 🌈 **Color schemes** | Ocean, Forest, Sunset, Midnight, Dawn |
|
| **Countdown font** | Google Fonts selector for timer display |
|
||||||
| 🔤 **Countdown font** | Google Fonts selector for timer display |
|
| **Background blobs** | Animated gradient blobs with film grain overlay |
|
||||||
| 🫧 **Background blobs** | Animated gradient blobs with film grain overlay |
|
| **Break title & message** | Fully customizable text shown during breaks |
|
||||||
| 🌑 **Backdrop opacity** | 50-100% for the break screen overlay |
|
|
||||||
| 💬 **Break title & message** | Fully customizable text shown during breaks |
|
|
||||||
| 🌙 **Dark mode** | Always on (the only civilized option) |
|
|
||||||
|
|
||||||
<br />
|
<br />
|
||||||
|
|
||||||
### 🔔 Notifications
|
### 🔔 Notifications
|
||||||
|
|
||||||
Native Windows toast notifications for:
|
Native Windows toast notifications for:
|
||||||
- ⏰ Pre-break warnings (configurable seconds before break)
|
- Pre-break warnings (configurable seconds before break)
|
||||||
- ✅ Break completion
|
- Break completion
|
||||||
|
- Streak milestones
|
||||||
|
- Daily goal achievement
|
||||||
|
- Break deferral (presentation mode)
|
||||||
|
|
||||||
<br />
|
<br />
|
||||||
|
|
||||||
### 🪟 Window Behavior
|
### 🪟 Window Behavior
|
||||||
|
|
||||||
- **Frameless window** with custom titlebar and drag region
|
- **Frameless window** with custom titlebar and drag region
|
||||||
- **Transparent background** with frosted glass effects
|
- **Transparent background** with frosted glass effects (backdrop-blur)
|
||||||
- **Window position persistence** - main and mini windows remember position between launches
|
- **Window position persistence** - main and mini windows remember position between launches
|
||||||
- **Animated view transitions** - directional fly/scale/fade transitions (700ms, cubicOut easing)
|
- **Animated view transitions** - directional fly/scale/fade transitions (700ms, cubicOut easing)
|
||||||
|
- **Momentum scroll** - iOS-style drag scrolling with elastic overscroll in settings
|
||||||
|
|
||||||
<br />
|
<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 |
|
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.
|
||||||
|:--|:--------|:------------|
|
|
||||||
| ⌨️ | **Full keyboard navigation** | Every control reachable and operable via keyboard alone. Arrow keys adjust color pickers, steppers, and time spinners. Tab/Shift+Tab cycles through all interactive elements. |
|
> **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.
|
||||||
| 🔍 | **Visible focus indicators** | Global `:focus-visible` outlines on all interactive elements - no hidden or suppressed focus rings |
|
|
||||||
| 🗣️ | **Screen reader support** | `aria-live` regions announce timer state changes, break activities, and status updates. Progress rings use `role="progressbar"` with value text. Stats chart has a screen-reader-accessible data table. |
|
#### Contrast & Visual Design
|
||||||
| 🎯 | **Focus management** | View transitions move focus to the new view's heading. Break screen traps focus to prevent interaction with obscured content. |
|
|
||||||
| 🎨 | **Color contrast** | All text meets 4.5:1 minimum contrast ratio against dark backgrounds (WCAG AA) |
|
| | Feature | Standard | Description |
|
||||||
| 🖥️ | **Windows High Contrast** | `forced-colors: active` media query maps all theme tokens to system colors |
|
|:--|:--------|:---------|:------------|
|
||||||
| 🐢 | **Reduced motion** | `prefers-reduced-motion` disables all CSS animations/transitions *and* all JavaScript-driven Web Animations API effects. No functionality lost - just calmer. |
|
| 🎨 | **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. |
|
||||||
| 🏷️ | **Descriptive labels** | All toggle switches, steppers, buttons, and form controls have descriptive accessible names instead of generic labels |
|
| 🔤 | **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 />
|
<br />
|
||||||
|
|
||||||
@@ -264,11 +376,11 @@ Core Cooldown targets **WCAG 2.1 Level AA** compliance. A break timer for preven
|
|||||||
Core Cooldown is **fully portable**. The executable carries everything it needs and stores everything it creates right next to itself:
|
Core Cooldown is **fully portable**. The executable carries everything it needs and stores everything it creates right next to itself:
|
||||||
|
|
||||||
```
|
```
|
||||||
📁 anywhere-you-want/
|
anywhere-you-want/
|
||||||
├── core-cooldown.exe ← the application
|
├── core-cooldown.exe <- the application
|
||||||
├── config.json ← your settings (auto-created on first run)
|
├── config.json <- your settings (auto-created on first run)
|
||||||
├── stats.json ← your break history (auto-created on first run)
|
├── stats.json <- your break history (auto-created on first run)
|
||||||
└── data/ ← WebView2 runtime data (auto-created on first run)
|
└── data/ <- WebView2 runtime data (auto-created on first run)
|
||||||
```
|
```
|
||||||
|
|
||||||
- No installer
|
- No installer
|
||||||
@@ -291,13 +403,13 @@ Core Cooldown is **fully portable**. The executable carries everything it needs
|
|||||||
|
|
||||||
That's it. No elevated permissions. No runtime dependencies. The first launch may take a moment while Windows initializes the WebView2 runtime.
|
That's it. No elevated permissions. No runtime dependencies. The first launch may take a moment while Windows initializes the WebView2 runtime.
|
||||||
|
|
||||||
**[Download latest release →](https://git.lashman.live/lashman/core-cooldown/releases)**
|
**[Download latest release](https://git.lashman.live/lashman/core-cooldown/releases)**
|
||||||
|
|
||||||
<br />
|
<br />
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🔨 Building from Source
|
## 🔧 Building from Source
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><strong>Prerequisites</strong></summary>
|
<summary><strong>Prerequisites</strong></summary>
|
||||||
@@ -371,7 +483,7 @@ A split-architecture desktop app: Rust backend for system integration and timer
|
|||||||
│ │ (WebView) │ │ (WebView) │ │ (WebView) │ │
|
│ │ (WebView) │ │ (WebView) │ │ (WebView) │ │
|
||||||
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
|
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
|
||||||
│ │ │ │ │
|
│ │ │ │ │
|
||||||
│ └─────────┬───────┴──────────────────┘ │
|
│ └─────────┬────────┴──────────────────┘ │
|
||||||
│ │ Tauri IPC │
|
│ │ Tauri IPC │
|
||||||
│ ┌─────────┴─────────┐ │
|
│ ┌─────────┴─────────┐ │
|
||||||
│ │ Rust Backend │ │
|
│ │ Rust Backend │ │
|
||||||
@@ -393,10 +505,10 @@ A split-architecture desktop app: Rust backend for system integration and timer
|
|||||||
|
|
||||||
| Module | Responsibility |
|
| Module | Responsibility |
|
||||||
|:-------|:---------------|
|
|:-------|:---------------|
|
||||||
| `lib.rs` | Tauri app builder, command registration, tray setup, timer tick thread, window management, global shortcuts |
|
| `lib.rs` | Tauri app builder, command registration, tray setup, timer tick thread, window management, global shortcuts, screen dim events, microbreak events, presentation mode detection |
|
||||||
| `config.rs` | Config struct with serde serialization, validation (clamping all values to safe ranges), portable file I/O |
|
| `config.rs` | Config struct (75 fields) with serde serialization, validation (clamping all values to safe ranges), portable file I/O |
|
||||||
| `timer.rs` | Timer state machine (`Running` / `Paused` / `BreakActive`), idle detection via Windows API, working hours enforcement |
|
| `timer.rs` | Timer state machine (`Running` / `Paused` / `BreakActive`), idle detection via Windows API, working hours enforcement, pomodoro cycle tracking, microbreak scheduling, presentation mode deferral |
|
||||||
| `stats.rs` | Daily break statistics, streak calculation, history queries |
|
| `stats.rs` | Daily break statistics, streak calculation, daily goal tracking, history queries, weekly summaries |
|
||||||
| `main.rs` | Entry point, WebView2 Runtime detection |
|
| `main.rs` | Entry point, WebView2 Runtime detection |
|
||||||
| `msvc_compat.rs` | MSVC CRT compatibility stubs for static WebView2Loader linking on MinGW |
|
| `msvc_compat.rs` | MSVC CRT compatibility stubs for static WebView2Loader linking on MinGW |
|
||||||
|
|
||||||
@@ -409,20 +521,21 @@ A split-architecture desktop app: Rust backend for system integration and timer
|
|||||||
|:------|:------|
|
|:------|:------|
|
||||||
| **Views** | `Dashboard`, `BreakScreen`, `Settings`, `StatsView` |
|
| **Views** | `Dashboard`, `BreakScreen`, `Settings`, `StatsView` |
|
||||||
| **Windows** | `BreakWindow` (standalone break modal), `MiniTimer` (floating mini mode) |
|
| **Windows** | `BreakWindow` (standalone break modal), `MiniTimer` (floating mini mode) |
|
||||||
| **Components** | `TimerRing`, `Titlebar`, `ToggleSwitch`, `Stepper`, `ColorPicker`, `FontSelector`, `TimeSpinner`, `BackgroundBlobs` |
|
| **Overlays** | `BreakOverlay` (break enforcement), `MicrobreakOverlay` (eye break), `DimOverlay` (screen dimming), `Celebration` (confetti) |
|
||||||
|
| **Components** | `TimerRing`, `Titlebar`, `ToggleSwitch`, `Stepper`, `ColorPicker`, `FontSelector`, `TimeSpinner`, `BackgroundBlobs`, `BreathingGuide`, `ActivityManager` |
|
||||||
| **Stores** | `timer.ts` (reactive timer state from IPC events), `config.ts` (config with debounced auto-save) |
|
| **Stores** | `timer.ts` (reactive timer state from IPC events), `config.ts` (config with debounced auto-save) |
|
||||||
| **Utilities** | `sounds.ts` (Web Audio synthesis), `activities.ts` (72 break activities), `animate.ts` (motion library) |
|
| **Utilities** | `sounds.ts` (Web Audio synthesis), `activities.ts` (71 break activities), `animate.ts` (motion library: fadeIn, scaleIn, inView, pressable, glowHover, dragScroll) |
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><strong>IPC contract</strong></summary>
|
<summary><strong>IPC contract</strong></summary>
|
||||||
|
|
||||||
**Commands** (frontend → backend):
|
**Commands** (frontend -> backend):
|
||||||
`get_config` · `save_config` · `update_pending_config` · `reset_config` · `toggle_timer` · `start_break_now` · `cancel_break` · `snooze` · `get_timer_state` · `set_view` · `get_stats` · `get_daily_history` · `get_cursor_position` · `save_window_position`
|
`get_config` · `save_config` · `update_pending_config` · `reset_config` · `toggle_timer` · `start_break_now` · `cancel_break` · `snooze` · `get_timer_state` · `set_view` · `get_stats` · `get_daily_history` · `get_weekly_summary` · `set_auto_start` · `get_auto_start_status` · `get_cursor_position` · `save_window_position`
|
||||||
|
|
||||||
**Events** (backend → frontend):
|
**Events** (backend -> frontend):
|
||||||
`timer-tick` · `break-started` · `break-ended` · `prebreak-warning` · `config-changed` · `natural-break-detected`
|
`timer-tick` · `break-started` · `break-ended` · `prebreak-warning` · `config-changed` · `natural-break-detected` · `screen-dim-update` · `microbreak-started` · `microbreak-ended` · `milestone-reached` · `daily-goal-met` · `break-deferred`
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -435,47 +548,161 @@ A split-architecture desktop app: Rust backend for system integration and timer
|
|||||||
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.
|
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.
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><strong>Full configuration schema (35 keys)</strong></summary>
|
<summary><strong>Full configuration schema (71 keys)</strong></summary>
|
||||||
|
|
||||||
<br />
|
<br />
|
||||||
|
|
||||||
| Key | Type | Default | Range | Description |
|
**Timer**
|
||||||
|:----|:-----|:--------|:------|:------------|
|
|
||||||
| `break_duration` | `u32` | `5` | 1-60 min | Duration of each break |
|
| Key | Type | Default | Description |
|
||||||
| `break_frequency` | `u32` | `25` | 5-120 min | Interval between breaks |
|
|:----|:-----|:--------|:------------|
|
||||||
| `auto_start` | `bool` | `true` | - | Start timer on launch |
|
| `break_duration` | `u32` | `5` | Duration of each break (1-60 min) |
|
||||||
| `break_title` | `string` | `"Rest your eyes"` | max 100 chars | Title shown on break screen |
|
| `break_frequency` | `u32` | `25` | Interval between breaks (5-120 min) |
|
||||||
| `break_message` | `string` | `"Look away..."` | max 500 chars | Message shown during breaks |
|
| `auto_start` | `bool` | `true` | Start timer on launch |
|
||||||
| `fullscreen_mode` | `bool` | `true` | - | Use fullscreen break window |
|
|
||||||
| `strict_mode` | `bool` | `false` | - | Remove skip/cancel buttons |
|
**Pomodoro**
|
||||||
| `allow_end_early` | `bool` | `true` | - | Allow ending break after 50% |
|
|
||||||
| `immediately_start_breaks` | `bool` | `false` | - | Skip pre-break notification |
|
| Key | Type | Default | Description |
|
||||||
| `working_hours_enabled` | `bool` | `false` | - | Restrict timer to schedule |
|
|:----|:-----|:--------|:------------|
|
||||||
| `working_hours_schedule` | `array` | Mon-Fri 09-18 | 7 days | Per-day time ranges |
|
| `pomodoro_enabled` | `bool` | `false` | Enable Pomodoro mode |
|
||||||
| `dark_mode` | `bool` | `true` | - | Dark theme |
|
| `pomodoro_short_breaks` | `u32` | `3` | Short breaks before long (1-10) |
|
||||||
| `color_scheme` | `string` | `"Ocean"` | 5 presets | Color scheme name |
|
| `pomodoro_long_break_duration` | `u32` | `15` | Long break duration (5-60 min) |
|
||||||
| `backdrop_opacity` | `f32` | `0.92` | 0.5-1.0 | Break screen opacity |
|
| `pomodoro_long_break_title` | `string` | `"Long break"` | Long break title |
|
||||||
| `notification_enabled` | `bool` | `true` | - | Enable toast notifications |
|
| `pomodoro_long_break_message` | `string` | `"Great work!..."` | Long break message |
|
||||||
| `notification_before_break` | `u32` | `30` | 0-300 sec | Pre-break warning time |
|
| `pomodoro_reset_on_skip` | `bool` | `false` | Reset cycle when skipping |
|
||||||
| `snooze_duration` | `u32` | `5` | 1-30 min | Snooze delay |
|
|
||||||
| `snooze_limit` | `u32` | `3` | 0-5 | Max snoozes per cycle |
|
**Microbreaks**
|
||||||
| `skip_cooldown` | `u32` | `60` | 0-600 sec | Cooldown between skips |
|
|
||||||
| `sound_enabled` | `bool` | `true` | - | Play notification sounds |
|
| Key | Type | Default | Description |
|
||||||
| `sound_volume` | `u32` | `70` | 0-100 | Volume percentage |
|
|:----|:-----|:--------|:------------|
|
||||||
| `sound_preset` | `string` | `"bell"` | 8 presets | Sound preset name |
|
| `microbreak_enabled` | `bool` | `false` | Enable 20-20-20 eye breaks |
|
||||||
| `idle_detection_enabled` | `bool` | `true` | - | Enable idle auto-pause |
|
| `microbreak_frequency` | `u32` | `20` | Microbreak interval (5-60 min) |
|
||||||
| `idle_timeout` | `u32` | `120` | 30-600 sec | Idle threshold |
|
| `microbreak_duration` | `u32` | `20` | Microbreak duration (10-60 sec) |
|
||||||
| `smart_breaks_enabled` | `bool` | `true` | - | Detect natural breaks |
|
| `microbreak_sound_enabled` | `bool` | `true` | Play sound on microbreak |
|
||||||
| `smart_break_threshold` | `u32` | `300` | 120-900 sec | Natural break threshold |
|
| `microbreak_show_activity` | `bool` | `true` | Show activity during microbreak |
|
||||||
| `smart_break_count_stats` | `bool` | `false` | - | Count natural breaks in stats |
|
| `microbreak_pause_during_break` | `bool` | `true` | No microbreaks during main breaks |
|
||||||
| `show_break_activities` | `bool` | `true` | - | Show activity suggestions |
|
|
||||||
| `ui_zoom` | `u32` | `100` | 50-200% | Interface zoom level |
|
**Break Screen**
|
||||||
| `accent_color` | `string` | `"#ff4d00"` | hex | Main accent color |
|
|
||||||
| `break_color` | `string` | `"#7c6aef"` | hex | Break screen ring color |
|
| Key | Type | Default | Description |
|
||||||
| `countdown_font` | `string` | `""` | font family | Google Font for countdown |
|
|:----|:-----|:--------|:------------|
|
||||||
| `background_blobs_enabled` | `bool` | `false` | - | Animated background blobs |
|
| `break_title` | `string` | `"Rest your eyes"` | Title shown on break screen |
|
||||||
| `mini_click_through` | `bool` | `true` | - | Mini mode click-through |
|
| `break_message` | `string` | `"Look away..."` | Message shown during breaks |
|
||||||
| `mini_hover_threshold` | `f32` | `3.0` | 1.0-10.0 sec | Hover delay before drag |
|
| `fullscreen_mode` | `bool` | `true` | Use fullscreen break window |
|
||||||
|
| `multi_monitor_break` | `bool` | `true` | Show overlay on all monitors |
|
||||||
|
| `show_break_activities` | `bool` | `true` | Show activity suggestions |
|
||||||
|
|
||||||
|
**Activities**
|
||||||
|
|
||||||
|
| Key | Type | Default | Description |
|
||||||
|
|:----|:-----|:--------|:------------|
|
||||||
|
| `custom_activities` | `array` | `[]` | User-created activities |
|
||||||
|
| `disabled_builtin_activities` | `array` | `[]` | Disabled built-in activities |
|
||||||
|
| `favorite_builtin_activities` | `array` | `[]` | Favorited built-in activities |
|
||||||
|
| `favorite_weight` | `u32` | `3` | How much more often favorites appear |
|
||||||
|
|
||||||
|
**Breathing Guide**
|
||||||
|
|
||||||
|
| Key | Type | Default | Description |
|
||||||
|
|:----|:-----|:--------|:------------|
|
||||||
|
| `breathing_guide_enabled` | `bool` | `true` | Show breathing guide during breaks |
|
||||||
|
| `breathing_pattern` | `string` | `"box"` | Breathing pattern (box/relaxing/energizing/calm/deep) |
|
||||||
|
|
||||||
|
**Behavior**
|
||||||
|
|
||||||
|
| Key | Type | Default | Description |
|
||||||
|
|:----|:-----|:--------|:------------|
|
||||||
|
| `strict_mode` | `bool` | `false` | Remove skip/cancel buttons |
|
||||||
|
| `allow_end_early` | `bool` | `true` | Allow ending break after 50% |
|
||||||
|
| `immediately_start_breaks` | `bool` | `false` | Skip pre-break notification |
|
||||||
|
| `snooze_duration` | `u32` | `5` | Snooze delay (1-30 min) |
|
||||||
|
| `snooze_limit` | `u32` | `3` | Max snoozes per cycle (0-5) |
|
||||||
|
|
||||||
|
**Alerts**
|
||||||
|
|
||||||
|
| Key | Type | Default | Description |
|
||||||
|
|:----|:-----|:--------|:------------|
|
||||||
|
| `notification_enabled` | `bool` | `true` | Enable toast notifications |
|
||||||
|
| `notification_before_break` | `u32` | `30` | Pre-break warning (0-300 sec) |
|
||||||
|
| `screen_dim_enabled` | `bool` | `false` | Gradually dim screen before breaks |
|
||||||
|
| `screen_dim_seconds` | `u32` | `10` | Start dimming N seconds before break |
|
||||||
|
| `screen_dim_max_opacity` | `f32` | `0.3` | Maximum dim intensity (10-70%) |
|
||||||
|
|
||||||
|
**Sound**
|
||||||
|
|
||||||
|
| Key | Type | Default | Description |
|
||||||
|
|:----|:-----|:--------|:------------|
|
||||||
|
| `sound_enabled` | `bool` | `true` | Play notification sounds |
|
||||||
|
| `sound_volume` | `u32` | `70` | Volume (0-100%) |
|
||||||
|
| `sound_preset` | `string` | `"bell"` | Sound preset |
|
||||||
|
|
||||||
|
**Idle & Smart Breaks**
|
||||||
|
|
||||||
|
| Key | Type | Default | Description |
|
||||||
|
|:----|:-----|:--------|:------------|
|
||||||
|
| `idle_detection_enabled` | `bool` | `true` | Enable idle auto-pause |
|
||||||
|
| `idle_timeout` | `u32` | `120` | Idle threshold (30-600 sec) |
|
||||||
|
| `smart_breaks_enabled` | `bool` | `true` | Detect natural breaks |
|
||||||
|
| `smart_break_threshold` | `u32` | `300` | Natural break threshold (120-900 sec) |
|
||||||
|
| `smart_break_count_stats` | `bool` | `false` | Count natural breaks in stats |
|
||||||
|
|
||||||
|
**Presentation Mode**
|
||||||
|
|
||||||
|
| Key | Type | Default | Description |
|
||||||
|
|:----|:-----|:--------|:------------|
|
||||||
|
| `presentation_mode_enabled` | `bool` | `true` | Defer breaks during fullscreen apps |
|
||||||
|
| `presentation_mode_defer_microbreaks` | `bool` | `true` | Also defer microbreaks |
|
||||||
|
| `presentation_mode_notification` | `bool` | `true` | Show toast when break deferred |
|
||||||
|
|
||||||
|
**Goals & Streaks**
|
||||||
|
|
||||||
|
| Key | Type | Default | Description |
|
||||||
|
|:----|:-----|:--------|:------------|
|
||||||
|
| `daily_goal_enabled` | `bool` | `true` | Track daily break target |
|
||||||
|
| `daily_goal_breaks` | `u32` | `8` | Target breaks per day (1-30) |
|
||||||
|
| `milestone_celebrations` | `bool` | `true` | Confetti on milestones |
|
||||||
|
| `streak_notifications` | `bool` | `true` | Toast on streak milestones |
|
||||||
|
|
||||||
|
**Appearance**
|
||||||
|
|
||||||
|
| Key | Type | Default | Description |
|
||||||
|
|:----|:-----|:--------|:------------|
|
||||||
|
| `ui_zoom` | `u32` | `100` | Interface zoom (50-200%) |
|
||||||
|
| `accent_color` | `string` | `"#ff4d00"` | Main accent color |
|
||||||
|
| `break_color` | `string` | `"#7c6aef"` | Break screen ring color |
|
||||||
|
| `countdown_font` | `string` | `""` | Google Font for countdown |
|
||||||
|
| `background_blobs_enabled` | `bool` | `false` | Animated background blobs |
|
||||||
|
|
||||||
|
**Working Hours**
|
||||||
|
|
||||||
|
| Key | Type | Default | Description |
|
||||||
|
|:----|:-----|:--------|:------------|
|
||||||
|
| `working_hours_enabled` | `bool` | `false` | Restrict timer to schedule |
|
||||||
|
| `working_hours_schedule` | `array` | Mon-Fri 09-18 | Per-day time ranges |
|
||||||
|
|
||||||
|
**Mini Mode**
|
||||||
|
|
||||||
|
| Key | Type | Default | Description |
|
||||||
|
|:----|:-----|:--------|:------------|
|
||||||
|
| `mini_click_through` | `bool` | `true` | Mini mode click-through |
|
||||||
|
| `mini_hover_threshold` | `f32` | `3.0` | Hover delay before drag (1-10 sec) |
|
||||||
|
|
||||||
|
**General**
|
||||||
|
|
||||||
|
| Key | Type | Default | Description |
|
||||||
|
|:----|:-----|:--------|:------------|
|
||||||
|
| `auto_start_on_login` | `bool` | `false` | Launch on Windows startup |
|
||||||
|
|
||||||
|
**Window Position (internal)**
|
||||||
|
|
||||||
|
| Key | Type | Default | Description |
|
||||||
|
|:----|:-----|:--------|:------------|
|
||||||
|
| `main_window_x` | `i32?` | `null` | Main window X position |
|
||||||
|
| `main_window_y` | `i32?` | `null` | Main window Y position |
|
||||||
|
| `main_window_width` | `u32?` | `null` | Main window width |
|
||||||
|
| `main_window_height` | `u32?` | `null` | Main window height |
|
||||||
|
| `mini_window_x` | `i32?` | `null` | Mini window X position |
|
||||||
|
| `mini_window_y` | `i32?` | `null` | Mini window Y position |
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -527,12 +754,12 @@ No contribution agreements to sign. No corporate CLAs. No licensing traps. Every
|
|||||||
|
|
||||||
**Some ways to help:**
|
**Some ways to help:**
|
||||||
|
|
||||||
- 🐛 Report bugs or rough edges
|
- Report bugs or rough edges
|
||||||
- 🧘 Suggest new break activities (especially with physiotherapy or ergonomics knowledge)
|
- Suggest new break activities (especially with physiotherapy or ergonomics knowledge)
|
||||||
- ♿ Improve accessibility (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
|
- Port idle detection to macOS/Linux
|
||||||
- 🌍 Translate the interface
|
- Translate the interface
|
||||||
- 💌 Share it with someone who needs it
|
- Share it with someone who needs it
|
||||||
|
|
||||||
The best software is built through mutual aid - people helping people because it's the right thing to do, not because there's a profit motive attached.
|
The best software is built through mutual aid - people helping people because it's the right thing to do, not because there's a profit motive attached.
|
||||||
|
|
||||||
@@ -540,7 +767,7 @@ The best software is built through mutual aid - people helping people because it
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📄 License
|
## 📜 License
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://creativecommons.org/publicdomain/zero/1.0/">
|
<a href="https://creativecommons.org/publicdomain/zero/1.0/">
|
||||||
@@ -564,7 +791,7 @@ See [`LICENSE`](LICENSE) for the full legal text.
|
|||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<sub>
|
<sub>
|
||||||
Built with care. Shared without conditions. 🧊<br />
|
Built with care. Shared without conditions.<br />
|
||||||
<em>Rest well.</em>
|
<em>Rest well.</em>
|
||||||
</sub>
|
</sub>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
218
docs/plans/2026-02-18-wcag-aaa-design.md
Normal file
218
docs/plans/2026-02-18-wcag-aaa-design.md
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
# WCAG 2.2 AAA Compliance Design
|
||||||
|
|
||||||
|
**Date:** 2026-02-18
|
||||||
|
**Scope:** `break-timer/` (frontend + CSS only, no Rust backend changes)
|
||||||
|
**Goal:** Achieve WCAG 2.2 AAA conformance while preserving the existing dark-theme visual identity.
|
||||||
|
|
||||||
|
## Audit Summary
|
||||||
|
|
||||||
|
42 issues found (8 Critical, 14 Major, 20 Minor) across 28 source files. The app already has solid AA foundations: focus indicators, reduced motion support, forced colors mode, ARIA roles on custom widgets, screen-reader text, and keyboard support for complex components.
|
||||||
|
|
||||||
|
## Design Decisions
|
||||||
|
|
||||||
|
1. **Secondary text color:** `#8a8a8a` -> `#a8a8a8` (7.28:1 ratio, minimal visual change)
|
||||||
|
2. **Target sizes:** Enlarge controls to 44px (visual + padding), including 20px traffic lights, 28px swatches, 36px steppers
|
||||||
|
3. **Auto-dismiss toasts:** Persist until dismissed when user hovers/focuses; auto-fade only if untouched
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section 1: Color & Contrast System
|
||||||
|
|
||||||
|
### Theme Token Changes (`app.css`)
|
||||||
|
|
||||||
|
| Token | Current | New | Ratio on #000 |
|
||||||
|
|-------|---------|-----|---------------|
|
||||||
|
| `--color-text-sec` | `#8a8a8a` | `#a8a8a8` | 7.28:1 |
|
||||||
|
| `--color-text-dim` | `#3a3a3a` | `#5c5c5c` | 3.5:1 (decorative) |
|
||||||
|
| `--color-border` | `#222222` | `#3a3a3a` | 2.63:1 (non-text) |
|
||||||
|
| New: `--color-input-border` | — | `#444444` | 3.14:1 |
|
||||||
|
| New: `--color-surface-lt` | — | `#1e1e1e` | 1.28:1 (bg-to-bg) |
|
||||||
|
|
||||||
|
### Hardcoded Color Replacements
|
||||||
|
|
||||||
|
- All `text-[#8a8a8a]` -> `text-text-sec` (Tailwind theme token)
|
||||||
|
- All `border-[#222]` -> `border-border`
|
||||||
|
- All `border-[#161616]` -> `border-[#333]` (card dividers: 3:1)
|
||||||
|
- All `bg-[#141414]` (stepper bg) -> `bg-[#1a1a1a]` with `border border-[#3a3a3a]`
|
||||||
|
- Toggle OFF knob: `#444` -> `#666`
|
||||||
|
- Mini paused text: `#555` -> `#a8a8a8`
|
||||||
|
- Placeholder text: `#2a2a2a` -> `#555` (3.37:1)
|
||||||
|
- Danger color: `#f85149` -> `#ff6b6b` (7.41:1)
|
||||||
|
|
||||||
|
### Focus Indicator Safety
|
||||||
|
|
||||||
|
When accent color is too dark, add white outer shadow fallback:
|
||||||
|
|
||||||
|
```css
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid var(--color-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
box-shadow: 0 0 0 4px rgba(255,255,255,0.3);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section 2: Target Size Enlargement
|
||||||
|
|
||||||
|
| Component | Current | New Visual | Hit Area |
|
||||||
|
|-----------|---------|-----------|----------|
|
||||||
|
| Titlebar traffic lights | 15x15px | 20x20px | 44x44px (padding) |
|
||||||
|
| Color swatches | 22x22px | 28x28px | 44px spacing |
|
||||||
|
| Stepper +/- buttons | 28x28px | 36x36px | 44x44px (padding) |
|
||||||
|
| Toggle switch | 48x24px | 52x28px | 52x44px (padding) |
|
||||||
|
| Back button | 32x32px | 40x40px | 44x44px |
|
||||||
|
| Stats tab buttons | ~60x30px | ~60x40px | 44px height |
|
||||||
|
| Activity star/remove | 32x32px | 36x36px | 44x44px (padding) |
|
||||||
|
| Time range buttons | 32x32px | 36x36px | 44x44px (padding) |
|
||||||
|
|
||||||
|
Strategy: Use `min-h-[44px] min-w-[44px]` on interactive elements with padding for visual sizing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section 3: Heading Hierarchy & Document Structure
|
||||||
|
|
||||||
|
### Heading Fixes
|
||||||
|
|
||||||
|
- Each view keeps `<h1 class="sr-only" tabindex="-1">`
|
||||||
|
- Settings/Stats: Change all `<h3>` to `<h2>`
|
||||||
|
- Working Hours section: Add missing heading
|
||||||
|
- BreakScreen `<h2>` stays correct
|
||||||
|
|
||||||
|
### Landmark Regions
|
||||||
|
|
||||||
|
- Titlebar: Wrap in `<header role="banner">`
|
||||||
|
- Dashboard bottom buttons: Wrap in `<nav aria-label="Main actions">`
|
||||||
|
- Stats tab bar: `<nav>` with `role="tablist"` / `role="tab"` / `role="tabpanel"`
|
||||||
|
- All sections: Add `aria-labelledby` pointing to heading `id`
|
||||||
|
|
||||||
|
### Document Title
|
||||||
|
|
||||||
|
Add `$effect` in `App.svelte` to set `document.title = "Core Cooldown - ${viewName}"` on view change.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section 4: Keyboard & Focus Fixes
|
||||||
|
|
||||||
|
### C5: Strict Mode Keyboard Trap
|
||||||
|
|
||||||
|
When `strict_mode` is true and buttons are hidden, render a visually hidden focusable `<span tabindex="0">` with sr-only text "Break in progress, please wait" so focus trap always has one element.
|
||||||
|
|
||||||
|
### Stepper Keyboard Hold-to-Repeat
|
||||||
|
|
||||||
|
Add `onkeydown` handler that starts repeat on held ArrowUp/ArrowDown/Enter/Space.
|
||||||
|
|
||||||
|
### `pressable` Keyboard Feedback
|
||||||
|
|
||||||
|
Add `keydown`/`keyup` listeners for Enter/Space to trigger same scale animation.
|
||||||
|
|
||||||
|
### `glowHover` Keyboard Focus
|
||||||
|
|
||||||
|
Add `focusin`/`focusout` listeners alongside `mouseenter`/`mouseleave`.
|
||||||
|
|
||||||
|
### Missing ARIA States
|
||||||
|
|
||||||
|
- Sound presets: `aria-pressed={$config.sound_preset === preset}`
|
||||||
|
- Breathing patterns: `role="radiogroup"` wrapper, `role="radio"` + `aria-checked` on buttons
|
||||||
|
- Color swatches: `aria-pressed={value === color}`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section 5: Timing & Notification Control
|
||||||
|
|
||||||
|
### Toast Persistence
|
||||||
|
|
||||||
|
All auto-dismissing elements (natural break toast, celebration overlay, goal badge):
|
||||||
|
|
||||||
|
1. Track `hovering` via `mouseenter/leave` + `focusin/focusout`
|
||||||
|
2. Auto-dismiss timer pauses while hovering
|
||||||
|
3. Close button appears (sr-only "Dismiss notification")
|
||||||
|
4. Escape key dismisses
|
||||||
|
5. `role="alert"` + `aria-live="assertive"` kept
|
||||||
|
|
||||||
|
### Breathing Guide Noise Fix
|
||||||
|
|
||||||
|
Change breathing `aria-live` span to only announce phase changes (Inhale/Hold/Exhale), not countdown ticks. Track `lastAnnouncedPhase` and update live text only on phase name change.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section 6: ARIA Patterns & Semantic Enrichment
|
||||||
|
|
||||||
|
### Section Accessible Names
|
||||||
|
|
||||||
|
Add `id` to each heading, `aria-labelledby` on parent `<section>`.
|
||||||
|
|
||||||
|
### Stats Tab Pattern
|
||||||
|
|
||||||
|
- `role="tablist"` wrapper
|
||||||
|
- `role="tab"` + `aria-selected` + `aria-controls` on each tab
|
||||||
|
- `role="tabpanel"` + `id` + `aria-labelledby` on panels
|
||||||
|
|
||||||
|
### Missing Data Tables
|
||||||
|
|
||||||
|
Add sr-only `<table>` for 30-day chart and heatmap (matching existing 7-day pattern).
|
||||||
|
|
||||||
|
### Additional ARIA
|
||||||
|
|
||||||
|
- Daily goal bar: `role="progressbar"` + aria-value attributes
|
||||||
|
- BreakOverlay: `role="alertdialog"` + `aria-label` + `<h2>`
|
||||||
|
- MicrobreakOverlay: `role="alertdialog"` + `aria-label` + heading
|
||||||
|
- Reset button: `aria-live="polite"` for confirmation state
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section 7: Visual Presentation (1.4.8)
|
||||||
|
|
||||||
|
### Line Spacing
|
||||||
|
|
||||||
|
Default `leading-relaxed` (1.625) on body. Override with `leading-none` only on countdown numerics.
|
||||||
|
|
||||||
|
### Text Selection
|
||||||
|
|
||||||
|
Remove global `user-select: none`. Keep only on drag regions and decorative elements.
|
||||||
|
|
||||||
|
### Skip Navigation
|
||||||
|
|
||||||
|
Add skip link as first child of `<main>`: `<a href="#main-content" class="sr-only focus:not-sr-only">Skip to content</a>`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section 8: Supplementary
|
||||||
|
|
||||||
|
### Abbreviations
|
||||||
|
|
||||||
|
Add `<abbr>` tags for standalone "s", "min", "h", "m" units. Add `title` on first use of Pomodoro, 20-20-20 in Settings.
|
||||||
|
|
||||||
|
### Help Tooltips
|
||||||
|
|
||||||
|
Add `title` attributes on complex settings (Pomodoro, Smart breaks, Microbreaks, Breathing).
|
||||||
|
|
||||||
|
### Known Limitations
|
||||||
|
|
||||||
|
- Canvas chart text spacing: Not fixable (Canvas API limitation). Sr-only data tables provide equivalent access.
|
||||||
|
- Timer interruptions: Pause button effectively suppresses all breaks. No separate DND mode needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files Affected
|
||||||
|
|
||||||
|
| File | Change Scope |
|
||||||
|
|------|-------------|
|
||||||
|
| `app.css` | Tokens, line-height, user-select, skip link, focus ring |
|
||||||
|
| `App.svelte` | Skip link, document title |
|
||||||
|
| `Titlebar.svelte` | Larger traffic lights, header landmark |
|
||||||
|
| `Dashboard.svelte` | Contrast, nav landmark, toast persistence, pomodoro alt text, goal progressbar |
|
||||||
|
| `BreakScreen.svelte` | Contrast, strict-mode focus, breathing aria-live fix |
|
||||||
|
| `Settings.svelte` | h2 headings, section labels, contrast, sizes, radio groups, abbr, title, reset aria-live |
|
||||||
|
| `StatsView.svelte` | h2 headings, tablist, data tables, contrast |
|
||||||
|
| `ToggleSwitch.svelte` | Larger (52x28), knob contrast |
|
||||||
|
| `Stepper.svelte` | Larger (36px), keyboard hold-repeat, contrast |
|
||||||
|
| `ColorPicker.svelte` | Larger swatches, aria-pressed, contrast |
|
||||||
|
| `FontSelector.svelte` | Contrast |
|
||||||
|
| `TimeSpinner.svelte` | Contrast |
|
||||||
|
| `ActivityManager.svelte` | Larger buttons, contrast, scroll fix |
|
||||||
|
| `MiniTimer.svelte` | Contrast |
|
||||||
|
| `MicrobreakOverlay.svelte` | Heading, role, label |
|
||||||
|
| `BreakOverlay.svelte` | Heading, role, label |
|
||||||
|
| `Celebration.svelte` | Toast persistence |
|
||||||
|
| `animate.ts` | pressable keyboard, glowHover focus |
|
||||||
967
docs/plans/2026-02-18-wcag-aaa-implementation.md
Normal file
967
docs/plans/2026-02-18-wcag-aaa-implementation.md
Normal file
@@ -0,0 +1,967 @@
|
|||||||
|
# WCAG 2.2 AAA Implementation Plan
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Achieve WCAG 2.2 AAA conformance across all frontend components while preserving the existing dark-theme visual identity.
|
||||||
|
|
||||||
|
**Architecture:** All changes are CSS + Svelte template only — no Rust backend changes. Theme tokens in `app.css` propagate through Tailwind's `@theme` system. Components use Svelte 5 runes (`$state`, `$derived`, `$effect`, `$props`). Accessibility patterns follow WAI-ARIA 1.2 (tablist, radiogroup, alertdialog, progressbar).
|
||||||
|
|
||||||
|
**Tech Stack:** Svelte 5, Tailwind CSS v4 (`@theme` tokens in CSS, no config file), TypeScript, Web Animations API (`motion` library)
|
||||||
|
|
||||||
|
**Note:** This project has no frontend test suite. Verification is done via `npm run build` (Vite build) and manual inspection. Each task ends with a build check and a commit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Theme Tokens & Global Styles (`app.css`)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `break-timer/src/app.css` (entire file)
|
||||||
|
|
||||||
|
This is the foundation — all subsequent tasks depend on these token changes.
|
||||||
|
|
||||||
|
**Step 1: Update theme tokens**
|
||||||
|
|
||||||
|
In `app.css`, inside the `@theme { }` block (lines 3–20):
|
||||||
|
|
||||||
|
- Change `--color-text-sec: #8a8a8a` → `--color-text-sec: #a8a8a8` (7.28:1 on black)
|
||||||
|
- Change `--color-text-dim: #3a3a3a` → `--color-text-dim: #5c5c5c` (3.5:1 decorative)
|
||||||
|
- Change `--color-border: #222222` → `--color-border: #3a3a3a` (2.63:1 non-text)
|
||||||
|
- Change `--color-danger: #f85149` → `--color-danger: #ff6b6b` (7.41:1)
|
||||||
|
- Add new token: `--color-input-border: #444444;`
|
||||||
|
- Add new token: `--color-surface-lt: #1e1e1e;`
|
||||||
|
|
||||||
|
**Step 2: Update body styles**
|
||||||
|
|
||||||
|
In the `html, body` block (lines 22–35):
|
||||||
|
|
||||||
|
- Change `user-select: none` to only apply on drag regions:
|
||||||
|
- REMOVE `user-select: none;` and `-webkit-user-select: none;` from body
|
||||||
|
- ADD a new rule for drag regions only:
|
||||||
|
```css
|
||||||
|
[data-tauri-drag-region],
|
||||||
|
[data-tauri-drag-region] * {
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- Add `line-height: 1.625;` (leading-relaxed) to body for AAA 1.4.8
|
||||||
|
|
||||||
|
**Step 3: Enhance focus indicators**
|
||||||
|
|
||||||
|
Replace the `:focus-visible` block (lines 73–76) with:
|
||||||
|
|
||||||
|
```css
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid var(--color-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `box-shadow` provides a white fallback when the accent color has low contrast against dark backgrounds.
|
||||||
|
|
||||||
|
**Step 4: Add skip link styles**
|
||||||
|
|
||||||
|
After the `.sr-only` block, add:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 5: Verify build**
|
||||||
|
|
||||||
|
Run: `cd break-timer && npm run build`
|
||||||
|
Expected: Build succeeds with no errors.
|
||||||
|
|
||||||
|
**Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add break-timer/src/app.css
|
||||||
|
git commit -m "a11y: update theme tokens and global styles for WCAG AAA
|
||||||
|
|
||||||
|
- Bump --color-text-sec to #a8a8a8 (7.28:1 contrast)
|
||||||
|
- Bump --color-text-dim to #5c5c5c (3.5:1 decorative)
|
||||||
|
- Bump --color-border to #3a3a3a (2.63:1 non-text)
|
||||||
|
- Bump --color-danger to #ff6b6b (7.41:1)
|
||||||
|
- Add --color-input-border and --color-surface-lt tokens
|
||||||
|
- Add white shadow fallback on :focus-visible
|
||||||
|
- Add leading-relaxed default line-height
|
||||||
|
- Scope user-select:none to drag regions only
|
||||||
|
- Add skip-link focus styles"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: App Shell (`App.svelte`)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `break-timer/src/App.svelte`
|
||||||
|
|
||||||
|
**Step 1: Add skip link**
|
||||||
|
|
||||||
|
Inside the `<main>` element (line 87), add skip link as first child and an id target:
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<main class="relative h-full bg-black">
|
||||||
|
<a href="#main-content" class="skip-link">Skip to content</a>
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
Then on the zoom container `<div>` (line 92), add `id="main-content"`:
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<div
|
||||||
|
id="main-content"
|
||||||
|
class="relative h-full overflow-hidden origin-top-left"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Add document title effect**
|
||||||
|
|
||||||
|
After the existing focus management `$effect` (after line 76), add:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 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"}`;
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Verify build**
|
||||||
|
|
||||||
|
Run: `cd break-timer && npm run build`
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add break-timer/src/App.svelte
|
||||||
|
git commit -m "a11y: add skip link and dynamic document title for WCAG AAA"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Titlebar (`Titlebar.svelte`)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `break-timer/src/lib/components/Titlebar.svelte`
|
||||||
|
|
||||||
|
**Step 1: Wrap in header landmark**
|
||||||
|
|
||||||
|
Change the outer `<div>` (line 8) to `<header role="banner">`.
|
||||||
|
|
||||||
|
**Step 2: Enlarge traffic lights to meet 44px target size**
|
||||||
|
|
||||||
|
Change each traffic light button from `h-[15px] w-[15px]` to `h-[20px] w-[20px]` visual size with `min-h-[44px] min-w-[44px]` hit area via padding. The trick: keep the visual circle at 20px but wrap in a 44px invisible tap area.
|
||||||
|
|
||||||
|
Replace each button's class. For example the Maximize button (line 27):
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<button
|
||||||
|
aria-label="Maximize"
|
||||||
|
class="traffic-btn group/btn relative flex h-[44px] w-[44px] items-center justify-center"
|
||||||
|
onclick={() => appWindow.toggleMaximize()}
|
||||||
|
>
|
||||||
|
<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 ...>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
Apply the same pattern to Minimize and Close buttons. The gap between buttons changes from `gap-[8px]` to `gap-0` since the 44px buttons provide their own spacing.
|
||||||
|
|
||||||
|
**Step 3: Verify build**
|
||||||
|
|
||||||
|
Run: `cd break-timer && npm run build`
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add break-timer/src/lib/components/Titlebar.svelte
|
||||||
|
git commit -m "a11y: add header landmark and enlarge traffic lights to 44px hit areas"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: ToggleSwitch (`ToggleSwitch.svelte`)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `break-timer/src/lib/components/ToggleSwitch.svelte`
|
||||||
|
|
||||||
|
**Step 1: Enlarge to 52x28 and fix knob contrast**
|
||||||
|
|
||||||
|
Change button dimensions from `h-[24px] w-[48px]` to `h-[28px] w-[52px]` with `min-h-[44px]` padding for hit area.
|
||||||
|
|
||||||
|
Change the knob span from `h-[19px] w-[19px]` to `h-[22px] w-[22px]`.
|
||||||
|
Change translate-x for ON state from `translate-x-[26px]` to `translate-x-[27px]`.
|
||||||
|
Change mt from `mt-[2.5px]` to `mt-[3px]`.
|
||||||
|
Change OFF knob color from `bg-[#444]` to `bg-[#666]` (better contrast).
|
||||||
|
|
||||||
|
The button should have `min-h-[44px]` via a wrapper approach or padding.
|
||||||
|
|
||||||
|
**Step 2: Verify build**
|
||||||
|
|
||||||
|
Run: `cd break-timer && npm run build`
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add break-timer/src/lib/components/ToggleSwitch.svelte
|
||||||
|
git commit -m "a11y: enlarge toggle switch and improve OFF knob contrast"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Stepper (`Stepper.svelte`)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `break-timer/src/lib/components/Stepper.svelte`
|
||||||
|
|
||||||
|
**Step 1: Enlarge buttons and fix contrast**
|
||||||
|
|
||||||
|
Change both +/- button dimensions from `h-7 w-7` (28px) to `h-9 w-9` (36px) with `min-h-[44px] min-w-[44px]` padding.
|
||||||
|
|
||||||
|
Change `bg-[#141414]` to `bg-[#1a1a1a] border border-[#3a3a3a]` for better non-text contrast.
|
||||||
|
|
||||||
|
Change `text-[#8a8a8a]` to `text-text-sec` (uses updated theme token).
|
||||||
|
|
||||||
|
**Step 2: Add keyboard hold-to-repeat**
|
||||||
|
|
||||||
|
Add `onkeydown` handlers to both buttons that trigger the same `startHold`/`stopHold` logic for Enter, Space, ArrowUp, ArrowDown keys:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function handleKeydown(fn: () => void, e: KeyboardEvent) {
|
||||||
|
if (["Enter", " ", "ArrowUp", "ArrowDown"].includes(e.key)) {
|
||||||
|
e.preventDefault();
|
||||||
|
startHold(e.key === "ArrowDown" || e.key === "ArrowUp" ? fn : fn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyup(e: KeyboardEvent) {
|
||||||
|
if (["Enter", " ", "ArrowUp", "ArrowDown"].includes(e.key)) {
|
||||||
|
stopHold();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `onkeydown` and `onkeyup` to both buttons. The decrease button uses ArrowDown for decrement, the increase button uses ArrowUp for increment.
|
||||||
|
|
||||||
|
**Step 3: Verify build**
|
||||||
|
|
||||||
|
Run: `cd break-timer && npm run build`
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add break-timer/src/lib/components/Stepper.svelte
|
||||||
|
git commit -m "a11y: enlarge stepper buttons and add keyboard hold-to-repeat"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Animation Actions (`animate.ts`)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `break-timer/src/lib/utils/animate.ts`
|
||||||
|
|
||||||
|
**Step 1: Add keyboard support to `pressable`**
|
||||||
|
|
||||||
|
In the `pressable` function (line 117), after the mousedown/mouseup/mouseleave listeners (lines 143–145), add keydown/keyup for Enter and Space:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
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("keydown", onKeyDown);
|
||||||
|
node.addEventListener("keyup", onKeyUp);
|
||||||
|
```
|
||||||
|
|
||||||
|
Update `destroy()` to remove these listeners too.
|
||||||
|
|
||||||
|
**Step 2: Add focus support to `glowHover`**
|
||||||
|
|
||||||
|
In the `glowHover` function (line 165), after mouseenter/mouseleave listeners (lines 201–202), add focusin/focusout:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
node.addEventListener("focusin", onEnter);
|
||||||
|
node.addEventListener("focusout", onLeave);
|
||||||
|
```
|
||||||
|
|
||||||
|
Update `destroy()` to remove these listeners too.
|
||||||
|
|
||||||
|
**Step 3: Verify build**
|
||||||
|
|
||||||
|
Run: `cd break-timer && npm run build`
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add break-timer/src/lib/utils/animate.ts
|
||||||
|
git commit -m "a11y: add keyboard feedback to pressable and focus glow to glowHover"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Dashboard (`Dashboard.svelte`)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `break-timer/src/lib/components/Dashboard.svelte`
|
||||||
|
|
||||||
|
**Step 1: Replace hardcoded `#8a8a8a` with theme token**
|
||||||
|
|
||||||
|
Replace all `text-[#8a8a8a]` with `text-text-sec` throughout the file. There are ~10 instances on lines 180, 208, 216, 234, 241, 256, 301, 329, 361.
|
||||||
|
|
||||||
|
Replace `border-[#222]` with `border-border` on the three bottom buttons (lines 302, 328, 360).
|
||||||
|
|
||||||
|
**Step 2: Wrap bottom buttons in nav landmark**
|
||||||
|
|
||||||
|
Around the three bottom action buttons (lines 296–382), wrap in:
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<nav aria-label="Main actions">
|
||||||
|
<!-- Bottom left: start break now -->
|
||||||
|
...
|
||||||
|
<!-- Bottom center: stats -->
|
||||||
|
...
|
||||||
|
<!-- Bottom right: settings -->
|
||||||
|
...
|
||||||
|
</nav>
|
||||||
|
```
|
||||||
|
|
||||||
|
The three `<div class="absolute bottom-5 ...">` blocks move inside the `<nav>`.
|
||||||
|
|
||||||
|
**Step 3: Make natural break toast persistent on hover**
|
||||||
|
|
||||||
|
Replace the simple timeout logic (lines 118–126) with hover-aware persistence:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
let toastHovering = $state(false);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if ($timer.naturalBreakOccurred) {
|
||||||
|
showNaturalBreakToast = true;
|
||||||
|
if (naturalBreakToastTimeout) clearTimeout(naturalBreakToastTimeout);
|
||||||
|
naturalBreakToastTimeout = setTimeout(() => {
|
||||||
|
if (!toastHovering) showNaturalBreakToast = false;
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
On the toast div (line 266), add:
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
onmouseenter={() => toastHovering = true}
|
||||||
|
onmouseleave={() => { toastHovering = false; showNaturalBreakToast = false; }}
|
||||||
|
onfocusin={() => toastHovering = true}
|
||||||
|
onfocusout={() => { toastHovering = false; showNaturalBreakToast = false; }}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add a close button inside the toast and an Escape key handler.
|
||||||
|
|
||||||
|
**Step 4: Add progressbar role to daily goal bar**
|
||||||
|
|
||||||
|
On the goal progress bar div (line 235), add ARIA:
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<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}
|
||||||
|
>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 5: Add sr-only text for pomodoro dots**
|
||||||
|
|
||||||
|
After the pomodoro dots visual `<div>` (around line 192), add:
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<span class="sr-only">
|
||||||
|
Pomodoro cycle: session {$timer.pomodoroCyclePosition + 1} of {$timer.pomodoroTotalInCycle}
|
||||||
|
</span>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 6: Verify build**
|
||||||
|
|
||||||
|
Run: `cd break-timer && npm run build`
|
||||||
|
|
||||||
|
**Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add break-timer/src/lib/components/Dashboard.svelte
|
||||||
|
git commit -m "a11y: dashboard contrast, nav landmark, toast persistence, goal progressbar"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 8: Settings — Part 1: Headings & Structure (`Settings.svelte`)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `break-timer/src/lib/components/Settings.svelte`
|
||||||
|
|
||||||
|
This is a large file (1176 lines). Split into two commits for reviewability.
|
||||||
|
|
||||||
|
**Step 1: Change all `<h3>` to `<h2>`**
|
||||||
|
|
||||||
|
Replace every `<h3 class="mb-4 text-[11px] ...` with `<h2 class="mb-4 text-[11px] ...` and corresponding `</h3>` with `</h2>`. There are 17 instances at approximate lines: 163, 218, 280, 346, 435, 444, 498, 590, 668, 732, 825, 862, 906, 976(Working Hours has no heading — add one), 1078, 1119, 1138.
|
||||||
|
|
||||||
|
**Step 2: Add `id` to headings and `aria-labelledby` to sections**
|
||||||
|
|
||||||
|
Each `<section>` gets an `aria-labelledby` pointing to its heading's `id`. Pattern:
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<section aria-labelledby="settings-timer" class="rounded-2xl p-5 ...">
|
||||||
|
<h2 id="settings-timer" class="mb-4 ...">Timer</h2>
|
||||||
|
```
|
||||||
|
|
||||||
|
Do this for all 18 sections. Use ids: `settings-timer`, `settings-pomodoro`, `settings-microbreaks`, `settings-breakscreen`, `settings-activities`, `settings-breathing`, `settings-behavior`, `settings-alerts`, `settings-sound`, `settings-idle`, `settings-presentation`, `settings-goals`, `settings-appearance`, `settings-workinghours`, `settings-minimode`, `settings-general`, `settings-shortcuts`.
|
||||||
|
|
||||||
|
**Step 3: Add missing heading for Working Hours section**
|
||||||
|
|
||||||
|
The Working Hours section (line 975) has no `<h3>`/`<h2>`. Add:
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<h2 id="settings-workinghours" class="mb-4 text-[11px] font-medium tracking-[0.15em] text-text-sec uppercase">
|
||||||
|
Working Hours
|
||||||
|
</h2>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Verify build**
|
||||||
|
|
||||||
|
Run: `cd break-timer && npm run build`
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add break-timer/src/lib/components/Settings.svelte
|
||||||
|
git commit -m "a11y: settings heading hierarchy (h3→h2) and section landmarks"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 9: Settings — Part 2: ARIA, Contrast, Sizes (`Settings.svelte`)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `break-timer/src/lib/components/Settings.svelte`
|
||||||
|
|
||||||
|
**Step 1: Breathing pattern — radiogroup/radio**
|
||||||
|
|
||||||
|
Wrap the breathing pattern buttons (line 464) in a `role="radiogroup"` container:
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<div role="radiogroup" aria-label="Breathing pattern" class="flex flex-col gap-1.5">
|
||||||
|
```
|
||||||
|
|
||||||
|
Each breathing pattern button gets `role="radio"` and `aria-checked`:
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<button
|
||||||
|
role="radio"
|
||||||
|
aria-checked={$config.breathing_pattern === bp.id}
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Sound preset — aria-pressed**
|
||||||
|
|
||||||
|
Each sound preset button (line 709) gets `aria-pressed`:
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<button
|
||||||
|
aria-pressed={$config.sound_preset === preset}
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Replace hardcoded colors**
|
||||||
|
|
||||||
|
- All `text-[#8a8a8a]` → `text-text-sec` (many instances throughout)
|
||||||
|
- All `bg-[#161616]` dividers → `bg-border` (use `--color-border` token)
|
||||||
|
- All `border-[#161616]` on inputs → `border-border`
|
||||||
|
- All `placeholder:text-[#2a2a2a]` → `placeholder:text-[#555]` (3.37:1)
|
||||||
|
- All `bg-[#141414]` → use updated stepper component (already handled in Task 5)
|
||||||
|
- Back button `h-8 w-8` → `h-10 w-10 min-h-[44px] min-w-[44px]` (line 127)
|
||||||
|
|
||||||
|
**Step 4: Reset button aria-live**
|
||||||
|
|
||||||
|
Wrap the reset button text in an `aria-live="polite"` region, or add `aria-live="polite"` to the button itself so screen readers announce the confirmation state change:
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<button
|
||||||
|
aria-live="polite"
|
||||||
|
...
|
||||||
|
>
|
||||||
|
{resetConfirming ? "Tap again to confirm reset" : "Reset to defaults"}
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 5: Add abbreviation tags**
|
||||||
|
|
||||||
|
For standalone unit abbreviations in setting descriptions, wrap with `<abbr>`:
|
||||||
|
|
||||||
|
- `"s"` (seconds) → `<abbr title="seconds">s</abbr>`
|
||||||
|
- `"min"` → `<abbr title="minutes">min</abbr>`
|
||||||
|
|
||||||
|
Apply on first occurrence in: alert timing description (line 613), idle timeout (line 755), snooze duration (line 537).
|
||||||
|
|
||||||
|
**Step 6: Add title tooltips on complex settings**
|
||||||
|
|
||||||
|
Add `title` attributes on the section-level labels for Pomodoro, Smart breaks, Microbreaks, Breathing:
|
||||||
|
|
||||||
|
- Pomodoro `<h2>`: `title="Pomodoro technique alternates focused work sessions with short and long breaks"`
|
||||||
|
- Microbreaks `<h2>`: `title="20-20-20 rule: every 20 minutes, look 20 feet away for 20 seconds"`
|
||||||
|
- Smart breaks `<div>`: `title="Automatically counts time away from computer as a break"`
|
||||||
|
- Breathing guide `<h2>`: `title="Visual breathing exercise during breaks to reduce stress"`
|
||||||
|
|
||||||
|
**Step 7: Verify build**
|
||||||
|
|
||||||
|
Run: `cd break-timer && npm run build`
|
||||||
|
|
||||||
|
**Step 8: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add break-timer/src/lib/components/Settings.svelte
|
||||||
|
git commit -m "a11y: settings ARIA patterns, contrast, abbreviations, and tooltips"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 10: StatsView — Tabs & Data Tables (`StatsView.svelte`)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `break-timer/src/lib/components/StatsView.svelte`
|
||||||
|
|
||||||
|
**Step 1: Change all `<h3>` to `<h2>`**
|
||||||
|
|
||||||
|
Replace all `<h3>` with `<h2>` and `</h3>` with `</h2>` (~12 instances).
|
||||||
|
|
||||||
|
**Step 2: Implement tablist pattern**
|
||||||
|
|
||||||
|
Replace the tab navigation (lines 297–310):
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<div role="tablist" aria-label="Statistics time range" class="flex gap-1 px-5 mb-3" use:fadeIn={{ duration: 0.3, y: 6 }}>
|
||||||
|
{#each [["today", "Today"], ["weekly", "Weekly"], ["monthly", "Monthly"]] as [tab, label]}
|
||||||
|
<button
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === tab}
|
||||||
|
aria-controls="tabpanel-{tab}"
|
||||||
|
id="tab-{tab}"
|
||||||
|
use:pressable
|
||||||
|
class="rounded-lg px-4 py-2 min-h-[44px] text-[11px] tracking-wider uppercase transition-all duration-200
|
||||||
|
{activeTab === tab
|
||||||
|
? 'bg-[#1a1a1a] text-white'
|
||||||
|
: 'text-text-sec hover:text-white'}"
|
||||||
|
onclick={() => activeTab = tab as any}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Wrap each tab's content in a `role="tabpanel"`:
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
{#if activeTab === "today"}
|
||||||
|
<div role="tabpanel" id="tabpanel-today" aria-labelledby="tab-today">
|
||||||
|
...
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Same for `weekly` and `monthly`.
|
||||||
|
|
||||||
|
**Step 3: Add sr-only data tables for 30-day chart and heatmap**
|
||||||
|
|
||||||
|
After the 30-day chart canvas (around line 533), add:
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
{#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}
|
||||||
|
```
|
||||||
|
|
||||||
|
After the heatmap canvas (around line 559), add:
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
{#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}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Replace hardcoded colors**
|
||||||
|
|
||||||
|
- All `text-[#8a8a8a]` → `text-text-sec`
|
||||||
|
- `bg-[#161616]` dividers → `bg-border`
|
||||||
|
- Back button: `h-8 w-8` → `h-10 w-10 min-h-[44px] min-w-[44px]`
|
||||||
|
|
||||||
|
**Step 5: Verify build**
|
||||||
|
|
||||||
|
Run: `cd break-timer && npm run build`
|
||||||
|
|
||||||
|
**Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add break-timer/src/lib/components/StatsView.svelte
|
||||||
|
git commit -m "a11y: stats tablist pattern, sr-only data tables, contrast updates"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 11: BreakScreen (`BreakScreen.svelte`)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `break-timer/src/lib/components/BreakScreen.svelte`
|
||||||
|
|
||||||
|
**Step 1: Add strict mode focus safety**
|
||||||
|
|
||||||
|
When `strict_mode` is true and buttons are hidden, the focus trap has zero focusable elements. After the `{#if showButtons}` block (around line 353 for in-app, line 203 for standalone), add an else block:
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
{:else}
|
||||||
|
<span tabindex="0" class="sr-only" aria-live="polite">
|
||||||
|
Break in progress, please wait
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
```
|
||||||
|
|
||||||
|
This ensures the focus trap always has at least one focusable element.
|
||||||
|
|
||||||
|
**Step 2: Fix breathing aria-live to only announce phase changes**
|
||||||
|
|
||||||
|
The breathing guide `<span aria-live="polite">` (lines 172 and 304) currently announces every countdown tick. Add a tracked variable that only updates on phase change:
|
||||||
|
|
||||||
|
In the script section, add:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
let lastAnnouncedPhase = $state("");
|
||||||
|
let breathAnnouncement = $derived(
|
||||||
|
breathPhase !== lastAnnouncedPhase
|
||||||
|
? (() => { lastAnnouncedPhase = breathPhase; return `${breathPhase}`; })()
|
||||||
|
: ""
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Actually, better approach — use a separate `$effect` and a dedicated announcement state:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
let breathAnnouncement = $state("");
|
||||||
|
$effect(() => {
|
||||||
|
// Only announce when the breathing phase name changes, not countdown ticks
|
||||||
|
breathAnnouncement = breathPhase;
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Then change the aria-live span to use a separate invisible span for SR announcements:
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<span class="sr-only" aria-live="polite" aria-atomic="true">{breathAnnouncement}</span>
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep the visible breathing text without `aria-live` (remove `aria-live="polite"` from the visible span).
|
||||||
|
|
||||||
|
**Step 3: Replace hardcoded colors**
|
||||||
|
|
||||||
|
- `text-[#8a8a8a]` → `text-text-sec`
|
||||||
|
- `border-[#222]` → `border-border`
|
||||||
|
|
||||||
|
**Step 4: Verify build**
|
||||||
|
|
||||||
|
Run: `cd break-timer && npm run build`
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add break-timer/src/lib/components/BreakScreen.svelte
|
||||||
|
git commit -m "a11y: break screen strict-mode focus safety and breathing phase announcements"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 12: Celebration Toast Persistence (`Celebration.svelte`)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `break-timer/src/lib/components/Celebration.svelte`
|
||||||
|
|
||||||
|
**Step 1: Add hover/focus-aware auto-dismiss**
|
||||||
|
|
||||||
|
Replace the CSS-driven 3.5s fade with JS-controlled timing. Add state:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
let milestoneHovering = $state(false);
|
||||||
|
let goalHovering = $state(false);
|
||||||
|
```
|
||||||
|
|
||||||
|
For the `.celebration-overlay` div, add:
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
onmouseenter={() => milestoneHovering = true}
|
||||||
|
onmouseleave={() => milestoneHovering = false}
|
||||||
|
onfocusin={() => milestoneHovering = true}
|
||||||
|
onfocusout={() => milestoneHovering = false}
|
||||||
|
```
|
||||||
|
|
||||||
|
Remove `pointer-events: none` from `.celebration-overlay` and `.goal-overlay` styles.
|
||||||
|
|
||||||
|
Add `$effect` blocks that manage visibility based on hover state — when not hovering, set a timeout to add a CSS class that triggers fade-out.
|
||||||
|
|
||||||
|
Add a close button (sr-only label "Dismiss notification") to both overlays, and an Escape key handler.
|
||||||
|
|
||||||
|
**Step 2: Verify build**
|
||||||
|
|
||||||
|
Run: `cd break-timer && npm run build`
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add break-timer/src/lib/components/Celebration.svelte
|
||||||
|
git commit -m "a11y: celebration overlays persist on hover/focus with dismiss controls"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 13: MiniTimer Contrast (`MiniTimer.svelte`)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `break-timer/src/lib/components/MiniTimer.svelte`
|
||||||
|
|
||||||
|
**Step 1: Fix paused text contrast**
|
||||||
|
|
||||||
|
Change line 338: `color: {state === 'paused' ? '#555' : '#fff'}` to `color: {state === 'paused' ? '#a8a8a8' : '#fff'}`.
|
||||||
|
|
||||||
|
Replace `#8a8a8a` with the text-sec token value `#a8a8a8` on line 344 for pomodoro indicator.
|
||||||
|
|
||||||
|
**Step 2: Verify build**
|
||||||
|
|
||||||
|
Run: `cd break-timer && npm run build`
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add break-timer/src/lib/components/MiniTimer.svelte
|
||||||
|
git commit -m "a11y: fix mini timer paused text contrast for AAA"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 14: MicrobreakOverlay (`MicrobreakOverlay.svelte`)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `break-timer/src/lib/components/MicrobreakOverlay.svelte`
|
||||||
|
|
||||||
|
**Step 1: Add alertdialog role, heading, and label**
|
||||||
|
|
||||||
|
Change the outer `.microbreak-card` div (line 41):
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<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">
|
||||||
|
...
|
||||||
|
<span id="microbreak-msg" class="text-[15px] font-medium text-white">
|
||||||
|
Look away — 20 feet for {timeRemaining}s
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace `text-[#8a8a8a]` with `text-text-sec`.
|
||||||
|
|
||||||
|
**Step 2: Verify build**
|
||||||
|
|
||||||
|
Run: `cd break-timer && npm run build`
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add break-timer/src/lib/components/MicrobreakOverlay.svelte
|
||||||
|
git commit -m "a11y: microbreak overlay gets alertdialog role and heading"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 15: BreakOverlay (`BreakOverlay.svelte`)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `break-timer/src/lib/components/BreakOverlay.svelte`
|
||||||
|
|
||||||
|
**Step 1: Add alertdialog role, heading, and label**
|
||||||
|
|
||||||
|
Change the outer div (line 39):
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<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>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Verify build**
|
||||||
|
|
||||||
|
Run: `cd break-timer && npm run build`
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add break-timer/src/lib/components/BreakOverlay.svelte
|
||||||
|
git commit -m "a11y: break overlay gets alertdialog role and heading"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 16: ColorPicker (`ColorPicker.svelte`)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `break-timer/src/lib/components/ColorPicker.svelte`
|
||||||
|
|
||||||
|
**Step 1: Enlarge swatches and add aria-pressed**
|
||||||
|
|
||||||
|
Change swatch buttons (line 247) from `h-[22px] w-[22px]` to `h-[28px] w-[28px]` with a `min-h-[44px] min-w-[44px]` clickable area (via padding or wrapper).
|
||||||
|
|
||||||
|
Add `aria-pressed={value === color}` to each swatch button.
|
||||||
|
|
||||||
|
Change the flex container gap from `gap-[6px]` to `gap-[8px]` to accommodate larger swatches.
|
||||||
|
|
||||||
|
Replace `text-[#8a8a8a]` with `text-text-sec`.
|
||||||
|
|
||||||
|
**Step 2: Verify build**
|
||||||
|
|
||||||
|
Run: `cd break-timer && npm run build`
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add break-timer/src/lib/components/ColorPicker.svelte
|
||||||
|
git commit -m "a11y: enlarge color swatches, add aria-pressed"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 17: ActivityManager Target Sizes (`ActivityManager.svelte`)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `break-timer/src/lib/components/ActivityManager.svelte`
|
||||||
|
|
||||||
|
**Step 1: Enlarge star/remove buttons**
|
||||||
|
|
||||||
|
Find all favorite (★) and remove (✕) buttons. Change from any `w-8 h-8` / `w-7 h-7` to `w-9 h-9 min-h-[44px] min-w-[44px]`.
|
||||||
|
|
||||||
|
Replace `text-[#8a8a8a]` with `text-text-sec`.
|
||||||
|
|
||||||
|
**Step 2: Verify build**
|
||||||
|
|
||||||
|
Run: `cd break-timer && npm run build`
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add break-timer/src/lib/components/ActivityManager.svelte
|
||||||
|
git commit -m "a11y: enlarge activity manager action buttons to 44px targets"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 18: Final Build Verification & Cleanup
|
||||||
|
|
||||||
|
**Step 1: Full build**
|
||||||
|
|
||||||
|
Run: `cd break-timer && npm run build`
|
||||||
|
Expected: Build succeeds with zero errors.
|
||||||
|
|
||||||
|
**Step 2: Verify all theme token propagation**
|
||||||
|
|
||||||
|
Search for any remaining hardcoded `#8a8a8a` in Svelte files — there should be none (all replaced with `text-text-sec` or inline `#a8a8a8`).
|
||||||
|
|
||||||
|
Run: `grep -r "#8a8a8a" break-timer/src/` — should return zero results.
|
||||||
|
|
||||||
|
**Step 3: Verify no remaining h3 headings in Settings/Stats**
|
||||||
|
|
||||||
|
Run: `grep -n "<h3" break-timer/src/lib/components/Settings.svelte break-timer/src/lib/components/StatsView.svelte` — should return zero results.
|
||||||
|
|
||||||
|
**Step 4: Final commit if any cleanup needed**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -A break-timer/src/
|
||||||
|
git commit -m "a11y: final cleanup pass for WCAG 2.2 AAA compliance"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependency Graph
|
||||||
|
|
||||||
|
```
|
||||||
|
Task 1 (app.css tokens)
|
||||||
|
├── Task 2 (App.svelte)
|
||||||
|
├── Task 3 (Titlebar)
|
||||||
|
├── Task 4 (ToggleSwitch)
|
||||||
|
├── Task 5 (Stepper)
|
||||||
|
├── Task 6 (animate.ts)
|
||||||
|
├── Task 7 (Dashboard) ← depends on Task 6
|
||||||
|
├── Task 8 (Settings part 1)
|
||||||
|
├── Task 9 (Settings part 2) ← depends on Task 8
|
||||||
|
├── Task 10 (StatsView)
|
||||||
|
├── Task 11 (BreakScreen)
|
||||||
|
├── Task 12 (Celebration)
|
||||||
|
├── Task 13 (MiniTimer)
|
||||||
|
├── Task 14 (MicrobreakOverlay)
|
||||||
|
├── Task 15 (BreakOverlay)
|
||||||
|
├── Task 16 (ColorPicker)
|
||||||
|
└── Task 17 (ActivityManager)
|
||||||
|
Task 18 (Final verification) ← depends on all above
|
||||||
|
```
|
||||||
|
|
||||||
|
Tasks 2–17 are mostly independent of each other (except 7 depends on 6, and 9 depends on 8). They all depend on Task 1 being done first.
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "core-cooldown",
|
"name": "core-cooldown",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.2",
|
"version": "0.2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
2
src-tauri/Cargo.lock
generated
2
src-tauri/Cargo.lock
generated
@@ -480,7 +480,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "core-cooldown"
|
name = "core-cooldown"
|
||||||
version = "0.1.1"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "core-cooldown"
|
name = "core-cooldown"
|
||||||
version = "0.1.2"
|
version = "0.2.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
@@ -21,4 +21,4 @@ chrono = "0.4"
|
|||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
|
|
||||||
[target.'cfg(windows)'.dependencies]
|
[target.'cfg(windows)'.dependencies]
|
||||||
winapi = { version = "0.3", features = ["winuser", "sysinfoapi", "windef", "shellapi"] }
|
winapi = { version = "0.3", features = ["winuser", "sysinfoapi", "windef", "shellapi", "winreg"] }
|
||||||
|
|||||||
@@ -9,17 +9,70 @@ fn main() {
|
|||||||
|
|
||||||
// On GNU targets, replace the WebView2Loader import library with the static
|
// On GNU targets, replace the WebView2Loader import library with the static
|
||||||
// library so the loader is baked into the exe — no DLL to ship.
|
// library so the loader is baked into the exe — no DLL to ship.
|
||||||
#[cfg(target_env = "gnu")]
|
if std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("gnu") {
|
||||||
swap_webview2_to_static();
|
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
|
/// Replace `WebView2Loader.dll.lib` (dynamic import lib) with the contents of
|
||||||
/// `WebView2LoaderStatic.lib` (static archive) in the webview2-com-sys build
|
/// `WebView2LoaderStatic.lib` (static archive) in the webview2-com-sys build
|
||||||
/// output. The linker then statically links the WebView2 loader code, removing
|
/// output. The linker then statically links the WebView2 loader code, removing
|
||||||
/// the runtime dependency on WebView2Loader.dll.
|
/// the runtime dependency on WebView2Loader.dll.
|
||||||
#[cfg(target_env = "gnu")]
|
|
||||||
fn swap_webview2_to_static() {
|
fn swap_webview2_to_static() {
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"$schema": "https://raw.githubusercontent.com/nicegui-fr/nicegui/main/src-tauri/gen/schemas/desktop-schema.json",
|
"$schema": "https://raw.githubusercontent.com/nicegui-fr/nicegui/main/src-tauri/gen/schemas/desktop-schema.json",
|
||||||
"identifier": "default",
|
"identifier": "default",
|
||||||
"description": "Capability for the main window",
|
"description": "Capability for the main window",
|
||||||
"windows": ["main", "mini", "break"],
|
"windows": ["main", "mini", "break", "microbreak", "dim", "break-overlay-0", "break-overlay-1", "break-overlay-2", "break-overlay-3", "break-overlay-4", "break-overlay-5"],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:window:allow-start-dragging",
|
"core:window:allow-start-dragging",
|
||||||
"core:window:allow-minimize",
|
"core:window:allow-minimize",
|
||||||
|
|||||||
@@ -3,6 +3,16 @@ use serde::{Deserialize, Serialize};
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
/// A custom break activity defined by the user.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct CustomActivity {
|
||||||
|
pub id: String,
|
||||||
|
pub category: String,
|
||||||
|
pub text: String,
|
||||||
|
pub is_favorite: bool,
|
||||||
|
pub enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
/// A single time range (e.g., 09:00 to 17:00)
|
/// A single time range (e.g., 09:00 to 17:00)
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct TimeRange {
|
pub struct TimeRange {
|
||||||
@@ -107,6 +117,54 @@ pub struct Config {
|
|||||||
pub mini_click_through: bool, // Mini mode is click-through until hovered
|
pub mini_click_through: bool, // Mini mode is click-through until hovered
|
||||||
pub mini_hover_threshold: f32, // Seconds to hover before enabling drag (1.0-10.0)
|
pub mini_hover_threshold: f32, // Seconds to hover before enabling drag (1.0-10.0)
|
||||||
|
|
||||||
|
// F8: Auto-start on Windows login
|
||||||
|
pub auto_start_on_login: bool,
|
||||||
|
|
||||||
|
// F6: Custom break activities
|
||||||
|
pub custom_activities: Vec<CustomActivity>,
|
||||||
|
pub disabled_builtin_activities: Vec<String>,
|
||||||
|
pub favorite_builtin_activities: Vec<String>,
|
||||||
|
pub favorite_weight: u32, // Multiplier for favorites in random pool (2-10)
|
||||||
|
|
||||||
|
// F4: Guided breathing animation
|
||||||
|
pub breathing_guide_enabled: bool,
|
||||||
|
pub breathing_pattern: String, // "box", "relaxing", "energizing", "calm", "deep"
|
||||||
|
|
||||||
|
// F10: Break streaks & gamification
|
||||||
|
pub daily_goal_enabled: bool,
|
||||||
|
pub daily_goal_breaks: u32, // 1-30
|
||||||
|
pub milestone_celebrations: bool,
|
||||||
|
pub streak_notifications: bool,
|
||||||
|
|
||||||
|
// F1: Microbreaks & 20-20-20
|
||||||
|
pub microbreak_enabled: bool,
|
||||||
|
pub microbreak_frequency: u32, // 5-60 min
|
||||||
|
pub microbreak_duration: u32, // 10-60 sec
|
||||||
|
pub microbreak_sound_enabled: bool,
|
||||||
|
pub microbreak_show_activity: bool,
|
||||||
|
pub microbreak_pause_during_break: bool,
|
||||||
|
|
||||||
|
// F3: Pomodoro Mode
|
||||||
|
pub pomodoro_enabled: bool,
|
||||||
|
pub pomodoro_short_breaks: u32, // 1-10 (short breaks before long)
|
||||||
|
pub pomodoro_long_break_duration: u32, // 5-60 min
|
||||||
|
pub pomodoro_long_break_title: String, // max 100 chars
|
||||||
|
pub pomodoro_long_break_message: String, // max 500 chars
|
||||||
|
pub pomodoro_reset_on_skip: bool,
|
||||||
|
|
||||||
|
// F5: Screen dimming pre-break nudge
|
||||||
|
pub screen_dim_enabled: bool,
|
||||||
|
pub screen_dim_seconds: u32, // 3-60 sec before break
|
||||||
|
pub screen_dim_max_opacity: f32, // 0.1-0.7
|
||||||
|
|
||||||
|
// F2: Presentation mode / fullscreen detection
|
||||||
|
pub presentation_mode_enabled: bool,
|
||||||
|
pub presentation_mode_defer_microbreaks: bool,
|
||||||
|
pub presentation_mode_notification: bool,
|
||||||
|
|
||||||
|
// F9: Multi-monitor break enforcement
|
||||||
|
pub multi_monitor_break: bool,
|
||||||
|
|
||||||
// Window positions (persisted between launches)
|
// Window positions (persisted between launches)
|
||||||
pub main_window_x: Option<i32>,
|
pub main_window_x: Option<i32>,
|
||||||
pub main_window_y: Option<i32>,
|
pub main_window_y: Option<i32>,
|
||||||
@@ -186,6 +244,54 @@ impl Default for Config {
|
|||||||
mini_click_through: true,
|
mini_click_through: true,
|
||||||
mini_hover_threshold: 3.0,
|
mini_hover_threshold: 3.0,
|
||||||
|
|
||||||
|
// F8: Auto-start
|
||||||
|
auto_start_on_login: false,
|
||||||
|
|
||||||
|
// F6: Custom activities
|
||||||
|
custom_activities: Vec::new(),
|
||||||
|
disabled_builtin_activities: Vec::new(),
|
||||||
|
favorite_builtin_activities: Vec::new(),
|
||||||
|
favorite_weight: 3,
|
||||||
|
|
||||||
|
// F4: Breathing guide
|
||||||
|
breathing_guide_enabled: true,
|
||||||
|
breathing_pattern: "box".to_string(),
|
||||||
|
|
||||||
|
// F10: Gamification
|
||||||
|
daily_goal_enabled: true,
|
||||||
|
daily_goal_breaks: 8,
|
||||||
|
milestone_celebrations: true,
|
||||||
|
streak_notifications: true,
|
||||||
|
|
||||||
|
// F1: Microbreaks
|
||||||
|
microbreak_enabled: false,
|
||||||
|
microbreak_frequency: 20,
|
||||||
|
microbreak_duration: 20,
|
||||||
|
microbreak_sound_enabled: true,
|
||||||
|
microbreak_show_activity: true,
|
||||||
|
microbreak_pause_during_break: true,
|
||||||
|
|
||||||
|
// F3: Pomodoro
|
||||||
|
pomodoro_enabled: false,
|
||||||
|
pomodoro_short_breaks: 3,
|
||||||
|
pomodoro_long_break_duration: 15,
|
||||||
|
pomodoro_long_break_title: "Long break".to_string(),
|
||||||
|
pomodoro_long_break_message: "Great work! Take a longer rest.".to_string(),
|
||||||
|
pomodoro_reset_on_skip: false,
|
||||||
|
|
||||||
|
// F5: Screen dimming
|
||||||
|
screen_dim_enabled: false,
|
||||||
|
screen_dim_seconds: 10,
|
||||||
|
screen_dim_max_opacity: 0.3,
|
||||||
|
|
||||||
|
// F2: Presentation mode
|
||||||
|
presentation_mode_enabled: true,
|
||||||
|
presentation_mode_defer_microbreaks: true,
|
||||||
|
presentation_mode_notification: true,
|
||||||
|
|
||||||
|
// F9: Multi-monitor
|
||||||
|
multi_monitor_break: true,
|
||||||
|
|
||||||
// Window positions
|
// Window positions
|
||||||
main_window_x: None,
|
main_window_x: None,
|
||||||
main_window_y: None,
|
main_window_y: None,
|
||||||
@@ -349,6 +455,44 @@ impl Config {
|
|||||||
// UI zoom: 50-200%
|
// UI zoom: 50-200%
|
||||||
self.ui_zoom = self.ui_zoom.clamp(50, 200);
|
self.ui_zoom = self.ui_zoom.clamp(50, 200);
|
||||||
|
|
||||||
|
// F6: Custom activities
|
||||||
|
if self.custom_activities.len() > 100 {
|
||||||
|
self.custom_activities.truncate(100);
|
||||||
|
}
|
||||||
|
for act in &mut self.custom_activities {
|
||||||
|
if act.text.len() > 500 {
|
||||||
|
act.text.truncate(500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.favorite_weight = self.favorite_weight.clamp(2, 10);
|
||||||
|
|
||||||
|
// F4: Breathing pattern
|
||||||
|
let valid_patterns = vec!["box", "relaxing", "energizing", "calm", "deep"];
|
||||||
|
if !valid_patterns.contains(&self.breathing_pattern.as_str()) {
|
||||||
|
self.breathing_pattern = "box".to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
// F10: Daily goal
|
||||||
|
self.daily_goal_breaks = self.daily_goal_breaks.clamp(1, 30);
|
||||||
|
|
||||||
|
// F1: Microbreaks
|
||||||
|
self.microbreak_frequency = self.microbreak_frequency.clamp(5, 60);
|
||||||
|
self.microbreak_duration = self.microbreak_duration.clamp(10, 60);
|
||||||
|
|
||||||
|
// F3: Pomodoro
|
||||||
|
self.pomodoro_short_breaks = self.pomodoro_short_breaks.clamp(1, 10);
|
||||||
|
self.pomodoro_long_break_duration = self.pomodoro_long_break_duration.clamp(5, 60);
|
||||||
|
if self.pomodoro_long_break_title.len() > 100 {
|
||||||
|
self.pomodoro_long_break_title.truncate(100);
|
||||||
|
}
|
||||||
|
if self.pomodoro_long_break_message.len() > 500 {
|
||||||
|
self.pomodoro_long_break_message.truncate(500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// F5: Screen dimming
|
||||||
|
self.screen_dim_seconds = self.screen_dim_seconds.clamp(3, 60);
|
||||||
|
self.screen_dim_max_opacity = self.screen_dim_max_opacity.clamp(0.1, 0.7);
|
||||||
|
|
||||||
// Validate color hex strings
|
// Validate color hex strings
|
||||||
if !Self::is_valid_hex_color(&self.accent_color) {
|
if !Self::is_valid_hex_color(&self.accent_color) {
|
||||||
self.accent_color = "#ff4d00".to_string();
|
self.accent_color = "#ff4d00".to_string();
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ use tauri::{
|
|||||||
AppHandle, Emitter, Manager, State,
|
AppHandle, Emitter, Manager, State,
|
||||||
};
|
};
|
||||||
use tauri_plugin_notification::NotificationExt;
|
use tauri_plugin_notification::NotificationExt;
|
||||||
use timer::{AppView, TickResult, TimerManager, TimerSnapshot};
|
use timer::{AppView, MicrobreakTickResult, TickResult, TimerManager, TimerSnapshot};
|
||||||
|
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub timer: Arc<Mutex<TimerManager>>,
|
pub timer: Arc<Mutex<TimerManager>>,
|
||||||
@@ -127,7 +127,13 @@ fn set_view(state: State<AppState>, view: AppView) {
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn get_stats(state: State<AppState>) -> stats::StatsSnapshot {
|
fn get_stats(state: State<AppState>) -> stats::StatsSnapshot {
|
||||||
let s = state.stats.lock().unwrap();
|
let s = state.stats.lock().unwrap();
|
||||||
s.snapshot()
|
let timer = state.timer.lock().unwrap();
|
||||||
|
let daily_goal = if timer.config.daily_goal_enabled {
|
||||||
|
timer.config.daily_goal_breaks
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
s.snapshot(daily_goal)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -136,6 +142,126 @@ fn get_daily_history(state: State<AppState>, days: u32) -> Vec<stats::DayRecord>
|
|||||||
s.recent_days(days)
|
s.recent_days(days)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// F7: Weekly summary command
|
||||||
|
#[tauri::command]
|
||||||
|
fn get_weekly_summary(state: State<AppState>, weeks: u32) -> Vec<stats::WeekSummary> {
|
||||||
|
let s = state.stats.lock().unwrap();
|
||||||
|
s.weekly_summary(weeks)
|
||||||
|
}
|
||||||
|
|
||||||
|
// F8: Auto-start on Windows login
|
||||||
|
#[tauri::command]
|
||||||
|
fn set_auto_start(enabled: bool) -> Result<(), String> {
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
use std::ffi::OsStr;
|
||||||
|
use std::os::windows::ffi::OsStrExt;
|
||||||
|
use winapi::um::winreg::{RegOpenKeyExW, RegSetValueExW, RegDeleteValueW, HKEY_CURRENT_USER};
|
||||||
|
use winapi::um::winnt::{KEY_SET_VALUE, REG_SZ};
|
||||||
|
|
||||||
|
let sub_key: Vec<u16> = OsStr::new("SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Run")
|
||||||
|
.encode_wide()
|
||||||
|
.chain(std::iter::once(0))
|
||||||
|
.collect();
|
||||||
|
let value_name: Vec<u16> = OsStr::new("CoreCooldown")
|
||||||
|
.encode_wide()
|
||||||
|
.chain(std::iter::once(0))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
let mut hkey = std::ptr::null_mut();
|
||||||
|
let result = RegOpenKeyExW(
|
||||||
|
HKEY_CURRENT_USER,
|
||||||
|
sub_key.as_ptr(),
|
||||||
|
0,
|
||||||
|
KEY_SET_VALUE,
|
||||||
|
&mut hkey,
|
||||||
|
);
|
||||||
|
if result != 0 {
|
||||||
|
return Err("Failed to open registry key".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if enabled {
|
||||||
|
let exe_path = std::env::current_exe()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let path_str = exe_path.to_string_lossy();
|
||||||
|
let value_data: Vec<u16> = OsStr::new(&*path_str)
|
||||||
|
.encode_wide()
|
||||||
|
.chain(std::iter::once(0))
|
||||||
|
.collect();
|
||||||
|
let res = RegSetValueExW(
|
||||||
|
hkey,
|
||||||
|
value_name.as_ptr(),
|
||||||
|
0,
|
||||||
|
REG_SZ,
|
||||||
|
value_data.as_ptr() as *const u8,
|
||||||
|
(value_data.len() * 2) as u32,
|
||||||
|
);
|
||||||
|
winapi::um::winreg::RegCloseKey(hkey);
|
||||||
|
if res != 0 {
|
||||||
|
return Err("Failed to set registry value".to_string());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let _res = RegDeleteValueW(hkey, value_name.as_ptr());
|
||||||
|
winapi::um::winreg::RegCloseKey(hkey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
{
|
||||||
|
Err("Auto-start is only supported on Windows".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn get_auto_start_status() -> bool {
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
use std::ffi::OsStr;
|
||||||
|
use std::os::windows::ffi::OsStrExt;
|
||||||
|
use winapi::um::winreg::{RegOpenKeyExW, RegQueryValueExW, HKEY_CURRENT_USER};
|
||||||
|
use winapi::um::winnt::KEY_READ;
|
||||||
|
|
||||||
|
let sub_key: Vec<u16> = OsStr::new("SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Run")
|
||||||
|
.encode_wide()
|
||||||
|
.chain(std::iter::once(0))
|
||||||
|
.collect();
|
||||||
|
let value_name: Vec<u16> = OsStr::new("CoreCooldown")
|
||||||
|
.encode_wide()
|
||||||
|
.chain(std::iter::once(0))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
let mut hkey = std::ptr::null_mut();
|
||||||
|
let result = RegOpenKeyExW(
|
||||||
|
HKEY_CURRENT_USER,
|
||||||
|
sub_key.as_ptr(),
|
||||||
|
0,
|
||||||
|
KEY_READ,
|
||||||
|
&mut hkey,
|
||||||
|
);
|
||||||
|
if result != 0 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let res = RegQueryValueExW(
|
||||||
|
hkey,
|
||||||
|
value_name.as_ptr(),
|
||||||
|
std::ptr::null_mut(),
|
||||||
|
std::ptr::null_mut(),
|
||||||
|
std::ptr::null_mut(),
|
||||||
|
std::ptr::null_mut(),
|
||||||
|
);
|
||||||
|
winapi::um::winreg::RegCloseKey(hkey);
|
||||||
|
res == 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
{
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Cursor / Window Position Commands ────────────────────────────────────
|
// ── Cursor / Window Position Commands ────────────────────────────────────
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -197,12 +323,14 @@ 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.
|
/// 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(
|
fn render_tray_icon(
|
||||||
progress: f64,
|
progress: f64,
|
||||||
is_break: bool,
|
is_break: bool,
|
||||||
is_paused: bool,
|
is_paused: bool,
|
||||||
accent: (u8, u8, u8),
|
accent: (u8, u8, u8),
|
||||||
break_color: (u8, u8, u8),
|
break_color: (u8, u8, u8),
|
||||||
|
goal_met: bool,
|
||||||
) -> Vec<u8> {
|
) -> Vec<u8> {
|
||||||
let size: usize = 32;
|
let size: usize = 32;
|
||||||
let mut rgba = vec![0u8; size * size * 4];
|
let mut rgba = vec![0u8; size * size * 4];
|
||||||
@@ -241,6 +369,28 @@ fn render_tray_icon(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// F10: Draw a small green dot in the bottom-right corner when goal is met
|
||||||
|
if goal_met {
|
||||||
|
let dot_cx = 25.0_f64;
|
||||||
|
let dot_cy = 25.0_f64;
|
||||||
|
let dot_r = 4.0_f64;
|
||||||
|
for y in 20..32 {
|
||||||
|
for x in 20..32 {
|
||||||
|
let dx = x as f64 - dot_cx;
|
||||||
|
let dy = y as f64 - dot_cy;
|
||||||
|
let dist = (dx * dx + dy * dy).sqrt();
|
||||||
|
if dist <= dot_r {
|
||||||
|
let idx = (y * size + x) * 4;
|
||||||
|
rgba[idx] = 63; // green
|
||||||
|
rgba[idx + 1] = 185;
|
||||||
|
rgba[idx + 2] = 80;
|
||||||
|
rgba[idx + 3] = 255;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
rgba
|
rgba
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,14 +399,19 @@ fn update_tray(
|
|||||||
snapshot: &TimerSnapshot,
|
snapshot: &TimerSnapshot,
|
||||||
accent: (u8, u8, u8),
|
accent: (u8, u8, u8),
|
||||||
break_color: (u8, u8, u8),
|
break_color: (u8, u8, u8),
|
||||||
|
goal_met: bool,
|
||||||
) {
|
) {
|
||||||
// Update tooltip
|
// Update tooltip
|
||||||
let tooltip = match snapshot.state {
|
let tooltip = match snapshot.state {
|
||||||
timer::TimerState::Running => {
|
timer::TimerState::Running => {
|
||||||
|
if snapshot.deferred_break_pending {
|
||||||
|
"Core Cooldown — Break deferred (fullscreen)".to_string()
|
||||||
|
} else {
|
||||||
let m = snapshot.time_remaining / 60;
|
let m = snapshot.time_remaining / 60;
|
||||||
let s = 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 => {
|
timer::TimerState::Paused => {
|
||||||
if snapshot.idle_paused {
|
if snapshot.idle_paused {
|
||||||
"Core Cooldown — Paused (idle)".to_string()
|
"Core Cooldown — Paused (idle)".to_string()
|
||||||
@@ -279,7 +434,7 @@ fn update_tray(
|
|||||||
timer::TimerState::BreakActive => (snapshot.break_progress, true, false),
|
timer::TimerState::BreakActive => (snapshot.break_progress, true, false),
|
||||||
};
|
};
|
||||||
|
|
||||||
let icon_data = render_tray_icon(progress, is_break, is_paused, accent, break_color);
|
let icon_data = render_tray_icon(progress, is_break, is_paused, accent, break_color, goal_met);
|
||||||
let icon = Image::new_owned(icon_data, 32, 32);
|
let icon = Image::new_owned(icon_data, 32, 32);
|
||||||
let _ = tray.set_icon(Some(icon));
|
let _ = tray.set_icon(Some(icon));
|
||||||
}
|
}
|
||||||
@@ -369,41 +524,133 @@ pub fn run() {
|
|||||||
let handle = app.handle().clone();
|
let handle = app.handle().clone();
|
||||||
let timer_ref = app.state::<AppState>().timer.clone();
|
let timer_ref = app.state::<AppState>().timer.clone();
|
||||||
let stats_ref = app.state::<AppState>().stats.clone();
|
let stats_ref = app.state::<AppState>().stats.clone();
|
||||||
|
let data_dir_clone = data_dir.clone();
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
|
let mut dim_window_open = false;
|
||||||
|
let mut microbreak_window_open = false;
|
||||||
|
let mut break_deferred_notified = false;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
std::thread::sleep(Duration::from_secs(1));
|
std::thread::sleep(Duration::from_secs(1));
|
||||||
|
|
||||||
let (tick_result, snapshot, accent_hex, break_hex) = {
|
let (tick_result, mb_result, snapshot, accent_hex, break_hex, daily_goal, daily_goal_enabled, goal_met) = {
|
||||||
let mut timer = timer_ref.lock().unwrap();
|
let mut timer = timer_ref.lock().unwrap();
|
||||||
let result = timer.tick();
|
let result = timer.tick();
|
||||||
|
let mb = timer.tick_microbreak();
|
||||||
let snap = timer.snapshot();
|
let snap = timer.snapshot();
|
||||||
let ac = timer.config.accent_color.clone();
|
let ac = timer.config.accent_color.clone();
|
||||||
let bc = timer.config.break_color.clone();
|
let bc = timer.config.break_color.clone();
|
||||||
(result, snap, ac, bc)
|
let dg = timer.config.daily_goal_breaks;
|
||||||
|
let dge = timer.config.daily_goal_enabled;
|
||||||
|
// Check goal status
|
||||||
|
let s = stats_ref.lock().unwrap();
|
||||||
|
let goal_target = if dge { dg } else { 0 };
|
||||||
|
let ss = s.snapshot(goal_target);
|
||||||
|
(result, mb, snap, ac, bc, dg, dge, ss.daily_goal_met)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Update tray icon and tooltip with configured colors
|
// Update tray icon and tooltip with configured colors
|
||||||
let accent = parse_hex_color(&accent_hex, (255, 77, 0));
|
let accent = parse_hex_color(&accent_hex, (255, 77, 0));
|
||||||
let break_c = parse_hex_color(&break_hex, (124, 106, 239));
|
let break_c = parse_hex_color(&break_hex, (124, 106, 239));
|
||||||
update_tray(&tray, &snapshot, accent, break_c);
|
update_tray(&tray, &snapshot, accent, break_c, goal_met);
|
||||||
|
|
||||||
// Emit tick event with full snapshot
|
// Emit tick event with full snapshot
|
||||||
let _ = handle.emit("timer-tick", &snapshot);
|
let _ = handle.emit("timer-tick", &snapshot);
|
||||||
|
|
||||||
|
// F5: Screen dim window management
|
||||||
|
if snapshot.screen_dim_active && !dim_window_open {
|
||||||
|
open_dim_overlay(&handle, &data_dir_clone);
|
||||||
|
dim_window_open = true;
|
||||||
|
} else if !snapshot.screen_dim_active && dim_window_open {
|
||||||
|
close_dim_overlay(&handle);
|
||||||
|
dim_window_open = false;
|
||||||
|
}
|
||||||
|
if snapshot.screen_dim_active {
|
||||||
|
let max_opacity = {
|
||||||
|
let t = timer_ref.lock().unwrap();
|
||||||
|
t.config.screen_dim_max_opacity
|
||||||
|
};
|
||||||
|
let _ = handle.emit("screen-dim-update", &serde_json::json!({
|
||||||
|
"progress": snapshot.screen_dim_progress,
|
||||||
|
"maxOpacity": max_opacity
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// F1: Microbreak window management
|
||||||
|
match mb_result {
|
||||||
|
MicrobreakTickResult::MicrobreakStarted => {
|
||||||
|
open_microbreak_window(&handle, &data_dir_clone);
|
||||||
|
microbreak_window_open = true;
|
||||||
|
let _ = handle.emit("microbreak-started", &());
|
||||||
|
}
|
||||||
|
MicrobreakTickResult::MicrobreakEnded => {
|
||||||
|
close_microbreak_window(&handle);
|
||||||
|
microbreak_window_open = false;
|
||||||
|
let _ = handle.emit("microbreak-ended", &());
|
||||||
|
}
|
||||||
|
MicrobreakTickResult::None => {}
|
||||||
|
}
|
||||||
|
|
||||||
// Emit specific events for state transitions
|
// Emit specific events for state transitions
|
||||||
match tick_result {
|
match tick_result {
|
||||||
TickResult::BreakStarted(payload) => {
|
TickResult::BreakStarted(payload) => {
|
||||||
|
// Close dim overlay if it was open
|
||||||
|
if dim_window_open {
|
||||||
|
close_dim_overlay(&handle);
|
||||||
|
dim_window_open = false;
|
||||||
|
}
|
||||||
|
// Close microbreak if active
|
||||||
|
if microbreak_window_open {
|
||||||
|
close_microbreak_window(&handle);
|
||||||
|
microbreak_window_open = false;
|
||||||
|
}
|
||||||
handle_break_start(&handle, payload.fullscreen_mode);
|
handle_break_start(&handle, payload.fullscreen_mode);
|
||||||
|
// F9: Multi-monitor overlays
|
||||||
|
{
|
||||||
|
let timer = timer_ref.lock().unwrap();
|
||||||
|
if timer.config.fullscreen_mode && timer.config.multi_monitor_break {
|
||||||
|
open_multi_monitor_overlays(&handle, &data_dir_clone);
|
||||||
|
}
|
||||||
|
}
|
||||||
let _ = handle.emit("break-started", &payload);
|
let _ = handle.emit("break-started", &payload);
|
||||||
|
break_deferred_notified = false;
|
||||||
}
|
}
|
||||||
TickResult::BreakEnded => {
|
TickResult::BreakEnded => {
|
||||||
// Restore normal window state and close break window
|
// Restore normal window state and close break window
|
||||||
handle_break_end(&handle);
|
handle_break_end(&handle);
|
||||||
|
// F9: Close multi-monitor overlays
|
||||||
|
close_multi_monitor_overlays(&handle);
|
||||||
// Record completed break in stats
|
// Record completed break in stats
|
||||||
{
|
let break_result = {
|
||||||
let timer = timer_ref.lock().unwrap();
|
let timer = timer_ref.lock().unwrap();
|
||||||
|
let goal = if daily_goal_enabled { daily_goal } else { 0 };
|
||||||
let mut s = stats_ref.lock().unwrap();
|
let mut s = stats_ref.lock().unwrap();
|
||||||
s.record_break_completed(timer.break_total_duration);
|
s.record_break_completed(timer.break_total_duration, goal)
|
||||||
|
};
|
||||||
|
// F10: Emit milestone/goal events
|
||||||
|
if let Some(streak) = break_result.milestone_reached {
|
||||||
|
let _ = handle.emit("milestone-reached", &streak);
|
||||||
|
let timer = timer_ref.lock().unwrap();
|
||||||
|
if timer.config.streak_notifications {
|
||||||
|
let _ = handle
|
||||||
|
.notification()
|
||||||
|
.builder()
|
||||||
|
.title("Streak milestone!")
|
||||||
|
.body(&format!("{}-day streak! Keep it up!", streak))
|
||||||
|
.show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if break_result.daily_goal_just_met {
|
||||||
|
let _ = handle.emit("daily-goal-met", &());
|
||||||
|
let timer = timer_ref.lock().unwrap();
|
||||||
|
if timer.config.streak_notifications {
|
||||||
|
let _ = handle
|
||||||
|
.notification()
|
||||||
|
.builder()
|
||||||
|
.title("Daily goal reached!")
|
||||||
|
.body("You've hit your break goal for today.")
|
||||||
|
.show();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let _ = handle
|
let _ = handle
|
||||||
.notification()
|
.notification()
|
||||||
@@ -464,6 +711,22 @@ pub fn run() {
|
|||||||
.show();
|
.show();
|
||||||
let _ = handle.emit("natural-break-detected", &duration_seconds);
|
let _ = handle.emit("natural-break-detected", &duration_seconds);
|
||||||
}
|
}
|
||||||
|
TickResult::BreakDeferred => {
|
||||||
|
// F2: Notify once when break gets deferred
|
||||||
|
if !break_deferred_notified {
|
||||||
|
break_deferred_notified = true;
|
||||||
|
let timer = timer_ref.lock().unwrap();
|
||||||
|
if timer.config.presentation_mode_notification {
|
||||||
|
let _ = handle
|
||||||
|
.notification()
|
||||||
|
.builder()
|
||||||
|
.title("Break deferred")
|
||||||
|
.body("Fullscreen app detected — break will start when you exit.")
|
||||||
|
.show();
|
||||||
|
}
|
||||||
|
let _ = handle.emit("break-deferred", &());
|
||||||
|
}
|
||||||
|
}
|
||||||
TickResult::None => {}
|
TickResult::None => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -493,6 +756,9 @@ pub fn run() {
|
|||||||
set_view,
|
set_view,
|
||||||
get_stats,
|
get_stats,
|
||||||
get_daily_history,
|
get_daily_history,
|
||||||
|
get_weekly_summary,
|
||||||
|
set_auto_start,
|
||||||
|
get_auto_start_status,
|
||||||
get_cursor_position,
|
get_cursor_position,
|
||||||
save_window_position,
|
save_window_position,
|
||||||
])
|
])
|
||||||
@@ -688,3 +954,114 @@ fn toggle_mini_window(app: &AppHandle) {
|
|||||||
let _ = builder.build();
|
let _ = builder.build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── F1: Microbreak Window ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn open_microbreak_window(app: &AppHandle, data_dir: &std::path::Path) {
|
||||||
|
if app.get_webview_window("microbreak").is_some() {
|
||||||
|
return; // already open
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = tauri::WebviewWindowBuilder::new(
|
||||||
|
app,
|
||||||
|
"microbreak",
|
||||||
|
tauri::WebviewUrl::App("index.html?microbreak=1".into()),
|
||||||
|
)
|
||||||
|
.title("Eye Break")
|
||||||
|
.inner_size(400.0, 180.0)
|
||||||
|
.decorations(false)
|
||||||
|
.transparent(true)
|
||||||
|
.shadow(false)
|
||||||
|
.always_on_top(true)
|
||||||
|
.skip_taskbar(true)
|
||||||
|
.resizable(false)
|
||||||
|
.center()
|
||||||
|
.data_directory(data_dir.to_path_buf())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn close_microbreak_window(app: &AppHandle) {
|
||||||
|
if let Some(win) = app.get_webview_window("microbreak") {
|
||||||
|
let _ = win.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── F5: Dim Overlay Window ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn open_dim_overlay(app: &AppHandle, data_dir: &std::path::Path) {
|
||||||
|
if app.get_webview_window("dim").is_some() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let builder = tauri::WebviewWindowBuilder::new(
|
||||||
|
app,
|
||||||
|
"dim",
|
||||||
|
tauri::WebviewUrl::App("index.html?dim=1".into()),
|
||||||
|
)
|
||||||
|
.title("")
|
||||||
|
.decorations(false)
|
||||||
|
.transparent(true)
|
||||||
|
.shadow(false)
|
||||||
|
.always_on_top(true)
|
||||||
|
.skip_taskbar(true)
|
||||||
|
.resizable(false)
|
||||||
|
.maximized(true)
|
||||||
|
.data_directory(data_dir.to_path_buf());
|
||||||
|
|
||||||
|
if let Ok(win) = builder.build() {
|
||||||
|
let _ = win.set_ignore_cursor_events(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn close_dim_overlay(app: &AppHandle) {
|
||||||
|
if let Some(win) = app.get_webview_window("dim") {
|
||||||
|
let _ = win.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── F9: Multi-Monitor Break Overlays ────────────────────────────────────────
|
||||||
|
|
||||||
|
fn open_multi_monitor_overlays(app: &AppHandle, data_dir: &std::path::Path) {
|
||||||
|
let monitors = timer::get_all_monitors();
|
||||||
|
|
||||||
|
for (i, mon) in monitors.iter().enumerate() {
|
||||||
|
if mon.is_primary {
|
||||||
|
continue; // Primary is handled by the main break window
|
||||||
|
}
|
||||||
|
if i > 5 {
|
||||||
|
break; // Cap at 6 monitors
|
||||||
|
}
|
||||||
|
|
||||||
|
let label = format!("break-overlay-{}", i);
|
||||||
|
if app.get_webview_window(&label).is_some() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = tauri::WebviewWindowBuilder::new(
|
||||||
|
app,
|
||||||
|
&label,
|
||||||
|
tauri::WebviewUrl::App("index.html?breakoverlay=1".into()),
|
||||||
|
)
|
||||||
|
.title("")
|
||||||
|
.position(mon.x as f64, mon.y as f64)
|
||||||
|
.inner_size(mon.width as f64, mon.height as f64)
|
||||||
|
.decorations(false)
|
||||||
|
.transparent(true)
|
||||||
|
.shadow(false)
|
||||||
|
.always_on_top(true)
|
||||||
|
.skip_taskbar(true)
|
||||||
|
.resizable(false)
|
||||||
|
.data_directory(data_dir.to_path_buf())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn close_multi_monitor_overlays(app: &AppHandle) {
|
||||||
|
// Close any window with label starting with "break-overlay-"
|
||||||
|
for i in 0..6 {
|
||||||
|
let label = format!("break-overlay-{}", i);
|
||||||
|
if let Some(win) = app.get_webview_window(&label) {
|
||||||
|
let _ = win.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -42,8 +42,31 @@ pub struct StatsSnapshot {
|
|||||||
pub compliance_rate: f64,
|
pub compliance_rate: f64,
|
||||||
pub current_streak: u32,
|
pub current_streak: u32,
|
||||||
pub best_streak: u32,
|
pub best_streak: u32,
|
||||||
|
// F10: Daily goal
|
||||||
|
pub daily_goal_progress: u32,
|
||||||
|
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 {
|
||||||
|
pub week_start: String,
|
||||||
|
pub total_completed: u32,
|
||||||
|
pub total_skipped: u32,
|
||||||
|
pub total_break_time_secs: u64,
|
||||||
|
pub compliance_rate: f64,
|
||||||
|
pub avg_daily_completed: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
const MILESTONES: &[u32] = &[3, 5, 7, 14, 21, 30, 50, 100, 365];
|
||||||
|
|
||||||
impl Stats {
|
impl Stats {
|
||||||
/// Portable: stats file lives next to the exe
|
/// Portable: stats file lives next to the exe
|
||||||
fn stats_path() -> Option<PathBuf> {
|
fn stats_path() -> Option<PathBuf> {
|
||||||
@@ -91,12 +114,23 @@ impl Stats {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn record_break_completed(&mut self, duration_secs: u64) {
|
/// 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 day = self.today_mut();
|
||||||
|
let was_below_goal = day.breaks_completed < daily_goal;
|
||||||
day.breaks_completed += 1;
|
day.breaks_completed += 1;
|
||||||
day.total_break_time_secs += duration_secs;
|
day.total_break_time_secs += duration_secs;
|
||||||
|
let now_at_goal = day.breaks_completed >= daily_goal;
|
||||||
self.update_streak();
|
self.update_streak();
|
||||||
self.save();
|
self.save();
|
||||||
|
|
||||||
|
let milestone = self.check_milestone();
|
||||||
|
let daily_goal_just_met = was_below_goal && now_at_goal && daily_goal > 0;
|
||||||
|
|
||||||
|
BreakCompletedResult {
|
||||||
|
milestone_reached: milestone,
|
||||||
|
daily_goal_just_met,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn record_break_skipped(&mut self) {
|
pub fn record_break_skipped(&mut self) {
|
||||||
@@ -148,7 +182,17 @@ impl Stats {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn snapshot(&self) -> StatsSnapshot {
|
/// 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) {
|
||||||
|
Some(streak)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn snapshot(&self, daily_goal: u32) -> StatsSnapshot {
|
||||||
let key = Self::today_key();
|
let key = Self::today_key();
|
||||||
let today = self.data.days.get(&key);
|
let today = self.data.days.get(&key);
|
||||||
|
|
||||||
@@ -176,6 +220,8 @@ impl Stats {
|
|||||||
compliance_rate: compliance,
|
compliance_rate: compliance,
|
||||||
current_streak: self.data.current_streak,
|
current_streak: self.data.current_streak,
|
||||||
best_streak: self.data.best_streak,
|
best_streak: self.data.best_streak,
|
||||||
|
daily_goal_progress: completed,
|
||||||
|
daily_goal_met: daily_goal > 0 && completed >= daily_goal,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,4 +242,47 @@ impl Stats {
|
|||||||
|
|
||||||
records
|
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();
|
||||||
|
|
||||||
|
for w in 0..weeks {
|
||||||
|
let week_end = today - chrono::Duration::days((w * 7) as i64);
|
||||||
|
let week_start = week_end - chrono::Duration::days(6);
|
||||||
|
|
||||||
|
let mut total_completed = 0u32;
|
||||||
|
let mut total_skipped = 0u32;
|
||||||
|
let mut total_break_time = 0u64;
|
||||||
|
|
||||||
|
for d in 0..7 {
|
||||||
|
let day = week_start + chrono::Duration::days(d);
|
||||||
|
let key = day.format("%Y-%m-%d").to_string();
|
||||||
|
if let Some(record) = self.data.days.get(&key) {
|
||||||
|
total_completed += record.breaks_completed;
|
||||||
|
total_skipped += record.breaks_skipped;
|
||||||
|
total_break_time += record.total_break_time_secs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let total = total_completed + total_skipped;
|
||||||
|
let compliance = if total > 0 {
|
||||||
|
total_completed as f64 / total as f64
|
||||||
|
} else {
|
||||||
|
1.0
|
||||||
|
};
|
||||||
|
|
||||||
|
summaries.push(WeekSummary {
|
||||||
|
week_start: week_start.format("%Y-%m-%d").to_string(),
|
||||||
|
total_completed,
|
||||||
|
total_skipped,
|
||||||
|
total_break_time_secs: total_break_time,
|
||||||
|
compliance_rate: compliance,
|
||||||
|
avg_daily_completed: total_completed as f64 / 7.0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
summaries
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,27 @@ pub struct TimerSnapshot {
|
|||||||
pub natural_break_occurred: bool,
|
pub natural_break_occurred: bool,
|
||||||
pub smart_breaks_enabled: bool,
|
pub smart_breaks_enabled: bool,
|
||||||
pub smart_break_threshold: u32,
|
pub smart_break_threshold: u32,
|
||||||
|
// F1: Microbreaks
|
||||||
|
pub microbreak_enabled: bool,
|
||||||
|
pub microbreak_active: bool,
|
||||||
|
pub microbreak_time_remaining: u64,
|
||||||
|
pub microbreak_total_duration: u64,
|
||||||
|
pub microbreak_countdown: u64, // seconds until next microbreak
|
||||||
|
pub microbreak_frequency: u32,
|
||||||
|
// F3: Pomodoro
|
||||||
|
pub pomodoro_enabled: bool,
|
||||||
|
pub pomodoro_cycle_position: u32,
|
||||||
|
pub pomodoro_total_in_cycle: u32,
|
||||||
|
pub pomodoro_is_long_break: bool,
|
||||||
|
pub pomodoro_next_is_long: bool,
|
||||||
|
// F5: Screen dimming
|
||||||
|
pub screen_dim_active: bool,
|
||||||
|
pub screen_dim_progress: f64,
|
||||||
|
// F2: Presentation mode
|
||||||
|
pub presentation_mode_active: bool,
|
||||||
|
pub deferred_break_pending: bool,
|
||||||
|
// F10: Gamification
|
||||||
|
pub is_long_break: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Events emitted by the timer to the frontend
|
/// Events emitted by the timer to the frontend
|
||||||
@@ -63,6 +84,7 @@ pub struct BreakStartedPayload {
|
|||||||
pub strict_mode: bool,
|
pub strict_mode: bool,
|
||||||
pub snooze_duration: u32,
|
pub snooze_duration: u32,
|
||||||
pub fullscreen_mode: bool,
|
pub fullscreen_mode: bool,
|
||||||
|
pub is_long_break: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct TimerManager {
|
pub struct TimerManager {
|
||||||
@@ -84,6 +106,17 @@ pub struct TimerManager {
|
|||||||
// Smart breaks: track when idle started for natural break detection
|
// Smart breaks: track when idle started for natural break detection
|
||||||
pub idle_start_time: Option<Instant>,
|
pub idle_start_time: Option<Instant>,
|
||||||
pub natural_break_occurred: bool,
|
pub natural_break_occurred: bool,
|
||||||
|
// F1: Microbreaks
|
||||||
|
pub microbreak_time_remaining: u64,
|
||||||
|
pub microbreak_active: bool,
|
||||||
|
pub microbreak_time_until_end: u64,
|
||||||
|
pub microbreak_total_duration: u64,
|
||||||
|
// F3: Pomodoro
|
||||||
|
pub pomodoro_cycle_position: u32,
|
||||||
|
pub pomodoro_is_long_break: bool,
|
||||||
|
// F2: Presentation mode
|
||||||
|
pub presentation_mode_active: bool,
|
||||||
|
pub deferred_break_pending: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TimerManager {
|
impl TimerManager {
|
||||||
@@ -92,6 +125,7 @@ impl TimerManager {
|
|||||||
let freq = config.break_frequency_seconds();
|
let freq = config.break_frequency_seconds();
|
||||||
let pending = config.clone();
|
let pending = config.clone();
|
||||||
let auto_start = config.auto_start;
|
let auto_start = config.auto_start;
|
||||||
|
let microbreak_freq = config.microbreak_frequency as u64 * 60;
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
state: if auto_start {
|
state: if auto_start {
|
||||||
@@ -113,6 +147,17 @@ impl TimerManager {
|
|||||||
idle_paused: false,
|
idle_paused: false,
|
||||||
idle_start_time: None,
|
idle_start_time: None,
|
||||||
natural_break_occurred: false,
|
natural_break_occurred: false,
|
||||||
|
// F1: Microbreaks
|
||||||
|
microbreak_time_remaining: microbreak_freq,
|
||||||
|
microbreak_active: false,
|
||||||
|
microbreak_time_until_end: 0,
|
||||||
|
microbreak_total_duration: 0,
|
||||||
|
// F3: Pomodoro
|
||||||
|
pomodoro_cycle_position: 0,
|
||||||
|
pomodoro_is_long_break: false,
|
||||||
|
// F2: Presentation mode
|
||||||
|
presentation_mode_active: false,
|
||||||
|
deferred_break_pending: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,6 +218,18 @@ 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;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let fs = is_foreground_fullscreen();
|
||||||
|
self.presentation_mode_active = fs;
|
||||||
|
fs
|
||||||
|
}
|
||||||
|
|
||||||
/// Called every second. Returns what events should be emitted.
|
/// Called every second. Returns what events should be emitted.
|
||||||
pub fn tick(&mut self) -> TickResult {
|
pub fn tick(&mut self) -> TickResult {
|
||||||
// Idle detection and natural break detection
|
// Idle detection and natural break detection
|
||||||
@@ -223,15 +280,18 @@ impl TimerManager {
|
|||||||
TickResult::None
|
TickResult::None
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// F2: Check presentation mode before starting break
|
||||||
|
if self.check_presentation_mode() {
|
||||||
|
self.deferred_break_pending = true;
|
||||||
|
// Keep time at 0 so it triggers immediately when cleared
|
||||||
|
self.time_until_next_break = 0;
|
||||||
|
return TickResult::BreakDeferred;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear any deferred state
|
||||||
|
self.deferred_break_pending = false;
|
||||||
self.start_break();
|
self.start_break();
|
||||||
TickResult::BreakStarted(BreakStartedPayload {
|
TickResult::BreakStarted(self.make_break_payload())
|
||||||
title: self.config.break_title.clone(),
|
|
||||||
message: self.config.break_message.clone(),
|
|
||||||
duration: self.break_total_duration,
|
|
||||||
strict_mode: self.config.strict_mode,
|
|
||||||
snooze_duration: self.config.snooze_duration,
|
|
||||||
fullscreen_mode: self.config.fullscreen_mode,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
TimerState::BreakActive => {
|
TimerState::BreakActive => {
|
||||||
@@ -242,28 +302,139 @@ impl TimerManager {
|
|||||||
// Break completed naturally
|
// Break completed naturally
|
||||||
self.has_had_break = true;
|
self.has_had_break = true;
|
||||||
self.seconds_since_last_break = 0;
|
self.seconds_since_last_break = 0;
|
||||||
|
self.advance_pomodoro_cycle();
|
||||||
self.reset_timer();
|
self.reset_timer();
|
||||||
TickResult::BreakEnded
|
TickResult::BreakEnded
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
TimerState::Paused => TickResult::None,
|
TimerState::Paused => {
|
||||||
|
// F2: Check if deferred break can now proceed
|
||||||
|
if self.deferred_break_pending && !self.idle_paused {
|
||||||
|
if !self.check_presentation_mode() {
|
||||||
|
self.deferred_break_pending = false;
|
||||||
|
self.state = TimerState::Running;
|
||||||
|
self.start_break();
|
||||||
|
return TickResult::BreakStarted(self.make_break_payload());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TickResult::None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't tick microbreaks during main breaks
|
||||||
|
if self.config.microbreak_pause_during_break && self.state == TimerState::BreakActive {
|
||||||
|
return MicrobreakTickResult::None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't tick when manually paused (but not during idle-pause which auto-resumes)
|
||||||
|
if self.state == TimerState::Paused && !self.idle_paused {
|
||||||
|
return MicrobreakTickResult::None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// F2: Defer microbreaks during presentation mode
|
||||||
|
if self.config.presentation_mode_defer_microbreaks && self.presentation_mode_active {
|
||||||
|
return MicrobreakTickResult::None;
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.microbreak_active {
|
||||||
|
// Counting down microbreak
|
||||||
|
if self.microbreak_time_until_end > 0 {
|
||||||
|
self.microbreak_time_until_end -= 1;
|
||||||
|
MicrobreakTickResult::None
|
||||||
|
} else {
|
||||||
|
// Microbreak ended
|
||||||
|
self.microbreak_active = false;
|
||||||
|
self.reset_microbreak_timer();
|
||||||
|
MicrobreakTickResult::MicrobreakEnded
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Counting down to next microbreak
|
||||||
|
if self.microbreak_time_remaining > 0 {
|
||||||
|
self.microbreak_time_remaining -= 1;
|
||||||
|
MicrobreakTickResult::None
|
||||||
|
} else {
|
||||||
|
// Start microbreak
|
||||||
|
self.microbreak_active = true;
|
||||||
|
self.microbreak_total_duration = self.config.microbreak_duration as u64;
|
||||||
|
self.microbreak_time_until_end = self.microbreak_total_duration;
|
||||||
|
MicrobreakTickResult::MicrobreakStarted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reset_microbreak_timer(&mut self) {
|
||||||
|
self.microbreak_time_remaining = self.config.microbreak_frequency as u64 * 60;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_break_payload(&self) -> BreakStartedPayload {
|
||||||
|
BreakStartedPayload {
|
||||||
|
title: if self.pomodoro_is_long_break {
|
||||||
|
self.config.pomodoro_long_break_title.clone()
|
||||||
|
} else {
|
||||||
|
self.config.break_title.clone()
|
||||||
|
},
|
||||||
|
message: if self.pomodoro_is_long_break {
|
||||||
|
self.config.pomodoro_long_break_message.clone()
|
||||||
|
} else {
|
||||||
|
self.config.break_message.clone()
|
||||||
|
},
|
||||||
|
duration: self.break_total_duration,
|
||||||
|
strict_mode: self.config.strict_mode,
|
||||||
|
snooze_duration: self.config.snooze_duration,
|
||||||
|
fullscreen_mode: self.config.fullscreen_mode,
|
||||||
|
is_long_break: self.pomodoro_is_long_break,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn start_break(&mut self) {
|
pub fn start_break(&mut self) {
|
||||||
|
// F3: Determine if this should be a long break (Pomodoro)
|
||||||
|
if self.config.pomodoro_enabled {
|
||||||
|
// cycle_position counts from 0. Position == short_breaks means it's the long break.
|
||||||
|
self.pomodoro_is_long_break = self.pomodoro_cycle_position >= self.config.pomodoro_short_breaks;
|
||||||
|
} else {
|
||||||
|
self.pomodoro_is_long_break = false;
|
||||||
|
}
|
||||||
|
|
||||||
self.state = TimerState::BreakActive;
|
self.state = TimerState::BreakActive;
|
||||||
self.current_view = AppView::BreakScreen;
|
self.current_view = AppView::BreakScreen;
|
||||||
|
|
||||||
|
if self.pomodoro_is_long_break {
|
||||||
|
self.break_total_duration = self.config.pomodoro_long_break_duration as u64 * 60;
|
||||||
|
} else {
|
||||||
self.break_total_duration = self.config.break_duration_seconds();
|
self.break_total_duration = self.config.break_duration_seconds();
|
||||||
|
}
|
||||||
|
|
||||||
self.time_until_break_end = self.break_total_duration;
|
self.time_until_break_end = self.break_total_duration;
|
||||||
self.prebreak_notification_active = false;
|
self.prebreak_notification_active = false;
|
||||||
self.snoozes_used = 0;
|
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;
|
||||||
|
}
|
||||||
|
if self.pomodoro_is_long_break {
|
||||||
|
// After long break, reset cycle
|
||||||
|
self.pomodoro_cycle_position = 0;
|
||||||
|
} else {
|
||||||
|
self.pomodoro_cycle_position += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn reset_timer(&mut self) {
|
pub fn reset_timer(&mut self) {
|
||||||
self.state = TimerState::Running;
|
self.state = TimerState::Running;
|
||||||
self.current_view = AppView::Dashboard;
|
self.current_view = AppView::Dashboard;
|
||||||
self.time_until_next_break = self.config.break_frequency_seconds();
|
self.time_until_next_break = self.config.break_frequency_seconds();
|
||||||
self.prebreak_notification_active = false;
|
self.prebreak_notification_active = false;
|
||||||
|
self.pomodoro_is_long_break = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn toggle_timer(&mut self) {
|
pub fn toggle_timer(&mut self) {
|
||||||
@@ -281,14 +452,7 @@ impl TimerManager {
|
|||||||
pub fn start_break_now(&mut self) -> Option<BreakStartedPayload> {
|
pub fn start_break_now(&mut self) -> Option<BreakStartedPayload> {
|
||||||
if self.state == TimerState::Running || self.state == TimerState::Paused {
|
if self.state == TimerState::Running || self.state == TimerState::Paused {
|
||||||
self.start_break();
|
self.start_break();
|
||||||
Some(BreakStartedPayload {
|
Some(self.make_break_payload())
|
||||||
title: self.config.break_title.clone(),
|
|
||||||
message: self.config.break_message.clone(),
|
|
||||||
duration: self.break_total_duration,
|
|
||||||
strict_mode: self.config.strict_mode,
|
|
||||||
snooze_duration: self.config.snooze_duration,
|
|
||||||
fullscreen_mode: self.config.fullscreen_mode,
|
|
||||||
})
|
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
@@ -311,10 +475,15 @@ impl TimerManager {
|
|||||||
// "End break" — counts as completed
|
// "End break" — counts as completed
|
||||||
self.has_had_break = true;
|
self.has_had_break = true;
|
||||||
self.seconds_since_last_break = 0;
|
self.seconds_since_last_break = 0;
|
||||||
|
self.advance_pomodoro_cycle();
|
||||||
self.reset_timer();
|
self.reset_timer();
|
||||||
true
|
true
|
||||||
} else if !past_half {
|
} 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;
|
||||||
|
}
|
||||||
self.reset_timer();
|
self.reset_timer();
|
||||||
true
|
true
|
||||||
} else {
|
} else {
|
||||||
@@ -420,6 +589,25 @@ impl TimerManager {
|
|||||||
&self.config
|
&self.config
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// F5: Screen dim active when running and close to break
|
||||||
|
let dim_secs = self.config.screen_dim_seconds as u64;
|
||||||
|
let screen_dim_active = self.config.screen_dim_enabled
|
||||||
|
&& self.state == TimerState::Running
|
||||||
|
&& self.time_until_next_break <= dim_secs
|
||||||
|
&& self.time_until_next_break > 0
|
||||||
|
&& !self.deferred_break_pending;
|
||||||
|
let screen_dim_progress = if screen_dim_active && dim_secs > 0 {
|
||||||
|
1.0 - (self.time_until_next_break as f64 / dim_secs as f64)
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
|
||||||
|
// F3: Pomodoro info
|
||||||
|
let pomo_total = self.config.pomodoro_short_breaks + 1;
|
||||||
|
let pomo_next_is_long = self.config.pomodoro_enabled
|
||||||
|
&& self.pomodoro_cycle_position + 1 >= pomo_total
|
||||||
|
&& self.state != TimerState::BreakActive;
|
||||||
|
|
||||||
TimerSnapshot {
|
TimerSnapshot {
|
||||||
state: self.state,
|
state: self.state,
|
||||||
current_view: self.current_view,
|
current_view: self.current_view,
|
||||||
@@ -431,8 +619,16 @@ impl TimerManager {
|
|||||||
prebreak_warning: self.prebreak_notification_active,
|
prebreak_warning: self.prebreak_notification_active,
|
||||||
snoozes_used: self.snoozes_used,
|
snoozes_used: self.snoozes_used,
|
||||||
can_snooze: self.can_snooze(),
|
can_snooze: self.can_snooze(),
|
||||||
break_title: display_config.break_title.clone(),
|
break_title: if self.pomodoro_is_long_break {
|
||||||
break_message: display_config.break_message.clone(),
|
self.config.pomodoro_long_break_title.clone()
|
||||||
|
} else {
|
||||||
|
display_config.break_title.clone()
|
||||||
|
},
|
||||||
|
break_message: if self.pomodoro_is_long_break {
|
||||||
|
self.config.pomodoro_long_break_message.clone()
|
||||||
|
} else {
|
||||||
|
display_config.break_message.clone()
|
||||||
|
},
|
||||||
break_progress,
|
break_progress,
|
||||||
break_time_remaining: self.time_until_break_end,
|
break_time_remaining: self.time_until_break_end,
|
||||||
break_total_duration: self.break_total_duration,
|
break_total_duration: self.break_total_duration,
|
||||||
@@ -442,6 +638,27 @@ impl TimerManager {
|
|||||||
natural_break_occurred: self.natural_break_occurred,
|
natural_break_occurred: self.natural_break_occurred,
|
||||||
smart_breaks_enabled: display_config.smart_breaks_enabled,
|
smart_breaks_enabled: display_config.smart_breaks_enabled,
|
||||||
smart_break_threshold: display_config.smart_break_threshold,
|
smart_break_threshold: display_config.smart_break_threshold,
|
||||||
|
// F1: Microbreaks
|
||||||
|
microbreak_enabled: self.config.microbreak_enabled,
|
||||||
|
microbreak_active: self.microbreak_active,
|
||||||
|
microbreak_time_remaining: self.microbreak_time_until_end,
|
||||||
|
microbreak_total_duration: self.microbreak_total_duration,
|
||||||
|
microbreak_countdown: self.microbreak_time_remaining,
|
||||||
|
microbreak_frequency: self.config.microbreak_frequency,
|
||||||
|
// F3: Pomodoro
|
||||||
|
pomodoro_enabled: self.config.pomodoro_enabled,
|
||||||
|
pomodoro_cycle_position: self.pomodoro_cycle_position,
|
||||||
|
pomodoro_total_in_cycle: pomo_total,
|
||||||
|
pomodoro_is_long_break: self.pomodoro_is_long_break,
|
||||||
|
pomodoro_next_is_long: pomo_next_is_long,
|
||||||
|
// F5: Screen dimming
|
||||||
|
screen_dim_active,
|
||||||
|
screen_dim_progress,
|
||||||
|
// F2: Presentation mode
|
||||||
|
presentation_mode_active: self.presentation_mode_active,
|
||||||
|
deferred_break_pending: self.deferred_break_pending,
|
||||||
|
// F10
|
||||||
|
is_long_break: self.pomodoro_is_long_break,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -452,6 +669,14 @@ pub enum TickResult {
|
|||||||
BreakEnded,
|
BreakEnded,
|
||||||
PreBreakWarning { seconds_until_break: u64 },
|
PreBreakWarning { seconds_until_break: u64 },
|
||||||
NaturalBreakDetected { duration_seconds: u64 },
|
NaturalBreakDetected { duration_seconds: u64 },
|
||||||
|
BreakDeferred, // F2
|
||||||
|
}
|
||||||
|
|
||||||
|
/// F1: Microbreak tick result
|
||||||
|
pub enum MicrobreakTickResult {
|
||||||
|
None,
|
||||||
|
MicrobreakStarted,
|
||||||
|
MicrobreakEnded,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Result of checking idle state
|
/// Result of checking idle state
|
||||||
@@ -486,3 +711,101 @@ pub fn get_idle_seconds() -> u64 {
|
|||||||
pub fn get_idle_seconds() -> u64 {
|
pub fn get_idle_seconds() -> u64 {
|
||||||
0
|
0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// F2: Check if the foreground window is a fullscreen application
|
||||||
|
#[cfg(windows)]
|
||||||
|
pub fn is_foreground_fullscreen() -> bool {
|
||||||
|
use std::mem;
|
||||||
|
use winapi::shared::windef::{HWND, RECT};
|
||||||
|
use winapi::um::winuser::{
|
||||||
|
GetForegroundWindow, GetWindowRect, MonitorFromWindow, GetMonitorInfoW,
|
||||||
|
MONITORINFO, MONITOR_DEFAULTTONEAREST,
|
||||||
|
};
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
let hwnd: HWND = GetForegroundWindow();
|
||||||
|
if hwnd.is_null() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the monitor this window is on
|
||||||
|
let monitor = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST);
|
||||||
|
if monitor.is_null() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut mi: MONITORINFO = mem::zeroed();
|
||||||
|
mi.cbSize = mem::size_of::<MONITORINFO>() as u32;
|
||||||
|
if GetMonitorInfoW(monitor, &mut mi) == 0 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut wr: RECT = mem::zeroed();
|
||||||
|
if GetWindowRect(hwnd, &mut wr) == 0 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if window rect covers the monitor rect
|
||||||
|
let mr = mi.rcMonitor;
|
||||||
|
wr.left <= mr.left && wr.top <= mr.top && wr.right >= mr.right && wr.bottom >= mr.bottom
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
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};
|
||||||
|
use winapi::um::winuser::{EnumDisplayMonitors, GetMonitorInfoW, MONITORINFO};
|
||||||
|
|
||||||
|
unsafe extern "system" fn callback(
|
||||||
|
monitor: HMONITOR,
|
||||||
|
_hdc: HDC,
|
||||||
|
_rect: LPRECT,
|
||||||
|
data: isize,
|
||||||
|
) -> i32 {
|
||||||
|
let monitors = &mut *(data as *mut Vec<MonitorInfo>);
|
||||||
|
let mut mi: MONITORINFO = std::mem::zeroed();
|
||||||
|
mi.cbSize = std::mem::size_of::<MONITORINFO>() as u32;
|
||||||
|
if GetMonitorInfoW(monitor, &mut mi) != 0 {
|
||||||
|
let r = mi.rcMonitor;
|
||||||
|
monitors.push(MonitorInfo {
|
||||||
|
x: r.left,
|
||||||
|
y: r.top,
|
||||||
|
width: (r.right - r.left) as u32,
|
||||||
|
height: (r.bottom - r.top) as u32,
|
||||||
|
is_primary: (mi.dwFlags & 1) != 0, // MONITORINFOF_PRIMARY = 1
|
||||||
|
});
|
||||||
|
}
|
||||||
|
1 // continue enumeration
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut monitors: Vec<MonitorInfo> = Vec::new();
|
||||||
|
unsafe {
|
||||||
|
EnumDisplayMonitors(
|
||||||
|
std::ptr::null_mut(),
|
||||||
|
std::ptr::null(),
|
||||||
|
Some(callback),
|
||||||
|
&mut monitors as *mut Vec<MonitorInfo> as isize,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
monitors
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
pub fn get_all_monitors() -> Vec<MonitorInfo> {
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct MonitorInfo {
|
||||||
|
pub x: i32,
|
||||||
|
pub y: i32,
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
pub is_primary: bool,
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-config-schema/schema.json",
|
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-config-schema/schema.json",
|
||||||
"productName": "Core Cooldown",
|
"productName": "Core Cooldown",
|
||||||
"version": "0.1.2",
|
"version": "0.2.0",
|
||||||
"identifier": "com.corecooldown.app",
|
"identifier": "com.corecooldown.app",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../dist",
|
"frontendDist": "../dist",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
import Settings from "./lib/components/Settings.svelte";
|
import Settings from "./lib/components/Settings.svelte";
|
||||||
import StatsView from "./lib/components/StatsView.svelte";
|
import StatsView from "./lib/components/StatsView.svelte";
|
||||||
import BackgroundBlobs from "./lib/components/BackgroundBlobs.svelte";
|
import BackgroundBlobs from "./lib/components/BackgroundBlobs.svelte";
|
||||||
|
import Celebration from "./lib/components/Celebration.svelte";
|
||||||
|
|
||||||
const appWindow = getCurrentWebviewWindow();
|
const appWindow = getCurrentWebviewWindow();
|
||||||
|
|
||||||
@@ -74,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,
|
// When fullscreen_mode is OFF, the separate break window handles breaks,
|
||||||
// so the main window should keep showing whatever view it was on (dashboard).
|
// so the main window should keep showing whatever view it was on (dashboard).
|
||||||
const effectiveView = $derived(
|
const effectiveView = $derived(
|
||||||
@@ -84,11 +96,13 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<main class="relative h-full bg-black">
|
<main class="relative h-full bg-black">
|
||||||
|
<a href="#main-content" class="skip-link">Skip to content</a>
|
||||||
{#if $config.background_blobs_enabled}
|
{#if $config.background_blobs_enabled}
|
||||||
<BackgroundBlobs accentColor={$config.accent_color} breakColor={$config.break_color} />
|
<BackgroundBlobs accentColor={$config.accent_color} breakColor={$config.break_color} />
|
||||||
{/if}
|
{/if}
|
||||||
<Titlebar />
|
<Titlebar />
|
||||||
<div
|
<div
|
||||||
|
id="main-content"
|
||||||
class="relative h-full overflow-hidden origin-top-left"
|
class="relative h-full overflow-hidden origin-top-left"
|
||||||
style="
|
style="
|
||||||
transform: scale({zoomScale});
|
transform: scale({zoomScale});
|
||||||
@@ -133,4 +147,5 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
<Celebration />
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
36
src/app.css
36
src/app.css
@@ -5,17 +5,19 @@
|
|||||||
--color-surface: #0e0e0e;
|
--color-surface: #0e0e0e;
|
||||||
--color-card: #141414;
|
--color-card: #141414;
|
||||||
--color-card-lt: #1c1c1c;
|
--color-card-lt: #1c1c1c;
|
||||||
--color-border: #222222;
|
--color-border: #3a3a3a;
|
||||||
--color-accent: #ff4d00;
|
--color-accent: #ff4d00;
|
||||||
--color-accent-lt: #ff7733;
|
--color-accent-lt: #ff7733;
|
||||||
--color-accent-dim: #ff4d0018;
|
--color-accent-dim: #ff4d0018;
|
||||||
--color-accent-glow: #ff4d0040;
|
--color-accent-glow: #ff4d0040;
|
||||||
--color-success: #3fb950;
|
--color-success: #3fb950;
|
||||||
--color-warning: #f0a500;
|
--color-warning: #f0a500;
|
||||||
--color-danger: #f85149;
|
--color-danger: #ff6b6b;
|
||||||
--color-text-pri: #ffffff;
|
--color-text-pri: #ffffff;
|
||||||
--color-text-sec: #8a8a8a;
|
--color-text-sec: #a8a8a8;
|
||||||
--color-text-dim: #3a3a3a;
|
--color-text-dim: #5c5c5c;
|
||||||
|
--color-input-border: #444444;
|
||||||
|
--color-surface-lt: #1e1e1e;
|
||||||
--color-caption-bg: #050505;
|
--color-caption-bg: #050505;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,6 +32,11 @@ body {
|
|||||||
Arial, sans-serif;
|
Arial, sans-serif;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
line-height: 1.625;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-tauri-drag-region],
|
||||||
|
[data-tauri-drag-region] * {
|
||||||
user-select: none;
|
user-select: none;
|
||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
}
|
}
|
||||||
@@ -69,10 +76,31 @@ body {
|
|||||||
border-width: 0;
|
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 ── */
|
/* ── Accessibility: Focus indicators ── */
|
||||||
:focus-visible {
|
:focus-visible {
|
||||||
outline: 2px solid var(--color-accent);
|
outline: 2px solid var(--color-accent);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
|
box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Accessibility: Reduced motion ── */
|
/* ── Accessibility: Reduced motion ── */
|
||||||
|
|||||||
581
src/lib/components/ActivityManager.svelte
Normal file
581
src/lib/components/ActivityManager.svelte
Normal file
@@ -0,0 +1,581 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { config, autoSave } from "../stores/config";
|
||||||
|
import { breakActivities, getCategoryLabel } from "../utils/activities";
|
||||||
|
import ToggleSwitch from "./ToggleSwitch.svelte";
|
||||||
|
import { pressable } from "../utils/animate";
|
||||||
|
|
||||||
|
const categories = ["eyes", "stretch", "breathing", "movement"] as const;
|
||||||
|
let expandedCategory = $state<string | null>(null);
|
||||||
|
let newActivityText = $state("");
|
||||||
|
let newActivityCategory = $state<string>("eyes");
|
||||||
|
let dropdownOpen = $state(false);
|
||||||
|
let dropdownRef = $state<HTMLElement>(undefined!);
|
||||||
|
let dropdownTriggerRef = $state<HTMLElement>(undefined!);
|
||||||
|
let focusedOptionIndex = $state(-1);
|
||||||
|
|
||||||
|
// Close dropdown on outside click
|
||||||
|
function handleOutsideClick(e: MouseEvent) {
|
||||||
|
if (dropdownRef && !dropdownRef.contains(e.target as Node)) {
|
||||||
|
dropdownOpen = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (dropdownOpen) {
|
||||||
|
document.addEventListener("mousedown", handleOutsideClick);
|
||||||
|
// Set initial focused option to current selection
|
||||||
|
focusedOptionIndex = categories.indexOf(newActivityCategory as typeof categories[number]);
|
||||||
|
return () => document.removeEventListener("mousedown", handleOutsideClick);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Focus the highlighted option when index changes
|
||||||
|
$effect(() => {
|
||||||
|
if (dropdownOpen && focusedOptionIndex >= 0) {
|
||||||
|
const options = dropdownRef?.querySelectorAll<HTMLElement>('[role="option"]');
|
||||||
|
options?.[focusedOptionIndex]?.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleDropdownKeydown(e: KeyboardEvent) {
|
||||||
|
if (!dropdownOpen) {
|
||||||
|
// Open on ArrowDown/Up/Enter/Space when closed
|
||||||
|
if (["ArrowDown", "ArrowUp", "Enter", " "].includes(e.key)) {
|
||||||
|
e.preventDefault();
|
||||||
|
dropdownOpen = true;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case "Escape":
|
||||||
|
e.preventDefault();
|
||||||
|
dropdownOpen = false;
|
||||||
|
dropdownTriggerRef?.focus();
|
||||||
|
break;
|
||||||
|
case "ArrowDown":
|
||||||
|
e.preventDefault();
|
||||||
|
focusedOptionIndex = Math.min(focusedOptionIndex + 1, categories.length - 1);
|
||||||
|
break;
|
||||||
|
case "ArrowUp":
|
||||||
|
e.preventDefault();
|
||||||
|
focusedOptionIndex = Math.max(focusedOptionIndex - 1, 0);
|
||||||
|
break;
|
||||||
|
case "Home":
|
||||||
|
e.preventDefault();
|
||||||
|
focusedOptionIndex = 0;
|
||||||
|
break;
|
||||||
|
case "End":
|
||||||
|
e.preventDefault();
|
||||||
|
focusedOptionIndex = categories.length - 1;
|
||||||
|
break;
|
||||||
|
case "Enter":
|
||||||
|
case " ":
|
||||||
|
e.preventDefault();
|
||||||
|
if (focusedOptionIndex >= 0) {
|
||||||
|
newActivityCategory = categories[focusedOptionIndex];
|
||||||
|
dropdownOpen = false;
|
||||||
|
dropdownTriggerRef?.focus();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "Tab":
|
||||||
|
dropdownOpen = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function markChanged() {
|
||||||
|
autoSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleCategory(cat: string) {
|
||||||
|
expandedCategory = expandedCategory === cat ? null : cat;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBuiltinDisabled(text: string): boolean {
|
||||||
|
return $config.disabled_builtin_activities.includes(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBuiltinFavorite(text: string): boolean {
|
||||||
|
return $config.favorite_builtin_activities.includes(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleBuiltinEnabled(text: string) {
|
||||||
|
if (isBuiltinDisabled(text)) {
|
||||||
|
$config.disabled_builtin_activities = $config.disabled_builtin_activities.filter((t) => t !== text);
|
||||||
|
} else {
|
||||||
|
$config.disabled_builtin_activities = [...$config.disabled_builtin_activities, text];
|
||||||
|
}
|
||||||
|
markChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleBuiltinFavorite(text: string) {
|
||||||
|
if (isBuiltinFavorite(text)) {
|
||||||
|
$config.favorite_builtin_activities = $config.favorite_builtin_activities.filter((t) => t !== text);
|
||||||
|
} else {
|
||||||
|
$config.favorite_builtin_activities = [...$config.favorite_builtin_activities, text];
|
||||||
|
}
|
||||||
|
markChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
function addCustomActivity() {
|
||||||
|
const trimmed = newActivityText.trim();
|
||||||
|
if (!trimmed || trimmed.length > 500) return;
|
||||||
|
if ($config.custom_activities.length >= 100) return;
|
||||||
|
|
||||||
|
const id = `custom-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
||||||
|
$config.custom_activities = [
|
||||||
|
...$config.custom_activities,
|
||||||
|
{ id, category: newActivityCategory, text: trimmed, is_favorite: false, enabled: true },
|
||||||
|
];
|
||||||
|
newActivityText = "";
|
||||||
|
markChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeCustomActivity(id: string) {
|
||||||
|
$config.custom_activities = $config.custom_activities.filter((a) => a.id !== id);
|
||||||
|
markChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleCustomEnabled(id: string) {
|
||||||
|
$config.custom_activities = $config.custom_activities.map((a) =>
|
||||||
|
a.id === id ? { ...a, enabled: !a.enabled } : a,
|
||||||
|
);
|
||||||
|
markChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleCustomFavorite(id: string) {
|
||||||
|
$config.custom_activities = $config.custom_activities.map((a) =>
|
||||||
|
a.id === id ? { ...a, is_favorite: !a.is_favorite } : a,
|
||||||
|
);
|
||||||
|
markChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get counts per category
|
||||||
|
function builtinCount(cat: string): number {
|
||||||
|
return breakActivities.filter((a) => a.category === cat).length;
|
||||||
|
}
|
||||||
|
function customCount(cat: string): number {
|
||||||
|
return $config.custom_activities.filter((a) => a.category === cat).length;
|
||||||
|
}
|
||||||
|
function disabledCount(cat: string): number {
|
||||||
|
return breakActivities
|
||||||
|
.filter((a) => a.category === cat)
|
||||||
|
.filter((a) => isBuiltinDisabled(a.text)).length
|
||||||
|
+ $config.custom_activities
|
||||||
|
.filter((a) => a.category === cat)
|
||||||
|
.filter((a) => !a.enabled).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Svelte action: blocks mousedown from reaching parent dragScroll ──
|
||||||
|
// Uses native addEventListener (not Svelte delegation) so stopPropagation
|
||||||
|
// fires BEFORE the parent's node-level mousedown handler in bubble phase.
|
||||||
|
function blockParentDrag(node: HTMLElement) {
|
||||||
|
function stop(e: MouseEvent) { e.stopPropagation(); }
|
||||||
|
node.addEventListener("mousedown", stop);
|
||||||
|
return { destroy() { node.removeEventListener("mousedown", stop); } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Svelte action: inner drag-scroll with momentum, overscroll, custom scrollbar ──
|
||||||
|
const prefersReducedMotion = typeof window !== "undefined"
|
||||||
|
? window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
||||||
|
: false;
|
||||||
|
|
||||||
|
function innerDragScroll(node: HTMLElement) {
|
||||||
|
const scrollEl = node.querySelector(".activity-scroll") as HTMLElement;
|
||||||
|
const content = scrollEl?.querySelector(".scroll-content") as HTMLElement;
|
||||||
|
if (!scrollEl || !content) return { destroy() {} };
|
||||||
|
|
||||||
|
// Reduced motion: skip custom scroll physics, allow normal scroll
|
||||||
|
if (prefersReducedMotion) {
|
||||||
|
// Still block wheel propagation for isolation
|
||||||
|
function onWheel(e: WheelEvent) {
|
||||||
|
e.stopPropagation();
|
||||||
|
const { scrollTop, scrollHeight, clientHeight } = scrollEl;
|
||||||
|
const atTop = scrollTop <= 0 && e.deltaY < 0;
|
||||||
|
const atBottom = scrollTop + clientHeight >= scrollHeight && e.deltaY > 0;
|
||||||
|
if (!atTop && !atBottom) {
|
||||||
|
e.preventDefault();
|
||||||
|
scrollEl.scrollTop += e.deltaY;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
node.addEventListener("wheel", onWheel, { passive: false });
|
||||||
|
return { destroy() { node.removeEventListener("wheel", onWheel); } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create custom scrollbar thumb
|
||||||
|
const thumb = document.createElement("div");
|
||||||
|
thumb.style.cssText =
|
||||||
|
"position:absolute;right:2px;top:0;width:3px;border-radius:1.5px;" +
|
||||||
|
"background:rgba(255,255,255,0.15);opacity:0;transition:opacity 0.3s ease;" +
|
||||||
|
"z-index:10;pointer-events:none;";
|
||||||
|
node.appendChild(thumb);
|
||||||
|
|
||||||
|
let hideTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let coastFrame = 0;
|
||||||
|
let overscroll = 0;
|
||||||
|
|
||||||
|
function getMaxScroll() {
|
||||||
|
return scrollEl.scrollHeight - scrollEl.clientHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showThumb() {
|
||||||
|
const { scrollTop, scrollHeight, clientHeight } = scrollEl;
|
||||||
|
if (scrollHeight <= clientHeight) { thumb.style.opacity = "0"; return; }
|
||||||
|
const h = Math.max(20, (clientHeight / scrollHeight) * clientHeight);
|
||||||
|
const t = (scrollTop / (scrollHeight - clientHeight)) * (clientHeight - h);
|
||||||
|
thumb.style.height = `${h}px`;
|
||||||
|
thumb.style.transform = `translateY(${t}px)`;
|
||||||
|
thumb.style.opacity = "1";
|
||||||
|
if (hideTimer) clearTimeout(hideTimer);
|
||||||
|
hideTimer = setTimeout(() => { thumb.style.opacity = "0"; }, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setOverscroll(amount: number) {
|
||||||
|
overscroll = amount;
|
||||||
|
if (Math.abs(amount) < 0.5) {
|
||||||
|
content.style.transform = "";
|
||||||
|
overscroll = 0;
|
||||||
|
} else {
|
||||||
|
content.style.transform = `translateY(${-amount}px)`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function springBack() {
|
||||||
|
const from = overscroll;
|
||||||
|
if (Math.abs(from) < 0.5) { setOverscroll(0); return; }
|
||||||
|
const start = performance.now();
|
||||||
|
function frame() {
|
||||||
|
const t = Math.min(1, (performance.now() - start) / 500);
|
||||||
|
const ease = 1 - Math.pow(1 - t, 3); // cubic ease-out
|
||||||
|
setOverscroll(from * (1 - ease));
|
||||||
|
showThumb();
|
||||||
|
if (t < 1) coastFrame = requestAnimationFrame(frame);
|
||||||
|
else { content.style.transform = ""; overscroll = 0; }
|
||||||
|
}
|
||||||
|
coastFrame = requestAnimationFrame(frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
function forceReset() {
|
||||||
|
cancelAnimationFrame(coastFrame);
|
||||||
|
content.style.transform = "";
|
||||||
|
overscroll = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDown(e: MouseEvent) {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (e.button !== 0) return;
|
||||||
|
const tag = (e.target as HTMLElement).tagName;
|
||||||
|
if (["BUTTON", "INPUT", "LABEL", "SELECT"].includes(tag)) return;
|
||||||
|
|
||||||
|
forceReset();
|
||||||
|
const startY = e.clientY;
|
||||||
|
const startScroll = scrollEl.scrollTop;
|
||||||
|
let lastY = e.clientY;
|
||||||
|
let lastTime = Date.now();
|
||||||
|
const vSamples: number[] = [];
|
||||||
|
|
||||||
|
scrollEl.style.cursor = "grabbing";
|
||||||
|
showThumb();
|
||||||
|
|
||||||
|
function onMove(ev: MouseEvent) {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
const y = ev.clientY;
|
||||||
|
const now = Date.now();
|
||||||
|
const dt = now - lastTime;
|
||||||
|
if (dt > 0) {
|
||||||
|
vSamples.push((lastY - y) / dt);
|
||||||
|
if (vSamples.length > 5) vSamples.shift();
|
||||||
|
}
|
||||||
|
lastY = y;
|
||||||
|
lastTime = now;
|
||||||
|
|
||||||
|
const desired = startScroll - (y - startY);
|
||||||
|
const max = getMaxScroll();
|
||||||
|
|
||||||
|
if (desired < 0) {
|
||||||
|
scrollEl.scrollTop = 0;
|
||||||
|
setOverscroll(desired * 0.3);
|
||||||
|
} else if (desired > max) {
|
||||||
|
scrollEl.scrollTop = max;
|
||||||
|
setOverscroll((desired - max) * 0.3);
|
||||||
|
} else {
|
||||||
|
scrollEl.scrollTop = desired;
|
||||||
|
if (overscroll !== 0) setOverscroll(0);
|
||||||
|
}
|
||||||
|
showThumb();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onUp(ev: MouseEvent) {
|
||||||
|
ev.stopPropagation();
|
||||||
|
scrollEl.style.cursor = "";
|
||||||
|
window.removeEventListener("mousemove", onMove, true);
|
||||||
|
window.removeEventListener("mouseup", onUp, true);
|
||||||
|
|
||||||
|
if (overscroll !== 0) { springBack(); return; }
|
||||||
|
|
||||||
|
// Momentum coast
|
||||||
|
const avgV = vSamples.length > 0
|
||||||
|
? vSamples.reduce((a, b) => a + b, 0) / vSamples.length : 0;
|
||||||
|
const v0 = Math.max(-4, Math.min(4, avgV));
|
||||||
|
if (Math.abs(v0) < 0.005) return;
|
||||||
|
|
||||||
|
const tau = 300;
|
||||||
|
const coastStart = performance.now();
|
||||||
|
const scrollStart2 = scrollEl.scrollTop;
|
||||||
|
const totalDist = v0 * tau;
|
||||||
|
|
||||||
|
function coast() {
|
||||||
|
const t = performance.now() - coastStart;
|
||||||
|
const decay = Math.exp(-t / tau);
|
||||||
|
const offset = totalDist * (1 - decay);
|
||||||
|
const target = scrollStart2 + offset;
|
||||||
|
const max = getMaxScroll();
|
||||||
|
|
||||||
|
if (target < 0) {
|
||||||
|
scrollEl.scrollTop = 0;
|
||||||
|
const bounce = Math.min(30, Math.abs(v0 * decay) * 40);
|
||||||
|
setOverscroll(-bounce);
|
||||||
|
springBack();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (target > max) {
|
||||||
|
scrollEl.scrollTop = max;
|
||||||
|
const bounce = Math.min(30, Math.abs(v0 * decay) * 40);
|
||||||
|
setOverscroll(bounce);
|
||||||
|
springBack();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollEl.scrollTop = target;
|
||||||
|
showThumb();
|
||||||
|
if (Math.abs(v0 * decay) > 0.0005) {
|
||||||
|
coastFrame = requestAnimationFrame(coast);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
coastFrame = requestAnimationFrame(coast);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("mousemove", onMove, true);
|
||||||
|
window.addEventListener("mouseup", onUp, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onWheel(e: WheelEvent) {
|
||||||
|
e.stopPropagation();
|
||||||
|
const { scrollTop, scrollHeight, clientHeight } = scrollEl;
|
||||||
|
const atTop = scrollTop <= 0 && e.deltaY < 0;
|
||||||
|
const atBottom = scrollTop + clientHeight >= scrollHeight && e.deltaY > 0;
|
||||||
|
if (!atTop && !atBottom) {
|
||||||
|
e.preventDefault();
|
||||||
|
scrollEl.scrollTop += e.deltaY;
|
||||||
|
showThumb();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
node.addEventListener("mousedown", onDown);
|
||||||
|
node.addEventListener("wheel", onWheel, { passive: false });
|
||||||
|
|
||||||
|
return {
|
||||||
|
destroy() {
|
||||||
|
node.removeEventListener("mousedown", onDown);
|
||||||
|
node.removeEventListener("wheel", onWheel);
|
||||||
|
forceReset();
|
||||||
|
if (hideTimer) clearTimeout(hideTimer);
|
||||||
|
thumb.remove();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<!-- Add custom activity -->
|
||||||
|
<div class="flex gap-2 items-center">
|
||||||
|
<div class="flex-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Add custom activity..."
|
||||||
|
aria-label="New activity text"
|
||||||
|
maxlength={500}
|
||||||
|
class="w-full rounded-xl border border-[#161616] bg-black px-3 py-2.5 text-[13px]
|
||||||
|
text-white outline-none placeholder:text-[#2a2a2a] focus:border-[#333]"
|
||||||
|
bind:value={newActivityText}
|
||||||
|
onkeydown={(e) => { if (e.key === "Enter") addCustomActivity(); }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<!-- Custom category dropdown -->
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div class="relative" bind:this={dropdownRef} onkeydown={handleDropdownKeydown} role="presentation">
|
||||||
|
<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-text-sec
|
||||||
|
hover:border-[#333] hover:text-white transition-colors"
|
||||||
|
onclick={() => { dropdownOpen = !dropdownOpen; }}
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
aria-expanded={dropdownOpen}
|
||||||
|
aria-label="Category: {getCategoryLabel(newActivityCategory)}"
|
||||||
|
>
|
||||||
|
<span>{getCategoryLabel(newActivityCategory)}</span>
|
||||||
|
<svg aria-hidden="true" class="w-3 h-3 opacity-50 transition-transform duration-200 {dropdownOpen ? 'rotate-180' : ''}" viewBox="0 0 12 12" fill="none">
|
||||||
|
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{#if dropdownOpen}
|
||||||
|
<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-text-sec hover:bg-[#1a1a1a] hover:text-white'}
|
||||||
|
{focusedOptionIndex === i ? 'bg-[#1a1a1a] text-white' : ''}"
|
||||||
|
role="option"
|
||||||
|
aria-selected={newActivityCategory === cat}
|
||||||
|
tabindex={focusedOptionIndex === i ? 0 : -1}
|
||||||
|
onclick={() => { newActivityCategory = cat; dropdownOpen = false; dropdownTriggerRef?.focus(); }}
|
||||||
|
>
|
||||||
|
{getCategoryLabel(cat)}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
use:pressable
|
||||||
|
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()}
|
||||||
|
aria-label="Add custom activity"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Category buttons -->
|
||||||
|
<div class="flex flex-wrap gap-1.5">
|
||||||
|
{#each categories as cat}
|
||||||
|
{@const total = builtinCount(cat) + customCount(cat)}
|
||||||
|
{@const disabled = disabledCount(cat)}
|
||||||
|
{@const isExpanded = expandedCategory === cat}
|
||||||
|
<button
|
||||||
|
use:pressable
|
||||||
|
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-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-text-sec' : 'text-text-sec opacity-60'}">
|
||||||
|
{total - disabled}/{total}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Animated accordion for activity list -->
|
||||||
|
{#each categories as cat}
|
||||||
|
{@const isExpanded = expandedCategory === cat}
|
||||||
|
{@const catBuiltins = breakActivities.filter((a) => a.category === cat)}
|
||||||
|
{@const catCustoms = $config.custom_activities.filter((a) => a.category === cat)}
|
||||||
|
<div class="accordion-wrapper" class:accordion-open={isExpanded} id="activity-panel-{cat}" role="region" aria-label="{getCategoryLabel(cat)} activities">
|
||||||
|
<div class="accordion-inner">
|
||||||
|
<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-text-sec uppercase">Custom</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="relative" use:innerDragScroll>
|
||||||
|
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||||
|
<div class="activity-scroll max-h-[200px] overflow-y-auto overflow-x-hidden" tabindex="0" role="group" aria-label="{getCategoryLabel(cat)} activity list">
|
||||||
|
<div class="scroll-content">
|
||||||
|
{#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 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-text-sec line-through'}">
|
||||||
|
{activity.text}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
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}"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
<ToggleSwitch
|
||||||
|
checked={activity.enabled}
|
||||||
|
label="Enable {activity.text}"
|
||||||
|
onchange={() => toggleCustomEnabled(activity.id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#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-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 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-text-sec' : 'text-text-sec line-through opacity-60'}">
|
||||||
|
{activity.text}
|
||||||
|
</span>
|
||||||
|
<ToggleSwitch
|
||||||
|
checked={!isBuiltinDisabled(activity.text)}
|
||||||
|
label="Enable {activity.text}"
|
||||||
|
onchange={() => toggleBuiltinEnabled(activity.text)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Accordion: CSS grid row transition for height animation */
|
||||||
|
.accordion-wrapper {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 0fr;
|
||||||
|
transition: grid-template-rows 0.3s cubic-bezier(0.22, 0.03, 0.26, 1);
|
||||||
|
}
|
||||||
|
.accordion-wrapper.accordion-open {
|
||||||
|
grid-template-rows: 1fr;
|
||||||
|
}
|
||||||
|
.accordion-inner {
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide native scrollbar completely */
|
||||||
|
.activity-scroll {
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
scrollbar-width: none; /* Firefox */
|
||||||
|
}
|
||||||
|
.activity-scroll::-webkit-scrollbar {
|
||||||
|
display: none; /* Chrome/Edge */
|
||||||
|
}
|
||||||
|
</style>
|
||||||
59
src/lib/components/BreakOverlay.svelte
Normal file
59
src/lib/components/BreakOverlay.svelte
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { listen } from "@tauri-apps/api/event";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { loadConfig, config } from "../stores/config";
|
||||||
|
import type { TimerSnapshot } from "../stores/timer";
|
||||||
|
|
||||||
|
let breakTimeRemaining = $state(0);
|
||||||
|
let breakTotalDuration = $state(0);
|
||||||
|
|
||||||
|
const progress = $derived(breakTotalDuration > 0 ? breakTimeRemaining / breakTotalDuration : 0);
|
||||||
|
|
||||||
|
function formatTime(secs: number): string {
|
||||||
|
const m = Math.floor(secs / 60);
|
||||||
|
const s = secs % 60;
|
||||||
|
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
await loadConfig();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const snap = await invoke<TimerSnapshot>("get_timer_state");
|
||||||
|
breakTimeRemaining = snap.breakTimeRemaining;
|
||||||
|
breakTotalDuration = snap.breakTotalDuration;
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
await listen<TimerSnapshot>("timer-tick", (event) => {
|
||||||
|
breakTimeRemaining = event.payload.breakTimeRemaining;
|
||||||
|
breakTotalDuration = event.payload.breakTotalDuration;
|
||||||
|
});
|
||||||
|
|
||||||
|
await listen("break-ended", () => {
|
||||||
|
// Window will be closed by backend
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</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">
|
||||||
|
{formatTime(breakTimeRemaining)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Progress bar -->
|
||||||
|
<div class="w-48 h-[3px] rounded-full overflow-hidden" style="background: rgba(255,255,255,0.06);">
|
||||||
|
<div
|
||||||
|
class="h-full transition-[width] duration-1000 ease-linear"
|
||||||
|
style="width: {(1 - progress) * 100}%; background: {$config.break_color};"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { scaleIn, fadeIn, pressable } from "../utils/animate";
|
import { scaleIn, fadeIn, pressable } from "../utils/animate";
|
||||||
import { pickRandomActivity, getCategoryIcon, getCategoryLabel, type BreakActivity } from "../utils/activities";
|
import { pickRandomActivity, getCategoryIcon, getCategoryLabel, type BreakActivity } from "../utils/activities";
|
||||||
|
import BreathingGuide from "./BreathingGuide.svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
standalone?: boolean;
|
standalone?: boolean;
|
||||||
@@ -16,14 +17,14 @@
|
|||||||
|
|
||||||
const appWindow = $derived(standalone ? getCurrentWebviewWindow() : null);
|
const appWindow = $derived(standalone ? getCurrentWebviewWindow() : null);
|
||||||
|
|
||||||
let currentActivity = $state<BreakActivity>(pickRandomActivity());
|
let currentActivity = $state<BreakActivity>(pickRandomActivity(undefined, $config));
|
||||||
let activityCycleTimer: ReturnType<typeof setInterval> | null = null;
|
let activityCycleTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
// Cycle activity every 30 seconds during break
|
// Cycle activity every 30 seconds during break
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if ($config.show_break_activities && $timer.state === "breakActive") {
|
if ($config.show_break_activities && $timer.state === "breakActive") {
|
||||||
activityCycleTimer = setInterval(() => {
|
activityCycleTimer = setInterval(() => {
|
||||||
currentActivity = pickRandomActivity(currentActivity);
|
currentActivity = pickRandomActivity(currentActivity, $config);
|
||||||
}, 30_000);
|
}, 30_000);
|
||||||
}
|
}
|
||||||
return () => {
|
return () => {
|
||||||
@@ -34,6 +35,9 @@
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// F3: Long break indicator
|
||||||
|
const isLongBreak = $derived($timer.isLongBreak);
|
||||||
|
|
||||||
async function cancelBreak() {
|
async function cancelBreak() {
|
||||||
const snap = await invoke<TimerSnapshot>("cancel_break");
|
const snap = await invoke<TimerSnapshot>("cancel_break");
|
||||||
timer.set(snap);
|
timer.set(snap);
|
||||||
@@ -65,6 +69,44 @@
|
|||||||
|
|
||||||
const showButtons = $derived(!$config.strict_mode);
|
const showButtons = $derived(!$config.strict_mode);
|
||||||
|
|
||||||
|
// Breathing guide bindable state
|
||||||
|
let breathPhase = $state("Inhale");
|
||||||
|
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));
|
||||||
|
|
||||||
|
// Interpolate color between break_color (inhale) and accent_color (exhale)
|
||||||
|
// breathScale: 0.6 = exhale (accent), 1.0 = inhale (break)
|
||||||
|
function hexToRgb(hex: string): [number, number, number] {
|
||||||
|
const h = hex.replace("#", "");
|
||||||
|
return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];
|
||||||
|
}
|
||||||
|
function lerpColor(c1: string, c2: string, t: number): string {
|
||||||
|
const [r1, g1, b1] = hexToRgb(c1);
|
||||||
|
const [r2, g2, b2] = hexToRgb(c2);
|
||||||
|
const r = Math.round(r1 + (r2 - r1) * t);
|
||||||
|
const g = Math.round(g1 + (g2 - g1) * t);
|
||||||
|
const b = Math.round(b1 + (b2 - b1) * t);
|
||||||
|
return `rgb(${r},${g},${b})`;
|
||||||
|
}
|
||||||
|
// t=0 at exhale (scale=0.6), t=1 at inhale (scale=1.0)
|
||||||
|
const breathT = $derived((breathScale - 0.6) / 0.4);
|
||||||
|
const breathColor = $derived(lerpColor($config.accent_color, $config.break_color, breathT));
|
||||||
|
|
||||||
// Bottom progress bar uses a gradient from break color to accent
|
// Bottom progress bar uses a gradient from break color to accent
|
||||||
const barGradient = $derived(
|
const barGradient = $derived(
|
||||||
`linear-gradient(to right, ${$config.break_color}, ${$config.accent_color})`,
|
`linear-gradient(to right, ${$config.break_color}, ${$config.accent_color})`,
|
||||||
@@ -105,7 +147,21 @@
|
|||||||
<div class="break-ripple ripple-2" style="--ripple-color: {$config.break_color}"></div>
|
<div class="break-ripple ripple-2" style="--ripple-color: {$config.break_color}"></div>
|
||||||
<div class="break-ripple ripple-3" style="--ripple-color: {$config.break_color}"></div>
|
<div class="break-ripple ripple-3" style="--ripple-color: {$config.break_color}"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="break-breathe">
|
<!-- Hidden: runs breathing animation logic, we only use its phase data -->
|
||||||
|
{#if $config.breathing_guide_enabled}
|
||||||
|
<div class="hidden" aria-hidden="true">
|
||||||
|
<BreathingGuide
|
||||||
|
pattern={$config.breathing_pattern}
|
||||||
|
size={0}
|
||||||
|
color={$config.break_color}
|
||||||
|
showLabel={false}
|
||||||
|
bind:phaseLabel={breathPhase}
|
||||||
|
bind:countdown={breathCountdown}
|
||||||
|
bind:breathScale={breathScale}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div>
|
||||||
<TimerRing
|
<TimerRing
|
||||||
progress={breakRingProgress}
|
progress={breakRingProgress}
|
||||||
size={140}
|
size={140}
|
||||||
@@ -114,13 +170,23 @@
|
|||||||
label="Break timer"
|
label="Break timer"
|
||||||
valueText="{formatTime($timer.breakTimeRemaining)} remaining"
|
valueText="{formatTime($timer.breakTimeRemaining)} remaining"
|
||||||
>
|
>
|
||||||
<div class="break-breathe-counter">
|
<div class="flex flex-col items-center">
|
||||||
<span
|
<span
|
||||||
class="font-semibold leading-none tabular-nums text-white text-[26px]"
|
class="font-semibold leading-none tabular-nums text-white text-[26px]"
|
||||||
style={$config.countdown_font ? `font-family: '${$config.countdown_font}', monospace;` : ""}
|
style={$config.countdown_font ? `font-family: '${$config.countdown_font}', monospace;` : ""}
|
||||||
>
|
>
|
||||||
{formatTime($timer.breakTimeRemaining)}
|
{formatTime($timer.breakTimeRemaining)}
|
||||||
</span>
|
</span>
|
||||||
|
{#if $config.breathing_guide_enabled}
|
||||||
|
<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-hidden="true"
|
||||||
|
>
|
||||||
|
{breathPhase}{breathCountdown > 0 ? ` ${breathCountdown}s` : ""}
|
||||||
|
</span>
|
||||||
|
<span class="sr-only" aria-live="polite" aria-atomic="true">{breathAnnouncement}</span>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</TimerRing>
|
</TimerRing>
|
||||||
</div>
|
</div>
|
||||||
@@ -131,16 +197,16 @@
|
|||||||
<h2 class="text-[17px] font-medium text-white mb-1.5" tabindex="-1">
|
<h2 class="text-[17px] font-medium text-white mb-1.5" tabindex="-1">
|
||||||
{$timer.breakTitle}
|
{$timer.breakTitle}
|
||||||
</h2>
|
</h2>
|
||||||
<p class="text-[12px] leading-relaxed text-[#8a8a8a] mb-4 max-w-[240px]">
|
<p class="text-[12px] leading-relaxed text-text-sec mb-4 max-w-[240px]">
|
||||||
{$timer.breakMessage}
|
{$timer.breakMessage}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{#if $config.show_break_activities}
|
{#if $config.show_break_activities}
|
||||||
<div class="rounded-xl border border-[#ffffff08] bg-[#ffffff06] px-4 py-2.5 mb-4 max-w-[260px]">
|
<div class="rounded-xl border border-[#ffffff08] bg-[#ffffff06] px-4 py-2.5 mb-4 max-w-[260px]">
|
||||||
<div class="text-[9px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase mb-1">
|
<div class="text-[9px] font-medium tracking-[0.15em] text-text-sec uppercase mb-1">
|
||||||
{getCategoryLabel(currentActivity.category)}
|
{getCategoryLabel(currentActivity.category)}
|
||||||
</div>
|
</div>
|
||||||
<p class="text-[12px] leading-relaxed text-[#999]" aria-live="polite">
|
<p class="text-[12px] leading-relaxed text-text-sec" aria-live="polite">
|
||||||
{currentActivity.text}
|
{currentActivity.text}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -151,7 +217,7 @@
|
|||||||
<button
|
<button
|
||||||
use:pressable
|
use:pressable
|
||||||
class="rounded-full border border-[#333] px-5 py-2 text-[11px]
|
class="rounded-full border border-[#333] px-5 py-2 text-[11px]
|
||||||
tracking-wider text-[#8a8a8a] uppercase
|
tracking-wider text-text-sec uppercase
|
||||||
transition-colors duration-200
|
transition-colors duration-200
|
||||||
hover:border-[#444] hover:text-[#ccc]"
|
hover:border-[#444] hover:text-[#ccc]"
|
||||||
onclick={cancelBreak}
|
onclick={cancelBreak}
|
||||||
@@ -172,10 +238,15 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if $config.snooze_limit > 0}
|
{#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
|
{$timer.snoozesUsed}/{$config.snooze_limit} snoozes used
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||||
|
<span tabindex="0" class="sr-only" aria-live="polite">
|
||||||
|
Break in progress, please wait
|
||||||
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -209,7 +280,22 @@
|
|||||||
<div class="break-ripple ripple-3" style="--ripple-color: {$config.break_color}; --ripple-size: {isModal ? 160 : 200}px"></div>
|
<div class="break-ripple ripple-3" style="--ripple-color: {$config.break_color}; --ripple-size: {isModal ? 160 : 200}px"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="break-breathe relative">
|
<!-- Hidden: runs breathing animation logic, we only use its phase data -->
|
||||||
|
{#if $config.breathing_guide_enabled}
|
||||||
|
<div class="hidden" aria-hidden="true">
|
||||||
|
<BreathingGuide
|
||||||
|
pattern={$config.breathing_pattern}
|
||||||
|
size={0}
|
||||||
|
color={$config.break_color}
|
||||||
|
showLabel={false}
|
||||||
|
bind:phaseLabel={breathPhase}
|
||||||
|
bind:countdown={breathCountdown}
|
||||||
|
bind:breathScale={breathScale}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="relative">
|
||||||
<TimerRing
|
<TimerRing
|
||||||
progress={breakRingProgress}
|
progress={breakRingProgress}
|
||||||
size={isModal ? 160 : 200}
|
size={isModal ? 160 : 200}
|
||||||
@@ -218,7 +304,7 @@
|
|||||||
label="Break timer"
|
label="Break timer"
|
||||||
valueText="{formatTime($timer.breakTimeRemaining)} remaining"
|
valueText="{formatTime($timer.breakTimeRemaining)} remaining"
|
||||||
>
|
>
|
||||||
<div class="break-breathe-counter">
|
<div class="flex flex-col items-center">
|
||||||
<span
|
<span
|
||||||
class="font-semibold leading-none tabular-nums text-white"
|
class="font-semibold leading-none tabular-nums text-white"
|
||||||
class:text-[30px]={isModal}
|
class:text-[30px]={isModal}
|
||||||
@@ -227,17 +313,39 @@
|
|||||||
>
|
>
|
||||||
{formatTime($timer.breakTimeRemaining)}
|
{formatTime($timer.breakTimeRemaining)}
|
||||||
</span>
|
</span>
|
||||||
|
{#if $config.breathing_guide_enabled}
|
||||||
|
<span
|
||||||
|
class="block mt-2 tracking-wider uppercase text-center font-medium"
|
||||||
|
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-hidden="true"
|
||||||
|
>
|
||||||
|
{breathPhase}{breathCountdown > 0 ? ` ${breathCountdown}s` : ""}
|
||||||
|
</span>
|
||||||
|
<span class="sr-only" aria-live="polite" aria-atomic="true">{breathAnnouncement}</span>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</TimerRing>
|
</TimerRing>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- F3: Long break badge -->
|
||||||
|
{#if isLongBreak}
|
||||||
|
<div class="mb-2 rounded-full px-3 py-1 text-[10px] font-medium tracking-wider uppercase"
|
||||||
|
style="background: {$config.break_color}20; color: {$config.break_color}; border: 1px solid {$config.break_color}30;"
|
||||||
|
use:fadeIn={{ delay: 0.2, y: 8 }}
|
||||||
|
>
|
||||||
|
Long break
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<h2 class="mb-2 text-lg font-medium text-white" tabindex="-1" use:fadeIn={{ delay: 0.25, y: 10 }}>
|
<h2 class="mb-2 text-lg font-medium text-white" tabindex="-1" use:fadeIn={{ delay: 0.25, y: 10 }}>
|
||||||
{$timer.breakTitle}
|
{$timer.breakTitle}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<p
|
<p
|
||||||
class="max-w-[300px] text-center text-[13px] leading-relaxed text-[#8a8a8a]"
|
class="max-w-[300px] text-center text-[13px] leading-relaxed text-text-sec"
|
||||||
class:mb-4={$config.show_break_activities}
|
class:mb-4={$config.show_break_activities}
|
||||||
class:mb-8={!$config.show_break_activities}
|
class:mb-8={!$config.show_break_activities}
|
||||||
use:fadeIn={{ delay: 0.35, y: 10 }}
|
use:fadeIn={{ delay: 0.35, y: 10 }}
|
||||||
@@ -250,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"
|
class="mb-8 mx-auto max-w-[320px] rounded-2xl border border-[#1a1a1a] bg-[#0a0a0a] px-5 py-3.5 text-center"
|
||||||
use:fadeIn={{ delay: 0.4, y: 10 }}
|
use:fadeIn={{ delay: 0.4, y: 10 }}
|
||||||
>
|
>
|
||||||
<div class="mb-1.5 text-[10px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
|
<div class="mb-1.5 text-[10px] font-medium tracking-[0.15em] text-text-sec uppercase">
|
||||||
{getCategoryLabel(currentActivity.category)}
|
{getCategoryLabel(currentActivity.category)}
|
||||||
</div>
|
</div>
|
||||||
<p class="text-[13px] leading-relaxed text-[#999]" aria-live="polite">
|
<p class="text-[13px] leading-relaxed text-text-sec" aria-live="polite">
|
||||||
{currentActivity.text}
|
{currentActivity.text}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -263,8 +371,8 @@
|
|||||||
<div class="flex items-center gap-3" use:fadeIn={{ delay: 0.45, y: 10 }}>
|
<div class="flex items-center gap-3" use:fadeIn={{ delay: 0.45, y: 10 }}>
|
||||||
<button
|
<button
|
||||||
use:pressable
|
use:pressable
|
||||||
class="rounded-full border border-[#222] px-6 py-2.5 text-[12px]
|
class="rounded-full border border-border px-6 py-2.5 text-[12px]
|
||||||
tracking-wider text-[#8a8a8a] uppercase
|
tracking-wider text-text-sec uppercase
|
||||||
transition-colors duration-200
|
transition-colors duration-200
|
||||||
hover:border-[#333] hover:text-[#ccc]"
|
hover:border-[#333] hover:text-[#ccc]"
|
||||||
onclick={cancelBreak}
|
onclick={cancelBreak}
|
||||||
@@ -285,10 +393,15 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if $config.snooze_limit > 0}
|
{#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
|
{$timer.snoozesUsed}/{$config.snooze_limit} snoozes used
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||||
|
<span tabindex="0" class="sr-only" aria-live="polite">
|
||||||
|
Break in progress, please wait
|
||||||
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Bottom progress bar for modal -->
|
<!-- Bottom progress bar for modal -->
|
||||||
@@ -411,23 +524,6 @@
|
|||||||
background: rgba(255, 255, 255, 0.05);
|
background: rgba(255, 255, 255, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Breathing pulse on the ring ── */
|
|
||||||
.break-breathe {
|
|
||||||
animation: breathe 4s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
@keyframes breathe {
|
|
||||||
0%, 100% { transform: scale(1); }
|
|
||||||
50% { transform: scale(1.04); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.break-breathe-counter {
|
|
||||||
animation: breathe-counter 4s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
@keyframes breathe-counter {
|
|
||||||
0%, 100% { transform: scale(1); }
|
|
||||||
50% { transform: scale(0.962); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Ripple circles ── */
|
/* ── Ripple circles ── */
|
||||||
.break-ripple {
|
.break-ripple {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
183
src/lib/components/BreathingGuide.svelte
Normal file
183
src/lib/components/BreathingGuide.svelte
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
pattern?: string;
|
||||||
|
size?: number;
|
||||||
|
color?: string;
|
||||||
|
showLabel?: boolean;
|
||||||
|
phaseLabel?: string;
|
||||||
|
countdown?: number;
|
||||||
|
breathScale?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
pattern = "box",
|
||||||
|
size = 200,
|
||||||
|
color = "#7c6aef",
|
||||||
|
showLabel = true,
|
||||||
|
phaseLabel = $bindable("Inhale"),
|
||||||
|
countdown = $bindable(4),
|
||||||
|
breathScale = $bindable(0.6),
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
// Breathing patterns: arrays of [phase, durationSeconds]
|
||||||
|
const patterns: Record<string, [string, number][]> = {
|
||||||
|
box: [["Inhale", 4], ["Hold", 4], ["Exhale", 4], ["Hold", 4]],
|
||||||
|
relaxing: [["Inhale", 4], ["Hold", 7], ["Exhale", 8]],
|
||||||
|
energizing: [["Inhale", 6], ["Hold", 2], ["Exhale", 6], ["Hold", 2]],
|
||||||
|
calm: [["Inhale", 4], ["Hold", 4], ["Exhale", 6]],
|
||||||
|
deep: [["Inhale", 5], ["Exhale", 5]],
|
||||||
|
};
|
||||||
|
|
||||||
|
const phases = $derived(patterns[pattern] ?? patterns.box);
|
||||||
|
const totalCycleDuration = $derived(phases.reduce((sum, [, d]) => sum + d, 0));
|
||||||
|
let scale = $state(0.6);
|
||||||
|
let animationId: number | null = null;
|
||||||
|
let startTime = 0;
|
||||||
|
|
||||||
|
function animate(timestamp: number) {
|
||||||
|
if (!startTime) startTime = timestamp;
|
||||||
|
const elapsed = ((timestamp - startTime) / 1000) % totalCycleDuration;
|
||||||
|
|
||||||
|
let accumulated = 0;
|
||||||
|
for (const [label, duration] of phases) {
|
||||||
|
if (elapsed < accumulated + duration) {
|
||||||
|
phaseLabel = label;
|
||||||
|
const phaseElapsed = elapsed - accumulated;
|
||||||
|
countdown = Math.ceil(duration - phaseElapsed);
|
||||||
|
|
||||||
|
// Calculate scale based on phase
|
||||||
|
const t = phaseElapsed / duration;
|
||||||
|
if (label === "Inhale") {
|
||||||
|
scale = 0.6 + 0.4 * t; // 60% -> 100%
|
||||||
|
} else if (label === "Exhale") {
|
||||||
|
scale = 1.0 - 0.4 * t; // 100% -> 60%
|
||||||
|
}
|
||||||
|
// Hold phases keep current scale
|
||||||
|
breathScale = scale;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
accumulated += duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
animationId = requestAnimationFrame(animate);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
// Check reduced motion preference
|
||||||
|
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
||||||
|
phaseLabel = "Breathe";
|
||||||
|
countdown = 0;
|
||||||
|
scale = 0.8;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
animationId = requestAnimationFrame(animate);
|
||||||
|
return () => {
|
||||||
|
if (animationId !== null) cancelAnimationFrame(animationId);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const circleR = $derived(size / 2 - 8);
|
||||||
|
const cx = $derived(size / 2);
|
||||||
|
const cy = $derived(size / 2);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="breathing-guide" style="width: {size}px; height: {size}px;">
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 {size} {size}"
|
||||||
|
class="breathing-svg"
|
||||||
|
aria-label="Breathing guide: {phaseLabel}"
|
||||||
|
role="img"
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<filter id="breathing-glow" x="-50%" y="-50%" width="200%" height="200%">
|
||||||
|
<feGaussianBlur stdDeviation="12" result="blur" />
|
||||||
|
<feMerge>
|
||||||
|
<feMergeNode in="blur" />
|
||||||
|
<feMergeNode in="SourceGraphic" />
|
||||||
|
</feMerge>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- Outer glow circle -->
|
||||||
|
<circle
|
||||||
|
cx={cx}
|
||||||
|
cy={cy}
|
||||||
|
r={circleR * scale}
|
||||||
|
fill="none"
|
||||||
|
stroke={color}
|
||||||
|
stroke-width="2"
|
||||||
|
opacity="0.15"
|
||||||
|
filter="url(#breathing-glow)"
|
||||||
|
class="transition-r"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Main circle -->
|
||||||
|
<circle
|
||||||
|
cx={cx}
|
||||||
|
cy={cy}
|
||||||
|
r={circleR * scale}
|
||||||
|
fill="none"
|
||||||
|
stroke={color}
|
||||||
|
stroke-width="2"
|
||||||
|
opacity="0.4"
|
||||||
|
class="transition-r"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Inner fill -->
|
||||||
|
<circle
|
||||||
|
cx={cx}
|
||||||
|
cy={cy}
|
||||||
|
r={circleR * scale * 0.85}
|
||||||
|
fill={color}
|
||||||
|
opacity="0.06"
|
||||||
|
class="transition-r"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<!-- Phase label + countdown -->
|
||||||
|
{#if showLabel}
|
||||||
|
<div class="breathing-label">
|
||||||
|
<span class="text-[14px] font-medium text-white tracking-wider uppercase opacity-80">
|
||||||
|
{phaseLabel}
|
||||||
|
</span>
|
||||||
|
{#if countdown > 0}
|
||||||
|
<span class="text-[24px] font-semibold text-white tabular-nums mt-0.5">
|
||||||
|
{countdown}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.breathing-guide {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breathing-svg {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breathing-label {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 1;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Smooth radius transitions via CSS */
|
||||||
|
.transition-r {
|
||||||
|
transition: r 0.3s ease-out, opacity 0.3s ease-out;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
307
src/lib/components/Celebration.svelte
Normal file
307
src/lib/components/Celebration.svelte
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { milestoneEvent, dailyGoalEvent } from "../stores/timer";
|
||||||
|
import { config } from "../stores/config";
|
||||||
|
|
||||||
|
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(
|
||||||
|
showMilestone
|
||||||
|
? Array.from({ length: 24 }, (_, i) => ({
|
||||||
|
id: i,
|
||||||
|
color: confettiColors[i % confettiColors.length],
|
||||||
|
angle: (i / 24) * 360 + Math.random() * 15,
|
||||||
|
distance: 60 + Math.random() * 80,
|
||||||
|
delay: Math.random() * 0.3,
|
||||||
|
size: 4 + Math.random() * 4,
|
||||||
|
}))
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if showMilestone}
|
||||||
|
<!-- 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"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Confetti burst -->
|
||||||
|
<div class="confetti-container">
|
||||||
|
{#each confettiParticles as p (p.id)}
|
||||||
|
<div
|
||||||
|
class="confetti-particle"
|
||||||
|
style="
|
||||||
|
--angle: {p.angle}deg;
|
||||||
|
--distance: {p.distance}px;
|
||||||
|
--delay: {p.delay}s;
|
||||||
|
--size: {p.size}px;
|
||||||
|
background: {p.color};
|
||||||
|
"
|
||||||
|
></div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Milestone text -->
|
||||||
|
<div class="celebration-text">
|
||||||
|
<div class="text-[32px] font-bold text-white mb-1">{streakDays}</div>
|
||||||
|
<div class="text-[13px] font-medium tracking-wider uppercase text-white opacity-80">
|
||||||
|
day streak!
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showGoal && !showMilestone}
|
||||||
|
<!-- 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"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.celebration-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 9999;
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity 0.6s ease;
|
||||||
|
animation: celebration-enter 0.3s ease forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.celebration-overlay.fading {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes celebration-enter {
|
||||||
|
0% { opacity: 0; }
|
||||||
|
100% { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.confetti-container {
|
||||||
|
position: absolute;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confetti-particle {
|
||||||
|
position: absolute;
|
||||||
|
width: var(--size);
|
||||||
|
height: var(--size);
|
||||||
|
border-radius: 2px;
|
||||||
|
animation: confetti-burst 1.2s ease-out var(--delay) forwards;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes confetti-burst {
|
||||||
|
0% {
|
||||||
|
transform: translate(0, 0) rotate(0deg) scale(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
30% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform:
|
||||||
|
translate(
|
||||||
|
calc(cos(var(--angle)) * var(--distance)),
|
||||||
|
calc(sin(var(--angle)) * var(--distance) + 40px)
|
||||||
|
)
|
||||||
|
rotate(720deg)
|
||||||
|
scale(1);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.celebration-text {
|
||||||
|
text-align: center;
|
||||||
|
animation: celebration-pop 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
|
||||||
|
text-shadow: 0 2px 20px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes celebration-pop {
|
||||||
|
0% { transform: scale(0.5); opacity: 0; }
|
||||||
|
100% { transform: scale(1); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.goal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 9999;
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity 0.6s ease;
|
||||||
|
animation: goal-enter 0.35s ease forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.goal-overlay.fading {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes goal-enter {
|
||||||
|
0% { transform: translateX(-50%) translateY(-20px); opacity: 0; }
|
||||||
|
100% { transform: translateX(-50%) translateY(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.goal-badge {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: rgba(63, 185, 80, 0.12);
|
||||||
|
border: 1px solid rgba(63, 185, 80, 0.25);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 10px 18px;
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.celebration-overlay,
|
||||||
|
.confetti-particle,
|
||||||
|
.celebration-text,
|
||||||
|
.goal-overlay {
|
||||||
|
animation: none;
|
||||||
|
opacity: 1;
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
.celebration-overlay.fading,
|
||||||
|
.goal-overlay.fading {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
.confetti-particle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -231,7 +231,7 @@
|
|||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div class="text-[13px] text-white">{label}</div>
|
<div class="text-[13px] text-white">{label}</div>
|
||||||
<div class="font-mono text-[11px] text-[#8a8a8a]">{value}</div>
|
<div class="font-mono text-[11px] text-text-sec">{value}</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="h-6 w-6 rounded-full border border-[#333] shadow-[0_0_8px_0px] transition-shadow duration-300"
|
class="h-6 w-6 rounded-full border border-[#333] shadow-[0_0_8px_0px] transition-shadow duration-300"
|
||||||
@@ -240,31 +240,41 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Preset swatches -->
|
<!-- Preset swatches -->
|
||||||
<div class="flex flex-wrap gap-[6px]">
|
<div class="flex flex-wrap gap-[8px]">
|
||||||
{#each presets as color}
|
{#each presets as color}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="h-[22px] w-[22px] rounded-full transition-all duration-150
|
class="group flex items-center justify-center min-h-[44px] min-w-[44px] bg-transparent border-none p-0"
|
||||||
{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};"
|
|
||||||
onclick={() => selectPreset(color)}
|
onclick={() => selectPreset(color)}
|
||||||
aria-label="Select {getColorName(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}
|
{/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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="h-[22px] w-[22px] rounded-full transition-all duration-150
|
class="group flex items-center justify-center min-h-[44px] min-w-[44px] bg-transparent border-none p-0"
|
||||||
{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);"
|
|
||||||
onclick={() => { showCustom = !showCustom; }}
|
onclick={() => { showCustom = !showCustom; }}
|
||||||
aria-label="Custom color"
|
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>
|
</div>
|
||||||
|
|
||||||
<!-- Inline custom picker — slides open/closed -->
|
<!-- Inline custom picker — slides open/closed -->
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
import { config } from "../stores/config";
|
import { config } from "../stores/config";
|
||||||
import TimerRing from "./TimerRing.svelte";
|
import TimerRing from "./TimerRing.svelte";
|
||||||
import { scaleIn, fadeIn, pressable, glowHover } from "../utils/animate";
|
import { scaleIn, fadeIn, pressable, glowHover } from "../utils/animate";
|
||||||
|
import { listen } from "@tauri-apps/api/event";
|
||||||
|
|
||||||
async function toggleTimer() {
|
async function toggleTimer() {
|
||||||
const snap = await invoke<TimerSnapshot>("toggle_timer");
|
const snap = await invoke<TimerSnapshot>("toggle_timer");
|
||||||
@@ -28,7 +29,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const statusText = $derived(
|
const statusText = $derived(
|
||||||
$timer.idlePaused
|
$timer.deferredBreakPending
|
||||||
|
? "DEFERRED"
|
||||||
|
: $timer.idlePaused
|
||||||
? "IDLE"
|
? "IDLE"
|
||||||
: $timer.prebreakWarning
|
: $timer.prebreakWarning
|
||||||
? "BREAK SOON"
|
? "BREAK SOON"
|
||||||
@@ -37,6 +40,34 @@
|
|||||||
: "PAUSED",
|
: "PAUSED",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// F1: Microbreak countdown
|
||||||
|
const microbreakCountdown = $derived(() => {
|
||||||
|
if (!$timer.microbreakEnabled || $timer.microbreakActive) return "";
|
||||||
|
const secs = $timer.microbreakCountdown;
|
||||||
|
const m = Math.floor(secs / 60);
|
||||||
|
const s = secs % 60;
|
||||||
|
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// F10: Daily goal from stats
|
||||||
|
let dailyGoalProgress = $state(0);
|
||||||
|
let dailyGoalMet = $state(false);
|
||||||
|
|
||||||
|
// Load stats for daily goal display
|
||||||
|
async function loadGoalProgress() {
|
||||||
|
try {
|
||||||
|
const stats = await invoke<{ dailyGoalProgress: number; dailyGoalMet: boolean }>("get_stats");
|
||||||
|
dailyGoalProgress = stats.dailyGoalProgress;
|
||||||
|
dailyGoalMet = stats.dailyGoalMet;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
// Reload goal progress on each tick (approximately)
|
||||||
|
const _state = $timer.state;
|
||||||
|
loadGoalProgress();
|
||||||
|
});
|
||||||
|
|
||||||
// Track status changes for aria-live region (announce only on change, not every tick)
|
// Track status changes for aria-live region (announce only on change, not every tick)
|
||||||
let lastAnnouncedStatus = $state("");
|
let lastAnnouncedStatus = $state("");
|
||||||
let statusAnnouncement = $state("");
|
let statusAnnouncement = $state("");
|
||||||
@@ -81,6 +112,7 @@
|
|||||||
|
|
||||||
// Natural break notification
|
// Natural break notification
|
||||||
let showNaturalBreakToast = $state(false);
|
let showNaturalBreakToast = $state(false);
|
||||||
|
let toastHovering = $state(false);
|
||||||
let naturalBreakToastTimeout: ReturnType<typeof setTimeout> | null = null;
|
let naturalBreakToastTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
// Watch for natural break detection
|
// Watch for natural break detection
|
||||||
@@ -89,7 +121,9 @@
|
|||||||
showNaturalBreakToast = true;
|
showNaturalBreakToast = true;
|
||||||
if (naturalBreakToastTimeout) clearTimeout(naturalBreakToastTimeout);
|
if (naturalBreakToastTimeout) clearTimeout(naturalBreakToastTimeout);
|
||||||
naturalBreakToastTimeout = setTimeout(() => {
|
naturalBreakToastTimeout = setTimeout(() => {
|
||||||
|
if (!toastHovering) {
|
||||||
showNaturalBreakToast = false;
|
showNaturalBreakToast = false;
|
||||||
|
}
|
||||||
}, 5000);
|
}, 5000);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -146,11 +180,83 @@
|
|||||||
<!-- Status label -->
|
<!-- Status label -->
|
||||||
<span
|
<span
|
||||||
class="block text-center text-[11px] font-medium tracking-[0.25em]"
|
class="block text-center text-[11px] font-medium tracking-[0.25em]"
|
||||||
class:text-[#8a8a8a]={!$timer.prebreakWarning}
|
class:text-text-sec={!$timer.prebreakWarning && !$timer.deferredBreakPending}
|
||||||
class:text-warning={$timer.prebreakWarning}
|
class:text-warning={$timer.prebreakWarning}
|
||||||
|
class:text-[#fca311]={$timer.deferredBreakPending}
|
||||||
>
|
>
|
||||||
{statusText}
|
{statusText}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
<!-- Indicators inside ring -->
|
||||||
|
<div class="mt-2 flex flex-col items-center gap-1">
|
||||||
|
<!-- Pomodoro cycle -->
|
||||||
|
{#if $timer.pomodoroEnabled}
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
{#each Array($timer.pomodoroTotalInCycle) as _, i}
|
||||||
|
{@const isLong = i === $timer.pomodoroTotalInCycle - 1}
|
||||||
|
{@const isFilled = i < $timer.pomodoroCyclePosition}
|
||||||
|
{@const isCurrent = i === $timer.pomodoroCyclePosition}
|
||||||
|
<div
|
||||||
|
class="rounded-full transition-colors duration-300"
|
||||||
|
style="
|
||||||
|
width: {isLong ? 8 : 5}px;
|
||||||
|
height: {isLong ? 8 : 5}px;
|
||||||
|
background: {isFilled ? $config.accent_color : isCurrent ? $config.accent_color + '60' : '#222'};
|
||||||
|
{isCurrent ? 'box-shadow: 0 0 4px ' + $config.accent_color + '40;' : ''}
|
||||||
|
"
|
||||||
|
></div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<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-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"/>
|
||||||
|
</svg>
|
||||||
|
<span class="tabular-nums">{microbreakCountdown()}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Daily goal -->
|
||||||
|
{#if $config.daily_goal_enabled}
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
{#if dailyGoalMet}
|
||||||
|
<svg aria-hidden="true" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#3fb950" stroke-width="2.5">
|
||||||
|
<path d="M20 6L9 17l-5-5"/>
|
||||||
|
</svg>
|
||||||
|
<span class="text-[9px] text-[#3fb950]">Goal met</span>
|
||||||
|
{:else}
|
||||||
|
<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-text-sec tabular-nums">
|
||||||
|
{dailyGoalProgress}/{$config.daily_goal_breaks}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TimerRing>
|
</TimerRing>
|
||||||
</div>
|
</div>
|
||||||
@@ -159,7 +265,7 @@
|
|||||||
<!-- Last break info -->
|
<!-- Last break info -->
|
||||||
<div use:fadeIn={{ delay: 0.4, y: 10 }}>
|
<div use:fadeIn={{ delay: 0.4, y: 10 }}>
|
||||||
{#if $timer.hasHadBreak}
|
{#if $timer.hasHadBreak}
|
||||||
<p style="margin-bottom: {ringMargin}px;" class="text-[12px] text-[#8a8a8a]">
|
<p style="margin-bottom: {ringMargin}px;" class="text-[12px] text-text-sec">
|
||||||
Last break {formatDurationAgo($timer.secondsSinceLastBreak)}
|
Last break {formatDurationAgo($timer.secondsSinceLastBreak)}
|
||||||
</p>
|
</p>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -169,17 +275,24 @@
|
|||||||
|
|
||||||
<!-- Natural break notification toast -->
|
<!-- Natural break notification toast -->
|
||||||
{#if showNaturalBreakToast}
|
{#if showNaturalBreakToast}
|
||||||
|
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||||
<div
|
<div
|
||||||
role="alert"
|
role="alert"
|
||||||
class="absolute top-6 left-1/2 -translate-x-1/2 rounded-2xl border px-5 py-3 text-center backdrop-blur-xl transition-all duration-500"
|
class="absolute top-6 left-1/2 -translate-x-1/2 rounded-2xl border px-5 py-3 text-center backdrop-blur-xl transition-all duration-500"
|
||||||
style="border-color: rgba(63, 185, 80, 0.3); background: rgba(63, 185, 80, 0.1);"
|
style="border-color: rgba(63, 185, 80, 0.3); background: rgba(63, 185, 80, 0.1);"
|
||||||
use:scaleIn={{ duration: 0.3, delay: 0 }}
|
use:scaleIn={{ duration: 0.3, delay: 0 }}
|
||||||
|
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">
|
<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">
|
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#3fb950" stroke-width="2">
|
||||||
<path d="M20 6L9 17l-5-5"/>
|
<path d="M20 6L9 17l-5-5"/>
|
||||||
</svg>
|
</svg>
|
||||||
<span class="text-[13px] text-[#3fb950]">Natural break detected - timer reset</span>
|
<span class="text-[13px] text-[#3fb950]">Natural break detected - timer reset</span>
|
||||||
|
<button onclick={() => showNaturalBreakToast = false} class="ml-2 text-text-sec hover:text-white" aria-label="Dismiss notification">×</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -198,13 +311,15 @@
|
|||||||
{toggleBtnText}
|
{toggleBtnText}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<!-- Bottom navigation buttons -->
|
||||||
|
<nav aria-label="Main actions" class="contents">
|
||||||
<!-- Bottom left: start break now -->
|
<!-- Bottom left: start break now -->
|
||||||
<div class="absolute bottom-5 left-5" use:fadeIn={{ delay: 0.5, y: 8 }}>
|
<div class="absolute bottom-5 left-5" use:fadeIn={{ delay: 0.5, y: 8 }}>
|
||||||
<button
|
<button
|
||||||
aria-label="Start break now"
|
aria-label="Start break now"
|
||||||
use:pressable
|
use:pressable
|
||||||
class="flex h-11 w-11 items-center justify-center rounded-full
|
class="flex h-11 w-11 items-center justify-center rounded-full
|
||||||
border border-[#222] text-[#8a8a8a]
|
border border-border text-text-sec
|
||||||
transition-colors duration-200
|
transition-colors duration-200
|
||||||
hover:border-[#333] hover:text-[#aaa]"
|
hover:border-[#333] hover:text-[#aaa]"
|
||||||
onclick={startBreakNow}
|
onclick={startBreakNow}
|
||||||
@@ -231,7 +346,7 @@
|
|||||||
aria-label="Statistics"
|
aria-label="Statistics"
|
||||||
use:pressable
|
use:pressable
|
||||||
class="flex h-11 w-11 items-center justify-center rounded-full
|
class="flex h-11 w-11 items-center justify-center rounded-full
|
||||||
border border-[#222] text-[#8a8a8a]
|
border border-border text-text-sec
|
||||||
transition-colors duration-200
|
transition-colors duration-200
|
||||||
hover:border-[#333] hover:text-[#aaa]"
|
hover:border-[#333] hover:text-[#aaa]"
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
@@ -263,7 +378,7 @@
|
|||||||
aria-label="Settings"
|
aria-label="Settings"
|
||||||
use:pressable
|
use:pressable
|
||||||
class="flex h-11 w-11 items-center justify-center rounded-full
|
class="flex h-11 w-11 items-center justify-center rounded-full
|
||||||
border border-[#222] text-[#8a8a8a]
|
border border-border text-text-sec
|
||||||
transition-colors duration-200
|
transition-colors duration-200
|
||||||
hover:border-[#333] hover:text-[#aaa]"
|
hover:border-[#333] hover:text-[#aaa]"
|
||||||
onclick={openSettings}
|
onclick={openSettings}
|
||||||
@@ -286,6 +401,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
23
src/lib/components/DimOverlay.svelte
Normal file
23
src/lib/components/DimOverlay.svelte
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { listen } from "@tauri-apps/api/event";
|
||||||
|
|
||||||
|
let opacity = $state(0);
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
await listen<{ progress: number; maxOpacity: number }>("screen-dim-update", (event) => {
|
||||||
|
opacity = event.payload.progress * event.payload.maxOpacity;
|
||||||
|
});
|
||||||
|
|
||||||
|
// When break starts, this window gets destroyed by backend
|
||||||
|
await listen("break-started", () => {
|
||||||
|
opacity = 0;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 pointer-events-none"
|
||||||
|
style="background: rgba(0, 0, 0, {opacity}); transition: opacity 1s linear;"
|
||||||
|
aria-hidden="true"
|
||||||
|
></div>
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div class="text-[13px] text-white">Countdown font</div>
|
<div class="text-[13px] text-white">Countdown font</div>
|
||||||
<div class="text-[11px] text-[#8a8a8a]">
|
<div class="text-[11px] text-text-sec">
|
||||||
{value || "System default"}
|
{value || "System default"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -60,7 +60,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
aria-expanded={expanded}
|
aria-expanded={expanded}
|
||||||
aria-label={expanded ? "Close font browser" : "Browse fonts"}
|
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"
|
transition-colors hover:border-[#333] hover:text-white"
|
||||||
onclick={() => { expanded = !expanded; }}
|
onclick={() => { expanded = !expanded; }}
|
||||||
>
|
>
|
||||||
@@ -83,7 +83,7 @@
|
|||||||
transition-all duration-150
|
transition-all duration-150
|
||||||
{value === font.family
|
{value === font.family
|
||||||
? 'border-white/30 bg-[#141414]'
|
? '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)}
|
onclick={() => selectFont(font.family)}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
@@ -92,7 +92,7 @@
|
|||||||
>
|
>
|
||||||
25:00
|
25:00
|
||||||
</span>
|
</span>
|
||||||
<span class="text-[9px] tracking-wider text-[#8a8a8a] uppercase">
|
<span class="text-[9px] tracking-wider text-text-sec uppercase">
|
||||||
{font.label}
|
{font.label}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
81
src/lib/components/MicrobreakOverlay.svelte
Normal file
81
src/lib/components/MicrobreakOverlay.svelte
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { listen } from "@tauri-apps/api/event";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import type { TimerSnapshot } from "../stores/timer";
|
||||||
|
import { loadConfig, config } from "../stores/config";
|
||||||
|
import { pickRandomActivity, getCategoryLabel, type BreakActivity } from "../utils/activities";
|
||||||
|
|
||||||
|
let timeRemaining = $state(20);
|
||||||
|
let totalDuration = $state(20);
|
||||||
|
let activity = $state<BreakActivity | null>(null);
|
||||||
|
|
||||||
|
const progress = $derived(totalDuration > 0 ? 1 - timeRemaining / totalDuration : 0);
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
await loadConfig();
|
||||||
|
|
||||||
|
if ($config.microbreak_show_activity) {
|
||||||
|
// Pick an eye-focused activity for microbreaks
|
||||||
|
activity = pickRandomActivity(undefined, $config);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const snap = await invoke<TimerSnapshot>("get_timer_state");
|
||||||
|
timeRemaining = snap.microbreakTimeRemaining;
|
||||||
|
totalDuration = snap.microbreakTotalDuration;
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
await listen<TimerSnapshot>("timer-tick", (event) => {
|
||||||
|
const snap = event.payload;
|
||||||
|
timeRemaining = snap.microbreakTimeRemaining;
|
||||||
|
totalDuration = snap.microbreakTotalDuration;
|
||||||
|
});
|
||||||
|
|
||||||
|
await listen("microbreak-ended", () => {
|
||||||
|
// Window will be closed by backend
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<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 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-text-sec mb-3 ml-[34px]">
|
||||||
|
{activity.text}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Progress bar -->
|
||||||
|
<div class="h-[3px] w-full rounded-full overflow-hidden" style="background: rgba(255,255,255,0.05);">
|
||||||
|
<div
|
||||||
|
class="h-full transition-[width] duration-1000 ease-linear rounded-full"
|
||||||
|
style="width: {progress * 100}%; background: linear-gradient(to right, #7c6aef, #4361ee);"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.microbreak-card {
|
||||||
|
position: fixed;
|
||||||
|
top: 16px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 380px;
|
||||||
|
background: rgba(12, 12, 12, 0.95);
|
||||||
|
backdrop-filter: blur(24px);
|
||||||
|
-webkit-backdrop-filter: blur(24px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -16,6 +16,9 @@
|
|||||||
let breakColor = $state("#7c6aef");
|
let breakColor = $state("#7c6aef");
|
||||||
let countdownFont = $state("");
|
let countdownFont = $state("");
|
||||||
let draggable = $state(false);
|
let draggable = $state(false);
|
||||||
|
let pomodoroEnabled = $state(false);
|
||||||
|
let pomodoroCyclePosition = $state(0);
|
||||||
|
let pomodoroTotalInCycle = $state(4);
|
||||||
|
|
||||||
// Use config store directly for live updates
|
// Use config store directly for live updates
|
||||||
const uiZoom = $derived($config.ui_zoom);
|
const uiZoom = $derived($config.ui_zoom);
|
||||||
@@ -132,6 +135,9 @@
|
|||||||
timeText = formatTime(snap.timeRemaining);
|
timeText = formatTime(snap.timeRemaining);
|
||||||
progress = snap.progress;
|
progress = snap.progress;
|
||||||
}
|
}
|
||||||
|
pomodoroEnabled = snap.pomodoroEnabled;
|
||||||
|
pomodoroCyclePosition = snap.pomodoroCyclePosition;
|
||||||
|
pomodoroTotalInCycle = snap.pomodoroTotalInCycle;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Click opens main window
|
// Click opens main window
|
||||||
@@ -329,10 +335,16 @@ const fontStyle = $derived(
|
|||||||
<!-- Countdown text -->
|
<!-- Countdown text -->
|
||||||
<span
|
<span
|
||||||
class="ml-2.5 text-[18px] font-semibold leading-none tabular-nums"
|
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}
|
{timeText}
|
||||||
</span>
|
</span>
|
||||||
|
<!-- F3: Pomodoro cycle indicator -->
|
||||||
|
{#if pomodoroEnabled}
|
||||||
|
<span class="ml-1.5 text-[10px] tabular-nums" style="color: #a8a8a8;">
|
||||||
|
{pomodoroCyclePosition + 1}/{pomodoroTotalInCycle}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -9,9 +9,13 @@
|
|||||||
todaySkipped: number;
|
todaySkipped: number;
|
||||||
todaySnoozed: number;
|
todaySnoozed: number;
|
||||||
todayBreakTimeSecs: number;
|
todayBreakTimeSecs: number;
|
||||||
|
todayNaturalBreaks: number;
|
||||||
|
todayNaturalBreakTimeSecs: number;
|
||||||
complianceRate: number;
|
complianceRate: number;
|
||||||
currentStreak: number;
|
currentStreak: number;
|
||||||
bestStreak: number;
|
bestStreak: number;
|
||||||
|
dailyGoalProgress: number;
|
||||||
|
dailyGoalMet: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DayRecord {
|
interface DayRecord {
|
||||||
@@ -22,13 +26,27 @@
|
|||||||
totalBreakTimeSecs: number;
|
totalBreakTimeSecs: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface WeekSummary {
|
||||||
|
weekStart: string;
|
||||||
|
totalCompleted: number;
|
||||||
|
totalSkipped: number;
|
||||||
|
totalBreakTimeSecs: number;
|
||||||
|
complianceRate: number;
|
||||||
|
avgDailyCompleted: number;
|
||||||
|
}
|
||||||
|
|
||||||
let stats = $state<StatsSnapshot | null>(null);
|
let stats = $state<StatsSnapshot | null>(null);
|
||||||
let history = $state<DayRecord[]>([]);
|
let history = $state<DayRecord[]>([]);
|
||||||
|
let monthHistory = $state<DayRecord[]>([]);
|
||||||
|
let weeklySummaries = $state<WeekSummary[]>([]);
|
||||||
|
let activeTab = $state<"today" | "weekly" | "monthly">("today");
|
||||||
|
|
||||||
async function loadStats() {
|
async function loadStats() {
|
||||||
try {
|
try {
|
||||||
stats = await invoke<StatsSnapshot>("get_stats");
|
stats = await invoke<StatsSnapshot>("get_stats");
|
||||||
history = await invoke<DayRecord[]>("get_daily_history", { days: 7 });
|
history = await invoke<DayRecord[]>("get_daily_history", { days: 7 });
|
||||||
|
monthHistory = await invoke<DayRecord[]>("get_daily_history", { days: 30 });
|
||||||
|
weeklySummaries = await invoke<WeekSummary[]>("get_weekly_summary", { weeks: 4 });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to load stats:", e);
|
console.error("Failed to load stats:", e);
|
||||||
}
|
}
|
||||||
@@ -56,7 +74,21 @@
|
|||||||
return `${hrs}h ${rem}m`;
|
return `${hrs}h ${rem}m`;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Chart rendering
|
// F10: Daily goal progress
|
||||||
|
const goalPercent = $derived(
|
||||||
|
$config.daily_goal_breaks > 0
|
||||||
|
? Math.min(100, Math.round(((stats?.dailyGoalProgress ?? 0) / $config.daily_goal_breaks) * 100))
|
||||||
|
: 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
// F10: Next milestone
|
||||||
|
const milestones = [3, 5, 7, 14, 21, 30, 50, 100, 365];
|
||||||
|
const nextMilestone = $derived(() => {
|
||||||
|
const current = stats?.currentStreak ?? 0;
|
||||||
|
return milestones.find((m) => m > current) ?? null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Chart rendering — 7-day
|
||||||
let chartCanvas: HTMLCanvasElement | undefined = $state();
|
let chartCanvas: HTMLCanvasElement | undefined = $state();
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -64,6 +96,22 @@
|
|||||||
drawChart(chartCanvas, history);
|
drawChart(chartCanvas, history);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Chart rendering — 30-day
|
||||||
|
let monthChartCanvas: HTMLCanvasElement | undefined = $state();
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!monthChartCanvas || monthHistory.length === 0) return;
|
||||||
|
drawChart(monthChartCanvas, monthHistory);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Heatmap canvas
|
||||||
|
let heatmapCanvas: HTMLCanvasElement | undefined = $state();
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!heatmapCanvas || monthHistory.length === 0) return;
|
||||||
|
drawHeatmap(heatmapCanvas, monthHistory);
|
||||||
|
});
|
||||||
|
|
||||||
function drawChart(canvas: HTMLCanvasElement, data: DayRecord[]) {
|
function drawChart(canvas: HTMLCanvasElement, data: DayRecord[]) {
|
||||||
const ctx = canvas.getContext("2d");
|
const ctx = canvas.getContext("2d");
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
@@ -78,8 +126,8 @@
|
|||||||
ctx.clearRect(0, 0, w, h);
|
ctx.clearRect(0, 0, w, h);
|
||||||
|
|
||||||
const maxBreaks = Math.max(1, ...data.map((d) => d.breaksCompleted + d.breaksSkipped));
|
const maxBreaks = Math.max(1, ...data.map((d) => d.breaksCompleted + d.breaksSkipped));
|
||||||
const barWidth = Math.floor((w - 40) / data.length) - 8;
|
const barWidth = Math.max(2, Math.floor((w - 40) / data.length) - (data.length > 10 ? 2 : 8));
|
||||||
const barGap = 8;
|
const barGap = data.length > 10 ? 2 : 8;
|
||||||
const chartHeight = h - 30;
|
const chartHeight = h - 30;
|
||||||
|
|
||||||
const accentColor = $config.accent_color || "#ff4d00";
|
const accentColor = $config.accent_color || "#ff4d00";
|
||||||
@@ -90,34 +138,87 @@
|
|||||||
const completedH = total > 0 ? (day.breaksCompleted / maxBreaks) * chartHeight : 0;
|
const completedH = total > 0 ? (day.breaksCompleted / maxBreaks) * chartHeight : 0;
|
||||||
const skippedH = total > 0 ? (day.breaksSkipped / maxBreaks) * chartHeight : 0;
|
const skippedH = total > 0 ? (day.breaksSkipped / maxBreaks) * chartHeight : 0;
|
||||||
|
|
||||||
// Completed bar
|
|
||||||
if (completedH > 0) {
|
if (completedH > 0) {
|
||||||
ctx.fillStyle = accentColor;
|
ctx.fillStyle = accentColor;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
const barY = chartHeight - completedH;
|
const barY = chartHeight - completedH;
|
||||||
roundedRect(ctx, x, barY, barWidth, completedH, 4);
|
roundedRect(ctx, x, barY, barWidth, completedH, Math.min(4, barWidth / 2));
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skipped bar (stacked on top)
|
|
||||||
if (skippedH > 0) {
|
if (skippedH > 0) {
|
||||||
ctx.fillStyle = "#333";
|
ctx.fillStyle = "#333";
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
const barY = chartHeight - completedH - skippedH;
|
const barY = chartHeight - completedH - skippedH;
|
||||||
roundedRect(ctx, x, barY, barWidth, skippedH, 4);
|
roundedRect(ctx, x, barY, barWidth, skippedH, Math.min(4, barWidth / 2));
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Day label
|
// Day label — show every Nth for 30-day
|
||||||
ctx.fillStyle = "#8a8a8a";
|
if (data.length <= 7 || i % 5 === 0) {
|
||||||
|
ctx.fillStyle = "#a8a8a8";
|
||||||
ctx.font = "10px -apple-system, sans-serif";
|
ctx.font = "10px -apple-system, sans-serif";
|
||||||
ctx.textAlign = "center";
|
ctx.textAlign = "center";
|
||||||
const label = day.date.slice(5); // "MM-DD"
|
const label = day.date.slice(5);
|
||||||
ctx.fillText(label, x + barWidth / 2, h - 5);
|
ctx.fillText(label, x + barWidth / 2, h - 5);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawHeatmap(canvas: HTMLCanvasElement, data: DayRecord[]) {
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const cellSize = 16;
|
||||||
|
const gap = 2;
|
||||||
|
const cols = 7; // days of week
|
||||||
|
const rows = Math.ceil(data.length / cols);
|
||||||
|
|
||||||
|
const w = cols * (cellSize + gap) - gap;
|
||||||
|
const h = rows * (cellSize + gap) - gap;
|
||||||
|
canvas.width = w * dpr;
|
||||||
|
canvas.height = h * dpr;
|
||||||
|
canvas.style.width = `${w}px`;
|
||||||
|
canvas.style.height = `${h}px`;
|
||||||
|
ctx.scale(dpr, dpr);
|
||||||
|
ctx.clearRect(0, 0, w, h);
|
||||||
|
|
||||||
|
const maxBreaks = Math.max(1, ...data.map((d) => d.breaksCompleted));
|
||||||
|
const accentColor = $config.accent_color || "#ff4d00";
|
||||||
|
|
||||||
|
// Parse accent color for intensity scaling
|
||||||
|
const r = parseInt(accentColor.slice(1, 3), 16);
|
||||||
|
const g = parseInt(accentColor.slice(3, 5), 16);
|
||||||
|
const b = parseInt(accentColor.slice(5, 7), 16);
|
||||||
|
|
||||||
|
data.forEach((day, i) => {
|
||||||
|
const col = i % cols;
|
||||||
|
const row = Math.floor(i / cols);
|
||||||
|
const x = col * (cellSize + gap);
|
||||||
|
const y = row * (cellSize + gap);
|
||||||
|
|
||||||
|
const intensity = day.breaksCompleted > 0
|
||||||
|
? Math.min(1, day.breaksCompleted / maxBreaks)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
if (intensity === 0) {
|
||||||
|
ctx.fillStyle = "#161616";
|
||||||
|
} else {
|
||||||
|
// Blend from dark (#161616 = 22,22,22) to accent color
|
||||||
|
const level = 0.2 + intensity * 0.8;
|
||||||
|
const cr = Math.round(22 + (r - 22) * level);
|
||||||
|
const cg = Math.round(22 + (g - 22) * level);
|
||||||
|
const cb = Math.round(22 + (b - 22) * level);
|
||||||
|
ctx.fillStyle = `rgb(${cr}, ${cg}, ${cb})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
roundedRect(ctx, x, y, cellSize, cellSize, 3);
|
||||||
|
ctx.fill();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Accessible chart summary
|
|
||||||
const chartAriaLabel = $derived(() => {
|
const chartAriaLabel = $derived(() => {
|
||||||
if (history.length === 0) return "No break history data available";
|
if (history.length === 0) return "No break history data available";
|
||||||
const total = history.reduce((sum, d) => sum + d.breaksCompleted, 0);
|
const total = history.reduce((sum, d) => sum + d.breaksCompleted, 0);
|
||||||
@@ -125,6 +226,15 @@
|
|||||||
return `Weekly break chart: ${total} completed and ${skipped} skipped over ${history.length} days`;
|
return `Weekly break chart: ${total} completed and ${skipped} skipped over ${history.length} days`;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Monthly aggregations
|
||||||
|
const monthTotalCompleted = $derived(monthHistory.reduce((s, d) => s + d.breaksCompleted, 0));
|
||||||
|
const monthTotalSkipped = $derived(monthHistory.reduce((s, d) => s + d.breaksSkipped, 0));
|
||||||
|
const monthTotalTime = $derived(monthHistory.reduce((s, d) => s + d.totalBreakTimeSecs, 0));
|
||||||
|
const monthAvgCompliance = $derived(() => {
|
||||||
|
const total = monthTotalCompleted + monthTotalSkipped;
|
||||||
|
return total > 0 ? Math.round((monthTotalCompleted / total) * 100) : 100;
|
||||||
|
});
|
||||||
|
|
||||||
function roundedRect(
|
function roundedRect(
|
||||||
ctx: CanvasRenderingContext2D,
|
ctx: CanvasRenderingContext2D,
|
||||||
x: number,
|
x: number,
|
||||||
@@ -156,8 +266,8 @@
|
|||||||
<button
|
<button
|
||||||
aria-label="Back to dashboard"
|
aria-label="Back to dashboard"
|
||||||
use:pressable
|
use:pressable
|
||||||
class="mr-3 flex h-8 w-8 items-center justify-center rounded-full
|
class="mr-3 flex h-10 w-10 min-h-[44px] min-w-[44px] items-center justify-center rounded-full
|
||||||
text-[#8a8a8a] transition-colors hover:text-white"
|
text-text-sec transition-colors hover:text-white"
|
||||||
onclick={goBack}
|
onclick={goBack}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@@ -183,23 +293,44 @@
|
|||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab navigation -->
|
||||||
|
<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
|
||||||
|
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-text-sec hover:text-white'}"
|
||||||
|
onclick={() => activeTab = tab as any}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Scrollable content -->
|
<!-- Scrollable content -->
|
||||||
<div class="flex-1 overflow-y-auto px-5 pb-6" use:dragScroll>
|
<div class="flex-1 overflow-y-auto px-5 pb-6" use:dragScroll>
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
|
|
||||||
|
{#if activeTab === "today"}
|
||||||
|
<div role="tabpanel" id="tabpanel-today" aria-labelledby="tab-today">
|
||||||
<!-- Today's summary -->
|
<!-- Today's summary -->
|
||||||
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0 }}>
|
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0 }}>
|
||||||
<h3
|
<h2 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-text-sec uppercase">
|
||||||
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase"
|
|
||||||
>
|
|
||||||
Today
|
Today
|
||||||
</h3>
|
</h2>
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<div class="text-[28px] font-semibold text-white tabular-nums">
|
<div class="text-[28px] font-semibold text-white tabular-nums">
|
||||||
{stats?.todayCompleted ?? 0}
|
{stats?.todayCompleted ?? 0}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-[11px] text-[#8a8a8a]">Breaks taken</div>
|
<div class="text-[11px] text-text-sec">Breaks taken</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<div class="text-[28px] font-semibold tabular-nums"
|
<div class="text-[28px] font-semibold tabular-nums"
|
||||||
@@ -207,61 +338,104 @@
|
|||||||
>
|
>
|
||||||
{compliancePercent}%
|
{compliancePercent}%
|
||||||
</div>
|
</div>
|
||||||
<div class="text-[11px] text-[#8a8a8a]">Compliance</div>
|
<div class="text-[11px] text-text-sec">Compliance</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<div class="text-[28px] font-semibold text-white tabular-nums">
|
<div class="text-[28px] font-semibold text-white tabular-nums">
|
||||||
{breakTimeFormatted()}
|
{breakTimeFormatted()}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-[11px] text-[#8a8a8a]">Break time</div>
|
<div class="text-[11px] text-text-sec">Break time</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<div class="text-[28px] font-semibold text-white tabular-nums">
|
<div class="text-[28px] font-semibold text-white tabular-nums">
|
||||||
{stats?.todaySkipped ?? 0}
|
{stats?.todaySkipped ?? 0}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-[11px] text-[#8a8a8a]">Skipped</div>
|
<div class="text-[11px] text-text-sec">Skipped</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- 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 }}>
|
||||||
|
<h2 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-text-sec uppercase">
|
||||||
|
Daily Goal
|
||||||
|
</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);">
|
||||||
|
<circle cx="32" cy="32" r="28" fill="none" stroke="#161616" stroke-width="4" />
|
||||||
|
<circle cx="32" cy="32" r="28" fill="none" stroke={$config.accent_color} stroke-width="4"
|
||||||
|
stroke-dasharray={2 * Math.PI * 28}
|
||||||
|
stroke-dashoffset={2 * Math.PI * 28 * (1 - goalPercent / 100)}
|
||||||
|
stroke-linecap="round"
|
||||||
|
class="transition-[stroke-dashoffset] duration-500"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div class="absolute inset-0 flex items-center justify-center">
|
||||||
|
<span class="text-[14px] font-semibold text-white tabular-nums">{goalPercent}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-[14px] text-white font-medium">
|
||||||
|
{stats?.dailyGoalProgress ?? 0} / {$config.daily_goal_breaks} breaks
|
||||||
|
</div>
|
||||||
|
<div class="text-[11px] text-text-sec">
|
||||||
|
{stats?.dailyGoalMet ? "Goal reached!" : `${$config.daily_goal_breaks - (stats?.dailyGoalProgress ?? 0)} more to go`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Streak -->
|
<!-- Streak -->
|
||||||
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.06 }}>
|
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.06 }}>
|
||||||
<h3
|
<h2 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-text-sec uppercase">
|
||||||
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase"
|
|
||||||
>
|
|
||||||
Streak
|
Streak
|
||||||
</h3>
|
</h2>
|
||||||
|
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div class="text-[13px] text-white">Current streak</div>
|
<div class="text-[13px] text-white">Current streak</div>
|
||||||
<div class="text-[11px] text-[#8a8a8a]">Consecutive days with breaks</div>
|
<div class="text-[11px] text-text-sec">Consecutive days with breaks</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-[24px] font-semibold tabular-nums" style="color: {$config.accent_color}">
|
<div class="text-[24px] font-semibold tabular-nums" style="color: {$config.accent_color}">
|
||||||
{stats?.currentStreak ?? 0}
|
{stats?.currentStreak ?? 0}
|
||||||
</div>
|
</div>
|
||||||
</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 class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div class="text-[13px] text-white">Best streak</div>
|
<div class="text-[13px] text-white">Best streak</div>
|
||||||
<div class="text-[11px] text-[#8a8a8a]">All-time record</div>
|
<div class="text-[11px] text-text-sec">All-time record</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-[24px] font-semibold text-white tabular-nums">
|
<div class="text-[24px] font-semibold text-white tabular-nums">
|
||||||
{stats?.bestStreak ?? 0}
|
{stats?.bestStreak ?? 0}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- F10: Next milestone -->
|
||||||
|
{#if nextMilestone()}
|
||||||
|
<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-text-sec">{nextMilestone()} day streak</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-[13px] text-text-sec tabular-nums">
|
||||||
|
{nextMilestone()! - (stats?.currentStreak ?? 0)} days away
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Weekly chart -->
|
<!-- Weekly chart -->
|
||||||
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.12 }}>
|
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.12 }}>
|
||||||
<h3
|
<h2 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-text-sec uppercase">
|
||||||
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase"
|
|
||||||
>
|
|
||||||
Last 7 Days
|
Last 7 Days
|
||||||
</h3>
|
</h2>
|
||||||
|
|
||||||
<!-- svelte-ignore a11y_no_interactive_element_to_noninteractive_role -->
|
<!-- svelte-ignore a11y_no_interactive_element_to_noninteractive_role -->
|
||||||
<canvas
|
<canvas
|
||||||
@@ -271,7 +445,6 @@
|
|||||||
aria-label={chartAriaLabel()}
|
aria-label={chartAriaLabel()}
|
||||||
></canvas>
|
></canvas>
|
||||||
|
|
||||||
<!-- Screen-reader accessible data table for the chart -->
|
|
||||||
{#if history.length > 0}
|
{#if history.length > 0}
|
||||||
<table class="sr-only">
|
<table class="sr-only">
|
||||||
<caption>Break history for the last {history.length} days</caption>
|
<caption>Break history for the last {history.length} days</caption>
|
||||||
@@ -290,7 +463,7 @@
|
|||||||
</table>
|
</table>
|
||||||
{/if}
|
{/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="flex items-center gap-1.5">
|
||||||
<div class="h-2 w-2 rounded-sm" style="background: {$config.accent_color}"></div>
|
<div class="h-2 w-2 rounded-sm" style="background: {$config.accent_color}"></div>
|
||||||
Completed
|
Completed
|
||||||
@@ -301,6 +474,175 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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 }}>
|
||||||
|
<h2 class="mb-3 text-[11px] font-medium tracking-[0.15em] text-text-sec uppercase">
|
||||||
|
Week of {week.weekStart}
|
||||||
|
</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-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-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-text-sec">Compliance</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between text-[11px]">
|
||||||
|
<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 ? '#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>
|
||||||
|
{:else if trend < 0}
|
||||||
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M6 9l6 6 6-6"/></svg>
|
||||||
|
{:else}
|
||||||
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M5 12h14"/></svg>
|
||||||
|
{/if}
|
||||||
|
{Math.abs(Math.round(trend * 100))}%
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</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 }}>
|
||||||
|
<h2 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-text-sec uppercase">
|
||||||
|
Last 30 Days
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y_no_interactive_element_to_noninteractive_role -->
|
||||||
|
<canvas
|
||||||
|
bind:this={monthChartCanvas}
|
||||||
|
class="h-[140px] w-full"
|
||||||
|
role="img"
|
||||||
|
aria-label="30-day break history chart"
|
||||||
|
></canvas>
|
||||||
|
|
||||||
|
{#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
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<div class="h-2 w-2 rounded-sm bg-[#333]"></div>
|
||||||
|
Skipped
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Heatmap -->
|
||||||
|
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.06 }}>
|
||||||
|
<h2 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-text-sec uppercase">
|
||||||
|
Activity Heatmap
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<!-- svelte-ignore a11y_no_interactive_element_to_noninteractive_role -->
|
||||||
|
<canvas
|
||||||
|
bind:this={heatmapCanvas}
|
||||||
|
role="img"
|
||||||
|
aria-label="30-day activity heatmap"
|
||||||
|
></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#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>
|
||||||
|
<div class="w-3 h-3 rounded-sm" style="background: {$config.accent_color}40;"></div>
|
||||||
|
<div class="w-3 h-3 rounded-sm" style="background: {$config.accent_color}80;"></div>
|
||||||
|
<div class="w-3 h-3 rounded-sm" style="background: {$config.accent_color}c0;"></div>
|
||||||
|
<div class="w-3 h-3 rounded-sm" style="background: {$config.accent_color};"></div>
|
||||||
|
</div>
|
||||||
|
<span>More</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Monthly totals -->
|
||||||
|
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.12 }}>
|
||||||
|
<h2 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-text-sec uppercase">
|
||||||
|
Monthly Summary
|
||||||
|
</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-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-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-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-text-sec">Avg daily breaks</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -55,20 +55,34 @@
|
|||||||
if (holdTimer) { clearTimeout(holdTimer); holdTimer = null; }
|
if (holdTimer) { clearTimeout(holdTimer); holdTimer = null; }
|
||||||
if (holdInterval) { clearTimeout(holdInterval as unknown as ReturnType<typeof setTimeout>); holdInterval = 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>
|
</script>
|
||||||
|
|
||||||
<div class="flex items-center gap-1.5" role="group" aria-label={label}>
|
<div class="flex items-center gap-1.5" role="group" aria-label={label}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Decrease"
|
aria-label="Decrease"
|
||||||
class="flex h-7 w-7 items-center justify-center rounded-lg
|
class="flex h-9 w-9 min-h-[44px] min-w-[44px] items-center justify-center rounded-lg
|
||||||
bg-[#141414] text-[#999] transition-colors
|
border border-[#3a3a3a] bg-[#1a1a1a] text-text-sec transition-colors
|
||||||
hover:bg-[#1c1c1c] hover:text-white
|
hover:bg-[#1c1c1c] hover:text-white
|
||||||
disabled:opacity-20"
|
disabled:opacity-20"
|
||||||
onmousedown={() => startHold(decrement)}
|
onmousedown={() => startHold(decrement)}
|
||||||
onmouseup={stopHold}
|
onmouseup={stopHold}
|
||||||
onmouseleave={stopHold}
|
onmouseleave={stopHold}
|
||||||
onclick={(e) => { if (e.detail === 0) decrement(); }}
|
onkeydown={(e) => handleKeydown(decrement, e)}
|
||||||
|
onkeyup={handleKeyup}
|
||||||
disabled={value <= min}
|
disabled={value <= min}
|
||||||
>
|
>
|
||||||
−
|
−
|
||||||
@@ -79,14 +93,15 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Increase"
|
aria-label="Increase"
|
||||||
class="flex h-7 w-7 items-center justify-center rounded-lg
|
class="flex h-9 w-9 min-h-[44px] min-w-[44px] items-center justify-center rounded-lg
|
||||||
bg-[#141414] text-[#999] transition-colors
|
border border-[#3a3a3a] bg-[#1a1a1a] text-text-sec transition-colors
|
||||||
hover:bg-[#1c1c1c] hover:text-white
|
hover:bg-[#1c1c1c] hover:text-white
|
||||||
disabled:opacity-20"
|
disabled:opacity-20"
|
||||||
onmousedown={() => startHold(increment)}
|
onmousedown={() => startHold(increment)}
|
||||||
onmouseup={stopHold}
|
onmouseup={stopHold}
|
||||||
onmouseleave={stopHold}
|
onmouseleave={stopHold}
|
||||||
onclick={(e) => { if (e.detail === 0) increment(); }}
|
onkeydown={(e) => handleKeydown(increment, e)}
|
||||||
|
onkeyup={handleKeyup}
|
||||||
disabled={value >= max}
|
disabled={value >= max}
|
||||||
>
|
>
|
||||||
+
|
+
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Invisible drag region – traffic lights on the right -->
|
<!-- Invisible drag region – traffic lights on the right -->
|
||||||
<div
|
<header
|
||||||
data-tauri-drag-region
|
data-tauri-drag-region
|
||||||
class="group absolute top-0 left-0 right-0 z-50 flex h-10 items-center justify-end pr-3.5 select-none"
|
class="group absolute top-0 left-0 right-0 z-50 flex h-10 items-center justify-end pr-3.5 select-none"
|
||||||
>
|
>
|
||||||
@@ -20,15 +20,14 @@
|
|||||||
</span>
|
</span>
|
||||||
|
|
||||||
<!-- Traffic light buttons (order: maximize, minimize, close → close is rightmost) -->
|
<!-- Traffic light buttons (order: maximize, minimize, close → close is rightmost) -->
|
||||||
<div class="flex items-center gap-[8px] opacity-10 transition-opacity duration-300 group-hover:opacity-100 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) -->
|
<!-- Maximize (green) -->
|
||||||
<button
|
<button
|
||||||
aria-label="Maximize"
|
aria-label="Maximize"
|
||||||
class="traffic-btn group/btn relative flex h-[15px] w-[15px] items-center justify-center
|
class="group/btn relative flex h-[44px] w-[44px] items-center justify-center"
|
||||||
rounded-full bg-[#27C93F] transition-all duration-150
|
|
||||||
hover:brightness-110"
|
|
||||||
onclick={() => appWindow.toggleMaximize()}
|
onclick={() => appWindow.toggleMaximize()}
|
||||||
>
|
>
|
||||||
|
<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
|
<svg
|
||||||
class="absolute opacity-0 transition-opacity duration-150 group-hover/btn:opacity-100"
|
class="absolute opacity-0 transition-opacity duration-150 group-hover/btn:opacity-100"
|
||||||
width="8"
|
width="8"
|
||||||
@@ -40,16 +39,16 @@
|
|||||||
<polyline points="7,3 7,1 5,1" 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" />
|
<line x1="1" y1="7" x2="7" y2="1" stroke="#006500" stroke-width="1.2" stroke-linecap="round" />
|
||||||
</svg>
|
</svg>
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Minimize (yellow) -->
|
<!-- Minimize (yellow) -->
|
||||||
<button
|
<button
|
||||||
aria-label="Minimize"
|
aria-label="Minimize"
|
||||||
class="traffic-btn group/btn relative flex h-[15px] w-[15px] items-center justify-center
|
class="group/btn relative flex h-[44px] w-[44px] items-center justify-center"
|
||||||
rounded-full bg-[#FFBD2E] transition-all duration-150
|
|
||||||
hover:brightness-110"
|
|
||||||
onclick={() => appWindow.minimize()}
|
onclick={() => appWindow.minimize()}
|
||||||
>
|
>
|
||||||
|
<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
|
<svg
|
||||||
class="absolute opacity-0 transition-opacity duration-150 group-hover/btn:opacity-100"
|
class="absolute opacity-0 transition-opacity duration-150 group-hover/btn:opacity-100"
|
||||||
width="8"
|
width="8"
|
||||||
@@ -59,16 +58,16 @@
|
|||||||
>
|
>
|
||||||
<line x1="1" y1="1" x2="7" y2="1" stroke="#995700" stroke-width="1.3" stroke-linecap="round" />
|
<line x1="1" y1="1" x2="7" y2="1" stroke="#995700" stroke-width="1.3" stroke-linecap="round" />
|
||||||
</svg>
|
</svg>
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Close (red) — rightmost -->
|
<!-- Close (red) — rightmost -->
|
||||||
<button
|
<button
|
||||||
aria-label="Close"
|
aria-label="Close"
|
||||||
class="traffic-btn group/btn relative flex h-[15px] w-[15px] items-center justify-center
|
class="group/btn relative flex h-[44px] w-[44px] items-center justify-center"
|
||||||
rounded-full bg-[#FF5F57] transition-all duration-150
|
|
||||||
hover:brightness-110"
|
|
||||||
onclick={() => appWindow.close()}
|
onclick={() => appWindow.close()}
|
||||||
>
|
>
|
||||||
|
<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
|
<svg
|
||||||
class="absolute opacity-0 transition-opacity duration-150 group-hover/btn:opacity-100"
|
class="absolute opacity-0 transition-opacity duration-150 group-hover/btn:opacity-100"
|
||||||
width="8"
|
width="8"
|
||||||
@@ -79,6 +78,7 @@
|
|||||||
<line x1="1.5" y1="1.5" x2="6.5" y2="6.5" stroke="#4a0002" stroke-width="1.3" stroke-linecap="round" />
|
<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" />
|
<line x1="6.5" y1="1.5" x2="1.5" y2="6.5" stroke="#4a0002" stroke-width="1.3" stroke-linecap="round" />
|
||||||
</svg>
|
</svg>
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</header>
|
||||||
|
|||||||
@@ -15,19 +15,24 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- 44px hit area wrapper (WCAG 2.5.8) with compact visual toggle inside -->
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="switch"
|
role="switch"
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
aria-checked={checked}
|
aria-checked={checked}
|
||||||
class="relative inline-flex h-[24px] w-[48px] shrink-0 cursor-pointer rounded-full
|
class="relative inline-flex min-h-[44px] min-w-[52px] shrink-0 cursor-pointer items-center justify-center
|
||||||
transition-colors duration-200 ease-in-out"
|
bg-transparent border-none p-0"
|
||||||
style="background: {checked ? $config.accent_color : '#1a1a1a'};"
|
|
||||||
onclick={toggle}
|
onclick={toggle}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="pointer-events-none inline-block h-[19px] w-[19px] rounded-full
|
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
|
shadow-sm transition-transform duration-200 ease-in-out
|
||||||
{checked ? 'translate-x-[26px] bg-white' : 'translate-x-[3px] bg-[#444]'} mt-[2.5px]"
|
{checked ? 'translate-x-[27px] bg-white' : 'translate-x-[3px] bg-[#666]'}"
|
||||||
></span>
|
></span>
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -11,7 +11,13 @@ export interface DaySchedule {
|
|||||||
ranges: TimeRange[];
|
ranges: TimeRange[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type { TimeRange, DaySchedule };
|
export interface CustomActivity {
|
||||||
|
id: string;
|
||||||
|
category: string;
|
||||||
|
text: string;
|
||||||
|
is_favorite: boolean;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Config {
|
export interface Config {
|
||||||
break_duration: number;
|
break_duration: number;
|
||||||
@@ -24,7 +30,7 @@ export interface Config {
|
|||||||
allow_end_early: boolean;
|
allow_end_early: boolean;
|
||||||
immediately_start_breaks: boolean;
|
immediately_start_breaks: boolean;
|
||||||
working_hours_enabled: boolean;
|
working_hours_enabled: boolean;
|
||||||
working_hours_schedule: DaySchedule[]; // 7 days: Mon, Tue, Wed, Thu, Fri, Sat, Sun
|
working_hours_schedule: DaySchedule[];
|
||||||
dark_mode: boolean;
|
dark_mode: boolean;
|
||||||
color_scheme: string;
|
color_scheme: string;
|
||||||
backdrop_opacity: number;
|
backdrop_opacity: number;
|
||||||
@@ -49,6 +55,45 @@ export interface Config {
|
|||||||
background_blobs_enabled: boolean;
|
background_blobs_enabled: boolean;
|
||||||
mini_click_through: boolean;
|
mini_click_through: boolean;
|
||||||
mini_hover_threshold: number;
|
mini_hover_threshold: number;
|
||||||
|
// F8: Auto-start on login
|
||||||
|
auto_start_on_login: boolean;
|
||||||
|
// F6: Custom activities
|
||||||
|
custom_activities: CustomActivity[];
|
||||||
|
disabled_builtin_activities: string[];
|
||||||
|
favorite_builtin_activities: string[];
|
||||||
|
favorite_weight: number;
|
||||||
|
// F4: Breathing guide
|
||||||
|
breathing_guide_enabled: boolean;
|
||||||
|
breathing_pattern: string;
|
||||||
|
// F10: Gamification
|
||||||
|
daily_goal_enabled: boolean;
|
||||||
|
daily_goal_breaks: number;
|
||||||
|
milestone_celebrations: boolean;
|
||||||
|
streak_notifications: boolean;
|
||||||
|
// F1: Microbreaks
|
||||||
|
microbreak_enabled: boolean;
|
||||||
|
microbreak_frequency: number;
|
||||||
|
microbreak_duration: number;
|
||||||
|
microbreak_sound_enabled: boolean;
|
||||||
|
microbreak_show_activity: boolean;
|
||||||
|
microbreak_pause_during_break: boolean;
|
||||||
|
// F3: Pomodoro
|
||||||
|
pomodoro_enabled: boolean;
|
||||||
|
pomodoro_short_breaks: number;
|
||||||
|
pomodoro_long_break_duration: number;
|
||||||
|
pomodoro_long_break_title: string;
|
||||||
|
pomodoro_long_break_message: string;
|
||||||
|
pomodoro_reset_on_skip: boolean;
|
||||||
|
// F5: Screen dimming
|
||||||
|
screen_dim_enabled: boolean;
|
||||||
|
screen_dim_seconds: number;
|
||||||
|
screen_dim_max_opacity: number;
|
||||||
|
// F2: Presentation mode
|
||||||
|
presentation_mode_enabled: boolean;
|
||||||
|
presentation_mode_defer_microbreaks: boolean;
|
||||||
|
presentation_mode_notification: boolean;
|
||||||
|
// F9: Multi-monitor
|
||||||
|
multi_monitor_break: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultConfig: Config = {
|
const defaultConfig: Config = {
|
||||||
@@ -63,13 +108,13 @@ const defaultConfig: Config = {
|
|||||||
immediately_start_breaks: false,
|
immediately_start_breaks: false,
|
||||||
working_hours_enabled: false,
|
working_hours_enabled: false,
|
||||||
working_hours_schedule: [
|
working_hours_schedule: [
|
||||||
{ enabled: true, ranges: [{ start: "09:00", end: "18:00" }] }, // Monday
|
{ enabled: true, ranges: [{ start: "09:00", end: "18:00" }] },
|
||||||
{ enabled: true, ranges: [{ start: "09:00", end: "18:00" }] }, // Tuesday
|
{ enabled: true, ranges: [{ start: "09:00", end: "18:00" }] },
|
||||||
{ enabled: true, ranges: [{ start: "09:00", end: "18:00" }] }, // Wednesday
|
{ enabled: true, ranges: [{ start: "09:00", end: "18:00" }] },
|
||||||
{ enabled: true, ranges: [{ start: "09:00", end: "18:00" }] }, // Thursday
|
{ enabled: true, ranges: [{ start: "09:00", end: "18:00" }] },
|
||||||
{ enabled: true, ranges: [{ start: "09:00", end: "18:00" }] }, // Friday
|
{ enabled: true, ranges: [{ start: "09:00", end: "18:00" }] },
|
||||||
{ enabled: false, ranges: [{ start: "09:00", end: "18:00" }] }, // Saturday
|
{ enabled: false, ranges: [{ start: "09:00", end: "18:00" }] },
|
||||||
{ enabled: false, ranges: [{ start: "09:00", end: "18:00" }] }, // Sunday
|
{ enabled: false, ranges: [{ start: "09:00", end: "18:00" }] },
|
||||||
],
|
],
|
||||||
dark_mode: true,
|
dark_mode: true,
|
||||||
color_scheme: "Ocean",
|
color_scheme: "Ocean",
|
||||||
@@ -95,6 +140,45 @@ const defaultConfig: Config = {
|
|||||||
background_blobs_enabled: true,
|
background_blobs_enabled: true,
|
||||||
mini_click_through: true,
|
mini_click_through: true,
|
||||||
mini_hover_threshold: 3.0,
|
mini_hover_threshold: 3.0,
|
||||||
|
// F8
|
||||||
|
auto_start_on_login: false,
|
||||||
|
// F6
|
||||||
|
custom_activities: [],
|
||||||
|
disabled_builtin_activities: [],
|
||||||
|
favorite_builtin_activities: [],
|
||||||
|
favorite_weight: 3,
|
||||||
|
// F4
|
||||||
|
breathing_guide_enabled: true,
|
||||||
|
breathing_pattern: "box",
|
||||||
|
// F10
|
||||||
|
daily_goal_enabled: true,
|
||||||
|
daily_goal_breaks: 8,
|
||||||
|
milestone_celebrations: true,
|
||||||
|
streak_notifications: true,
|
||||||
|
// F1
|
||||||
|
microbreak_enabled: false,
|
||||||
|
microbreak_frequency: 20,
|
||||||
|
microbreak_duration: 20,
|
||||||
|
microbreak_sound_enabled: true,
|
||||||
|
microbreak_show_activity: true,
|
||||||
|
microbreak_pause_during_break: true,
|
||||||
|
// F3
|
||||||
|
pomodoro_enabled: false,
|
||||||
|
pomodoro_short_breaks: 3,
|
||||||
|
pomodoro_long_break_duration: 15,
|
||||||
|
pomodoro_long_break_title: "Long break",
|
||||||
|
pomodoro_long_break_message: "Great work! Take a longer rest.",
|
||||||
|
pomodoro_reset_on_skip: false,
|
||||||
|
// F5
|
||||||
|
screen_dim_enabled: false,
|
||||||
|
screen_dim_seconds: 10,
|
||||||
|
screen_dim_max_opacity: 0.3,
|
||||||
|
// F2
|
||||||
|
presentation_mode_enabled: true,
|
||||||
|
presentation_mode_defer_microbreaks: true,
|
||||||
|
presentation_mode_notification: true,
|
||||||
|
// F9
|
||||||
|
multi_monitor_break: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const config = writable<Config>(defaultConfig);
|
export const config = writable<Config>(defaultConfig);
|
||||||
|
|||||||
@@ -26,6 +26,27 @@ export interface TimerSnapshot {
|
|||||||
naturalBreakOccurred: boolean;
|
naturalBreakOccurred: boolean;
|
||||||
smartBreaksEnabled: boolean;
|
smartBreaksEnabled: boolean;
|
||||||
smartBreakThreshold: number;
|
smartBreakThreshold: number;
|
||||||
|
// F1: Microbreaks
|
||||||
|
microbreakEnabled: boolean;
|
||||||
|
microbreakActive: boolean;
|
||||||
|
microbreakTimeRemaining: number;
|
||||||
|
microbreakTotalDuration: number;
|
||||||
|
microbreakCountdown: number;
|
||||||
|
microbreakFrequency: number;
|
||||||
|
// F3: Pomodoro
|
||||||
|
pomodoroEnabled: boolean;
|
||||||
|
pomodoroCyclePosition: number;
|
||||||
|
pomodoroTotalInCycle: number;
|
||||||
|
pomodoroIsLongBreak: boolean;
|
||||||
|
pomodoroNextIsLong: boolean;
|
||||||
|
// F5: Screen dimming
|
||||||
|
screenDimActive: boolean;
|
||||||
|
screenDimProgress: number;
|
||||||
|
// F2: Presentation mode
|
||||||
|
presentationModeActive: boolean;
|
||||||
|
deferredBreakPending: boolean;
|
||||||
|
// F10: Gamification
|
||||||
|
isLongBreak: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultSnapshot: TimerSnapshot = {
|
const defaultSnapshot: TimerSnapshot = {
|
||||||
@@ -50,6 +71,22 @@ const defaultSnapshot: TimerSnapshot = {
|
|||||||
naturalBreakOccurred: false,
|
naturalBreakOccurred: false,
|
||||||
smartBreaksEnabled: true,
|
smartBreaksEnabled: true,
|
||||||
smartBreakThreshold: 300,
|
smartBreakThreshold: 300,
|
||||||
|
microbreakEnabled: false,
|
||||||
|
microbreakActive: false,
|
||||||
|
microbreakTimeRemaining: 0,
|
||||||
|
microbreakTotalDuration: 0,
|
||||||
|
microbreakCountdown: 0,
|
||||||
|
microbreakFrequency: 1200,
|
||||||
|
pomodoroEnabled: false,
|
||||||
|
pomodoroCyclePosition: 0,
|
||||||
|
pomodoroTotalInCycle: 4,
|
||||||
|
pomodoroIsLongBreak: false,
|
||||||
|
pomodoroNextIsLong: false,
|
||||||
|
screenDimActive: false,
|
||||||
|
screenDimProgress: 0,
|
||||||
|
presentationModeActive: false,
|
||||||
|
deferredBreakPending: false,
|
||||||
|
isLongBreak: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const timer = writable<TimerSnapshot>(defaultSnapshot);
|
export const timer = writable<TimerSnapshot>(defaultSnapshot);
|
||||||
@@ -114,7 +151,42 @@ export async function initTimerStore() {
|
|||||||
playSound(cfg.sound_preset as any, cfg.sound_volume * 0.5);
|
playSound(cfg.sound_preset as any, cfg.sound_volume * 0.5);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// F1: Microbreak events
|
||||||
|
await listen("microbreak-started", () => {
|
||||||
|
const cfg = get(config);
|
||||||
|
if (cfg.microbreak_sound_enabled && cfg.sound_enabled) {
|
||||||
|
playSound(cfg.sound_preset as any, cfg.sound_volume * 0.4);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await listen("microbreak-ended", () => {
|
||||||
|
const cfg = get(config);
|
||||||
|
if (cfg.microbreak_sound_enabled && cfg.sound_enabled) {
|
||||||
|
playBreakEndSound(cfg.sound_preset as any, cfg.sound_volume * 0.3);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// F10: Milestone and daily goal events
|
||||||
|
await listen<{ streak: number }>("milestone-reached", (event) => {
|
||||||
|
milestoneEvent.set(event.payload.streak);
|
||||||
|
setTimeout(() => milestoneEvent.set(null), 4000);
|
||||||
|
});
|
||||||
|
|
||||||
|
await listen("daily-goal-met", () => {
|
||||||
|
dailyGoalEvent.set(true);
|
||||||
|
setTimeout(() => dailyGoalEvent.set(false), 4000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// F2: Break deferred
|
||||||
|
await listen("break-deferred", () => {
|
||||||
|
// Dashboard will show deferred status from snapshot
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// F10: Gamification event stores
|
||||||
|
export const milestoneEvent = writable<number | null>(null);
|
||||||
|
export const dailyGoalEvent = writable<boolean>(false);
|
||||||
|
|
||||||
// Helper: format seconds as MM:SS
|
// Helper: format seconds as MM:SS
|
||||||
export function formatTime(secs: number): string {
|
export function formatTime(secs: number): string {
|
||||||
|
|||||||
@@ -106,10 +106,55 @@ export function getCategoryLabel(cat: BreakActivity["category"]): string {
|
|||||||
return categoryLabels[cat];
|
return categoryLabels[cat];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Pick a random activity, optionally excluding a previous one */
|
/** Pick a random activity, optionally excluding a previous one.
|
||||||
export function pickRandomActivity(exclude?: BreakActivity): BreakActivity {
|
* When config is provided, respects disabled/favorite/custom activity settings. */
|
||||||
const pool = exclude
|
export function pickRandomActivity(
|
||||||
? breakActivities.filter((a) => a.text !== exclude.text)
|
exclude?: BreakActivity,
|
||||||
: breakActivities;
|
config?: {
|
||||||
return pool[Math.floor(Math.random() * pool.length)];
|
disabled_builtin_activities?: string[];
|
||||||
|
favorite_builtin_activities?: string[];
|
||||||
|
custom_activities?: Array<{ category: string; text: string; is_favorite: boolean; enabled: boolean }>;
|
||||||
|
favorite_weight?: number;
|
||||||
|
},
|
||||||
|
): BreakActivity {
|
||||||
|
const disabled = new Set(config?.disabled_builtin_activities ?? []);
|
||||||
|
const favorites = new Set(config?.favorite_builtin_activities ?? []);
|
||||||
|
const weight = config?.favorite_weight ?? 3;
|
||||||
|
|
||||||
|
// Build pool: enabled builtins + enabled customs
|
||||||
|
let pool: BreakActivity[] = breakActivities.filter((a) => !disabled.has(a.text));
|
||||||
|
|
||||||
|
// Add enabled custom activities
|
||||||
|
if (config?.custom_activities) {
|
||||||
|
for (const ca of config.custom_activities) {
|
||||||
|
if (ca.enabled) {
|
||||||
|
const cat = (["eyes", "stretch", "breathing", "movement"].includes(ca.category)
|
||||||
|
? ca.category
|
||||||
|
: "movement") as BreakActivity["category"];
|
||||||
|
pool.push({ category: cat, text: ca.text });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exclude previous
|
||||||
|
if (exclude) {
|
||||||
|
pool = pool.filter((a) => a.text !== exclude.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pool.length === 0) {
|
||||||
|
return exclude ?? breakActivities[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build weighted pool: favorites appear `weight` times
|
||||||
|
const weighted: BreakActivity[] = [];
|
||||||
|
for (const a of pool) {
|
||||||
|
const isFav = favorites.has(a.text) ||
|
||||||
|
(config?.custom_activities?.some((c) => c.text === a.text && c.is_favorite) ?? false);
|
||||||
|
const count = isFav ? weight : 1;
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
weighted.push(a);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return weighted[Math.floor(Math.random() * weighted.length)];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -140,15 +140,32 @@ 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("mousedown", onDown);
|
||||||
node.addEventListener("mouseup", onUp);
|
node.addEventListener("mouseup", onUp);
|
||||||
node.addEventListener("mouseleave", onUp);
|
node.addEventListener("mouseleave", onUp);
|
||||||
|
node.addEventListener("keydown", onKeyDown);
|
||||||
|
node.addEventListener("keyup", onKeyUp);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
destroy() {
|
destroy() {
|
||||||
node.removeEventListener("mousedown", onDown);
|
node.removeEventListener("mousedown", onDown);
|
||||||
node.removeEventListener("mouseup", onUp);
|
node.removeEventListener("mouseup", onUp);
|
||||||
node.removeEventListener("mouseleave", onUp);
|
node.removeEventListener("mouseleave", onUp);
|
||||||
|
node.removeEventListener("keydown", onKeyDown);
|
||||||
|
node.removeEventListener("keyup", onKeyUp);
|
||||||
active?.cancel();
|
active?.cancel();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -200,6 +217,8 @@ export function glowHover(
|
|||||||
|
|
||||||
node.addEventListener("mouseenter", onEnter);
|
node.addEventListener("mouseenter", onEnter);
|
||||||
node.addEventListener("mouseleave", onLeave);
|
node.addEventListener("mouseleave", onLeave);
|
||||||
|
node.addEventListener("focusin", onEnter);
|
||||||
|
node.addEventListener("focusout", onLeave);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
update(newOptions?: { color?: string }) {
|
update(newOptions?: { color?: string }) {
|
||||||
@@ -209,6 +228,8 @@ export function glowHover(
|
|||||||
destroy() {
|
destroy() {
|
||||||
node.removeEventListener("mouseenter", onEnter);
|
node.removeEventListener("mouseenter", onEnter);
|
||||||
node.removeEventListener("mouseleave", onLeave);
|
node.removeEventListener("mouseleave", onLeave);
|
||||||
|
node.removeEventListener("focusin", onEnter);
|
||||||
|
node.removeEventListener("focusout", onLeave);
|
||||||
enterAnim?.cancel();
|
enterAnim?.cancel();
|
||||||
leaveAnim?.cancel();
|
leaveAnim?.cancel();
|
||||||
},
|
},
|
||||||
|
|||||||
20
src/main.ts
20
src/main.ts
@@ -2,19 +2,35 @@ import "./app.css";
|
|||||||
import App from "./App.svelte";
|
import App from "./App.svelte";
|
||||||
import MiniTimer from "./lib/components/MiniTimer.svelte";
|
import MiniTimer from "./lib/components/MiniTimer.svelte";
|
||||||
import BreakWindow from "./lib/components/BreakWindow.svelte";
|
import BreakWindow from "./lib/components/BreakWindow.svelte";
|
||||||
|
import MicrobreakOverlay from "./lib/components/MicrobreakOverlay.svelte";
|
||||||
|
import DimOverlay from "./lib/components/DimOverlay.svelte";
|
||||||
|
import BreakOverlay from "./lib/components/BreakOverlay.svelte";
|
||||||
import { mount } from "svelte";
|
import { mount } from "svelte";
|
||||||
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const isMicrobreak = params.has("microbreak");
|
||||||
|
const isDim = params.has("dim");
|
||||||
|
const isBreakOverlay = params.has("breakoverlay");
|
||||||
const isMini = params.has("mini");
|
const isMini = params.has("mini");
|
||||||
const isBreak = params.has("break");
|
const isBreak = params.has("break");
|
||||||
|
|
||||||
if (isMini || isBreak) {
|
if (isMini || isBreak || isMicrobreak || isDim || isBreakOverlay) {
|
||||||
// Transparent body so rounded shapes show through the transparent window
|
// Transparent body so rounded shapes show through the transparent window
|
||||||
document.body.style.background = "transparent";
|
document.body.style.background = "transparent";
|
||||||
document.documentElement.style.background = "transparent";
|
document.documentElement.style.background = "transparent";
|
||||||
}
|
}
|
||||||
|
|
||||||
const component = isMini ? MiniTimer : isBreak ? BreakWindow : App;
|
const component = isMicrobreak
|
||||||
|
? MicrobreakOverlay
|
||||||
|
: isDim
|
||||||
|
? DimOverlay
|
||||||
|
: isBreakOverlay
|
||||||
|
? BreakOverlay
|
||||||
|
: isMini
|
||||||
|
? MiniTimer
|
||||||
|
: isBreak
|
||||||
|
? BreakWindow
|
||||||
|
: App;
|
||||||
|
|
||||||
const app = mount(component, {
|
const app = mount(component, {
|
||||||
target: document.getElementById("app")!,
|
target: document.getElementById("app")!,
|
||||||
|
|||||||
Reference in New Issue
Block a user