6 Commits

Author SHA1 Message Date
Your Name
a339dd1bb3 Add pomodoro, microbreaks, breathing guide, screen dimming, presentation mode, goals, multi-monitor, and activity manager
Major feature release (v0.1.3) adding 15 new features to the break timer:

Backend (Rust):
- Pomodoro cycle tracking with configurable short/long break pattern
- Microbreak scheduling (20-20-20 rule) with independent timer
- Screen dimming events with gradual opacity progression
- Presentation mode detection (fullscreen app deferral)
- Smart break detection (natural idle breaks counting toward goals)
- Daily goal tracking and streak milestone events
- Multi-monitor break overlay support
- Working hours enforcement with per-day schedules
- Weekly summary and natural break stats queries
- Config expanded to 71 validated fields

Frontend (Svelte):
- 6 new components: BreathingGuide, ActivityManager, BreakOverlay,
  MicrobreakOverlay, DimOverlay, Celebration
- Breathing guide with 5 patterns and animated pulsing halo
- Activity manager with favorites, custom activities, momentum scroll
- Confetti celebrations on milestones and goal completion
- Dashboard indicators (pomodoro/microbreak/goal) moved inside ring
- Settings reorganized into 18 logical cards
- Breathing pattern selector redesigned with timing descriptions
- Break activities expanded from 40 to 71 curated exercises
- Sound presets expanded from 4 to 8
- Stats view with weekly summary and natural break tracking

Also: version bump to 0.1.3, CHANGELOG, README and CLAUDE.md updates
2026-02-07 15:11:44 +02:00
Your Name
460bf2c613 Bump version to 0.1.2 2026-02-07 12:16:03 +02:00
Your Name
4cbf4c5bb8 Add WCAG 2.1 Level AA accessibility across all components
A break timer designed to prevent RSI should be usable by people who
already live with disabilities. This overhaul adds comprehensive
accessibility without changing the visual design.

Changes across 17 source files:
- Global focus-visible outlines, sr-only utility, forced-colors support
- prefers-reduced-motion kills all CSS animations AND JS Web Animations
- All text upgraded to 4.5:1+ contrast ratio (WCAG AA)
- Keyboard navigation for ColorPicker, Stepper, TimeSpinner
- Screen reader: aria-live status regions, progressbar roles, labeled
  controls, sr-only chart data table, focus management on view changes
- Focus trap on break screen, aria-hidden on decorative elements
- Descriptive labels on all 25+ toggle/stepper instances in Settings
- README updated with accessibility section and WCAG badge
2026-02-07 12:13:03 +02:00
d5ad1514d1 Update README.md 2026-02-07 09:35:24 +00:00
925a7d5516 Update README.md 2026-02-07 09:34:36 +00:00
Your Name
87ab035c68 Update README - fix download links, activity count, and accuracy
- Fix download links to point to Gitea releases page (not GitHub-style relative URLs)
- Correct activity count from 70 to 72
- Enlarge break screen screenshot
- Add missing IPC entries (natural-break-detected event, cursor/window commands)
- Add msvc_compat.rs to backend modules table
- Update winapi description to include WebView2 check dialog
- Remove unused dirs crate from dependencies
2026-02-07 11:24:16 +02:00
37 changed files with 4245 additions and 668 deletions

78
CHANGELOG.md Normal file
View 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

437
README.md
View File

@@ -21,10 +21,11 @@
<img src="https://img.shields.io/badge/svelte-5-FF3E00?style=flat-square&logo=svelte&logoColor=white" alt="Svelte 5" />
<img src="https://img.shields.io/badge/rust-2021-000000?style=flat-square&logo=rust&logoColor=white" alt="Rust" />
<img src="https://img.shields.io/badge/tailwind-v4-06B6D4?style=flat-square&logo=tailwindcss&logoColor=white" alt="Tailwind v4" />
<img src="https://img.shields.io/badge/WCAG_2.1-AA-228B22?style=flat-square" alt="WCAG 2.1 AA" />
</p>
<p align="center">
<a href="../../releases/latest"><strong>Download latest release</strong></a>
<a href="https://git.lashman.live/lashman/core-cooldown/releases"><strong>Download latest release</strong></a>
</p>
<br />
@@ -41,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.
@@ -58,11 +59,11 @@ Tools for human wellbeing should never be enclosed, never be scarce, and never s
---
## 🖼️ Screenshots
## Screenshots
<p align="center">
<img src="screenshots/01-dashboard.png" alt="Dashboard - Main Timer" width="420" /><br />
<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>
<br />
@@ -76,14 +77,14 @@ Tools for human wellbeing should never be enclosed, never be scarce, and never s
<p align="center">
<img src="screenshots/03-settings.png" alt="Settings" width="420" /><br />
<sub><strong>Settings</strong> - Grouped configuration cards with live preview</sub>
<sub><strong>Settings</strong> - 18 grouped configuration cards with live preview</sub>
</p>
<br />
<p align="center">
<img src="screenshots/04-break.png" alt="Break Screen" width="420" /><br />
<sub><strong>Break Screen</strong> - Always-on-top break overlay with activity suggestions</sub>
<img src="screenshots/04-break.png" alt="Break Screen" width="600" /><br />
<sub><strong>Break Screen</strong> - Always-on-top break overlay with breathing guide and activity suggestions</sub>
</p>
<br />
@@ -99,23 +100,62 @@ Tools for human wellbeing should never be enclosed, never be scarce, and never s
## Features
### ⏱️ Timer & Breaks
### Timer & Breaks
| | Feature | Description |
|:--|:--------|:------------|
| 🔄 | **Configurable intervals** | Work sessions from 5-120 min, breaks from 1-60 min |
| 🔔 | **Pre-break warnings** | Toast notification + optional sound alert before each break |
| 🛡️ | **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 |
| ⏩ | **Early end** | Optionally allow ending a break after 50% completion |
| 😴 | **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 |
| 🎯 | **Manual break** | Start a break anytime from the dashboard or tray menu |
<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 |
|:--|:--------|:------------|
@@ -126,9 +166,20 @@ Tools for human wellbeing should never be enclosed, never be scarce, and never s
<br />
### 🧘 Break Activities
### Presentation Mode
Each break shows a randomized suggestion from a curated library of **70 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 |
|:---------|:---------|
@@ -139,9 +190,36 @@ Each break shows a randomized suggestion from a curated library of **70 activiti
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 />
### 📅 Working Hours Schedule
### 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 />
### Working Hours Schedule
A per-day schedule with multiple time ranges per day. The timer only runs during your configured hours - outside those hours, it pauses automatically.
@@ -151,7 +229,7 @@ A per-day schedule with multiple time ranges per day. The timer only runs during
<br />
### 📊 Statistics & History
### Statistics & History
| | Metric | Description |
|:--|:-------|:------------|
@@ -165,7 +243,7 @@ All statistics stored locally in a plain JSON file next to the executable.
<br />
### 🔊 Sound Effects
### Sound Effects
Synthesized notification sounds via Web Audio API - no bundled audio files, no network requests.
@@ -175,7 +253,7 @@ Sounds play on break start, pre-break warning, and break completion. Volume conf
<br />
### ⌨️ Global Keyboard Shortcuts
### Global Keyboard Shortcuts
| Shortcut | Action |
|:---------|:-------|
@@ -187,70 +265,89 @@ Works system-wide, even when Core Cooldown is not focused.
<br />
### 🔲 System Tray
### System Tray
- 🎨 **Dynamic icon** - 32x32 progress arc rendered in real-time (orange = focus, purple = break, dimmed = paused)
- 💬 **Countdown tooltip** - hover over tray icon to see time remaining
- 📋 **Context menu** - pause/resume, start break, toggle mini mode, show/hide, quit
- **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
- **Context menu** - pause/resume, start break, toggle mini mode, show/hide, quit
<br />
### 📌 Mini Mode
### Mini Mode
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
- **Hover to grab** - hover for a configurable number of seconds and it becomes draggable
- 🖱️ **Double-click** - opens the main window
- 🔀 **Togglable** - enable/disable from the tray menu
- **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
- **Double-click** - opens the main window
- **Togglable** - enable/disable from the tray menu
<br />
### 🎨 Appearance & Customization
### Appearance & Customization
| Setting | Range |
|:--------|:------|
| 🔍 **UI zoom** | 50-200% with live preview |
| 🎯 **Accent color** | Hex color picker for the main UI accent |
| 💜 **Break color** | Separate hex for the break screen ring |
| 🌈 **Color schemes** | Ocean, Forest, Sunset, Midnight, Dawn |
| 🔤 **Countdown font** | Google Fonts selector for timer display |
| 🫧 **Background blobs** | Animated gradient blobs with film grain overlay |
| 🌑 **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) |
| **UI zoom** | 50-200% with live preview |
| **Accent color** | Full color picker (SL pad + hue bar) for the main UI accent |
| **Break color** | Separate color for the break screen ring and breathing guide |
| **Countdown font** | Google Fonts selector for timer display |
| **Background blobs** | Animated gradient blobs with film grain overlay |
| **Break title & message** | Fully customizable text shown during breaks |
<br />
### 🔔 Notifications
### Notifications
Native Windows toast notifications for:
- Pre-break warnings (configurable seconds before break)
- Break completion
- Pre-break warnings (configurable seconds before break)
- Break completion
- Streak milestones
- Daily goal achievement
- Break deferral (presentation mode)
<br />
### 🪟 Window Behavior
### Window Behavior
- **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
- **Animated view transitions** - directional fly/scale/fade transitions (700ms, cubicOut easing)
- **Momentum scroll** - iOS-style drag scrolling with elastic overscroll in settings
<br />
### Accessibility
Core Cooldown targets **WCAG 2.1 Level AA** compliance. A break timer for preventing repetitive strain injury should be usable by everyone - including those who already live with disabilities.
| | Feature | Description |
|:--|:--------|:------------|
| ⌨️ | **Full keyboard navigation** | Every control reachable and operable via keyboard alone. Arrow keys navigate dropdowns, adjust color pickers, steppers, and time spinners. Tab/Shift+Tab cycles through all interactive elements. |
| 🔍 | **Visible focus indicators** | Global `:focus-visible` outlines on all interactive elements - no hidden or suppressed focus rings |
| 🗣️ | **Screen reader support** | `aria-live` regions announce timer state, breathing phase, break activities, and status changes. Progress rings use `role="progressbar"` with value text. Accordion panels have `aria-controls` and `aria-expanded`. Custom dropdowns support `role="listbox"` with arrow key navigation. |
| 🎯 | **Focus management** | View transitions move focus to the new view's heading. Break screen traps focus to prevent interaction with obscured content. Dropdown focus returns to trigger on close. |
| 🎨 | **Color contrast** | All text meets 4.5:1 minimum contrast ratio against dark backgrounds. Dynamic breathing text color interpolation validated against threshold. |
| 🖥️ | **Windows High Contrast** | `forced-colors: active` media query maps all theme tokens to system colors |
| 🐢 | **Reduced motion** | `prefers-reduced-motion` disables all CSS animations/transitions, all JavaScript-driven Web Animations API effects, and momentum scroll physics. No functionality lost - just calmer. |
| 🏷️ | **Descriptive labels** | All toggle switches, steppers, buttons, dropdowns, and form controls have descriptive accessible names |
| 👆 | **Touch targets** | Interactive elements meet minimum 32x32px hit areas for comfortable interaction |
<br />
---
## 📦 Portability
## Portability
Core Cooldown is **fully portable**. The executable carries everything it needs and stores everything it creates right next to itself:
```
📁 anywhere-you-want/
├── core-cooldown.exe the application
├── config.json your settings (auto-created on first run)
├── stats.json your break history (auto-created on first run)
└── data/ WebView2 runtime data (auto-created on first run)
anywhere-you-want/
├── core-cooldown.exe <- the application
├── config.json <- your settings (auto-created on first run)
├── stats.json <- your break history (auto-created on first run)
└── data/ <- WebView2 runtime data (auto-created on first run)
```
- No installer
@@ -263,7 +360,7 @@ Core Cooldown is **fully portable**. The executable carries everything it needs
---
## 🚀 Installation
## Installation
```
1. Download core-cooldown.exe from the Releases page
@@ -273,13 +370,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.
**[Download latest release](../../releases/latest)**
**[Download latest release](https://git.lashman.live/lashman/core-cooldown/releases)**
<br />
---
## 🔨 Building from Source
## Building from Source
<details>
<summary><strong>Prerequisites</strong></summary>
@@ -338,13 +435,13 @@ Output: `src-tauri/target/x86_64-pc-windows-gnu/release/core-cooldown.exe`
---
## 🏗️ Architecture
## Architecture
A split-architecture desktop app: Rust backend for system integration and timer logic, Svelte frontend rendered in a native WebView.
```
┌──────────────────────────────────────────────────────────────┐
🔲 System Tray │
System Tray
│ (dynamic icon · tooltip · menu) │
├──────────────────────────────────────────────────────────────┤
│ │
@@ -375,11 +472,12 @@ A split-architecture desktop app: Rust backend for system integration and timer
| Module | Responsibility |
|:-------|:---------------|
| `lib.rs` | Tauri app builder, command registration, tray setup, timer tick thread, window management, global shortcuts |
| `config.rs` | Config struct with serde serialization, validation (clamping all values to safe ranges), portable file I/O |
| `timer.rs` | Timer state machine (`Running` / `Paused` / `BreakActive`), idle detection via Windows API, working hours enforcement |
| `stats.rs` | Daily break statistics, streak calculation, history queries |
| `main.rs` | Entry point |
| `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 (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, pomodoro cycle tracking, microbreak scheduling, presentation mode deferral |
| `stats.rs` | Daily break statistics, streak calculation, daily goal tracking, history queries, weekly summaries |
| `main.rs` | Entry point, WebView2 Runtime detection |
| `msvc_compat.rs` | MSVC CRT compatibility stubs for static WebView2Loader linking on MinGW |
</details>
@@ -390,20 +488,21 @@ A split-architecture desktop app: Rust backend for system integration and timer
|:------|:------|
| **Views** | `Dashboard`, `BreakScreen`, `Settings`, `StatsView` |
| **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) |
| **Utilities** | `sounds.ts` (Web Audio synthesis), `activities.ts` (70 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>
<summary><strong>IPC contract</strong></summary>
**Commands** (frontend backend):
`get_config` · `save_config` · `update_pending_config` · `reset_config` · `toggle_timer` · `start_break_now` · `cancel_break` · `snooze` · `get_timer_state` · `set_view` · `get_stats` · `get_daily_history`
**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_weekly_summary` · `set_auto_start` · `get_auto_start_status` · `get_cursor_position` · `save_window_position`
**Events** (backend frontend):
`timer-tick` · `break-started` · `break-ended` · `prebreak-warning` · `config-changed`
**Events** (backend -> frontend):
`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>
@@ -411,52 +510,166 @@ A split-architecture desktop app: Rust backend for system integration and timer
---
## ⚙️ Configuration Reference
## Configuration Reference
All settings stored in `config.json` next to the executable. The settings panel exposes every option with live validation, but the file is plain JSON and can be edited by hand.
<details>
<summary><strong>Full configuration schema (35 keys)</strong></summary>
<summary><strong>Full configuration schema (71 keys)</strong></summary>
<br />
| Key | Type | Default | Range | Description |
|:----|:-----|:--------|:------|:------------|
| `break_duration` | `u32` | `5` | 1-60 min | Duration of each break |
| `break_frequency` | `u32` | `25` | 5-120 min | Interval between breaks |
| `auto_start` | `bool` | `true` | - | Start timer on launch |
| `break_title` | `string` | `"Rest your eyes"` | max 100 chars | Title shown on break screen |
| `break_message` | `string` | `"Look away..."` | max 500 chars | Message shown during breaks |
| `fullscreen_mode` | `bool` | `true` | - | Use fullscreen break window |
| `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 |
| `working_hours_enabled` | `bool` | `false` | - | Restrict timer to schedule |
| `working_hours_schedule` | `array` | Mon-Fri 09-18 | 7 days | Per-day time ranges |
| `dark_mode` | `bool` | `true` | - | Dark theme |
| `color_scheme` | `string` | `"Ocean"` | 5 presets | Color scheme name |
| `backdrop_opacity` | `f32` | `0.92` | 0.5-1.0 | Break screen opacity |
| `notification_enabled` | `bool` | `true` | - | Enable toast notifications |
| `notification_before_break` | `u32` | `30` | 0-300 sec | Pre-break warning time |
| `snooze_duration` | `u32` | `5` | 1-30 min | Snooze delay |
| `snooze_limit` | `u32` | `3` | 0-5 | Max snoozes per cycle |
| `skip_cooldown` | `u32` | `60` | 0-600 sec | Cooldown between skips |
| `sound_enabled` | `bool` | `true` | - | Play notification sounds |
| `sound_volume` | `u32` | `70` | 0-100 | Volume percentage |
| `sound_preset` | `string` | `"bell"` | 8 presets | Sound preset name |
| `idle_detection_enabled` | `bool` | `true` | - | Enable idle auto-pause |
| `idle_timeout` | `u32` | `120` | 30-600 sec | Idle threshold |
| `smart_breaks_enabled` | `bool` | `true` | - | Detect natural breaks |
| `smart_break_threshold` | `u32` | `300` | 120-900 sec | Natural break threshold |
| `smart_break_count_stats` | `bool` | `false` | - | Count natural breaks in stats |
| `show_break_activities` | `bool` | `true` | - | Show activity suggestions |
| `ui_zoom` | `u32` | `100` | 50-200% | Interface zoom level |
| `accent_color` | `string` | `"#ff4d00"` | hex | Main accent color |
| `break_color` | `string` | `"#7c6aef"` | hex | Break screen ring color |
| `countdown_font` | `string` | `""` | font family | Google Font for countdown |
| `background_blobs_enabled` | `bool` | `false` | - | Animated background blobs |
| `mini_click_through` | `bool` | `true` | - | Mini mode click-through |
| `mini_hover_threshold` | `f32` | `3.0` | 1.0-10.0 sec | Hover delay before drag |
**Timer**
| Key | Type | Default | Description |
|:----|:-----|:--------|:------------|
| `break_duration` | `u32` | `5` | Duration of each break (1-60 min) |
| `break_frequency` | `u32` | `25` | Interval between breaks (5-120 min) |
| `auto_start` | `bool` | `true` | Start timer on launch |
**Pomodoro**
| Key | Type | Default | Description |
|:----|:-----|:--------|:------------|
| `pomodoro_enabled` | `bool` | `false` | Enable Pomodoro mode |
| `pomodoro_short_breaks` | `u32` | `3` | Short breaks before long (1-10) |
| `pomodoro_long_break_duration` | `u32` | `15` | Long break duration (5-60 min) |
| `pomodoro_long_break_title` | `string` | `"Long break"` | Long break title |
| `pomodoro_long_break_message` | `string` | `"Great work!..."` | Long break message |
| `pomodoro_reset_on_skip` | `bool` | `false` | Reset cycle when skipping |
**Microbreaks**
| Key | Type | Default | Description |
|:----|:-----|:--------|:------------|
| `microbreak_enabled` | `bool` | `false` | Enable 20-20-20 eye breaks |
| `microbreak_frequency` | `u32` | `20` | Microbreak interval (5-60 min) |
| `microbreak_duration` | `u32` | `20` | Microbreak duration (10-60 sec) |
| `microbreak_sound_enabled` | `bool` | `true` | Play sound on microbreak |
| `microbreak_show_activity` | `bool` | `true` | Show activity during microbreak |
| `microbreak_pause_during_break` | `bool` | `true` | No microbreaks during main breaks |
**Break Screen**
| Key | Type | Default | Description |
|:----|:-----|:--------|:------------|
| `break_title` | `string` | `"Rest your eyes"` | Title shown on break screen |
| `break_message` | `string` | `"Look away..."` | Message shown during breaks |
| `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>
@@ -464,7 +677,7 @@ All settings stored in `config.json` next to the executable. The settings panel
---
## 📚 Dependencies
## Dependencies
<details>
<summary><strong>Rust crates</strong></summary>
@@ -478,7 +691,7 @@ All settings stored in `config.json` next to the executable. The settings panel
| `serde` / `serde_json` | Config and stats serialization |
| `chrono` | Date/time handling for schedules and statistics |
| `anyhow` | Error handling |
| `winapi` | Windows idle detection (`GetLastInputInfo`) |
| `winapi` | Windows idle detection (`GetLastInputInfo`), WebView2 check dialog |
</details>
@@ -500,7 +713,7 @@ All settings stored in `config.json` next to the executable. The settings panel
---
## 🤝 Contributing
## Contributing
This project belongs to no one and everyone. If you find it useful and want to make it better, you are welcome.
@@ -508,12 +721,12 @@ No contribution agreements to sign. No corporate CLAs. No licensing traps. Every
**Some ways to help:**
- 🐛 Report bugs or rough edges
- 🧘 Suggest new break activities (especially with physiotherapy or ergonomics knowledge)
- Improve accessibility
- 🐧 Port idle detection to macOS/Linux
- 🌍 Translate the interface
- 💌 Share it with someone who needs it
- Report bugs or rough edges
- Suggest new break activities (especially with physiotherapy or ergonomics knowledge)
- Improve accessibility (WCAG 2.1 AA foundation is in place - help us push further)
- Port idle detection to macOS/Linux
- Translate the interface
- 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.
@@ -521,7 +734,7 @@ The best software is built through mutual aid - people helping people because it
---
## 📄 License
## License
<p align="center">
<a href="https://creativecommons.org/publicdomain/zero/1.0/">
@@ -545,7 +758,7 @@ See [`LICENSE`](LICENSE) for the full legal text.
<p align="center">
<sub>
Built with care. Shared without conditions. 🧊<br />
Built with care. Shared without conditions.<br />
<em>Rest well.</em>
</sub>
</p>

View File

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

113
src-tauri/Cargo.lock generated
View File

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

View File

@@ -1,6 +1,6 @@
[package]
name = "core-cooldown"
version = "0.1.1"
version = "0.1.3"
edition = "2021"
[lib]
@@ -17,9 +17,8 @@ tauri-plugin-notification = "2"
tauri-plugin-global-shortcut = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
dirs = "5"
chrono = "0.4"
anyhow = "1"
[target.'cfg(windows)'.dependencies]
winapi = { version = "0.3", features = ["winuser", "sysinfoapi", "windef", "shellapi"] }
winapi = { version = "0.3", features = ["winuser", "sysinfoapi", "windef", "shellapi", "winreg"] }

View File

@@ -2,7 +2,7 @@
"$schema": "https://raw.githubusercontent.com/nicegui-fr/nicegui/main/src-tauri/gen/schemas/desktop-schema.json",
"identifier": "default",
"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": [
"core:window:allow-start-dragging",
"core:window:allow-minimize",

View File

@@ -3,6 +3,16 @@ use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
/// A custom break activity defined by the user.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CustomActivity {
pub id: String,
pub category: String,
pub text: String,
pub is_favorite: bool,
pub enabled: bool,
}
/// A single time range (e.g., 09:00 to 17:00)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TimeRange {
@@ -107,6 +117,54 @@ pub struct Config {
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)
// 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)
pub main_window_x: Option<i32>,
pub main_window_y: Option<i32>,
@@ -186,6 +244,54 @@ impl Default for Config {
mini_click_through: true,
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
main_window_x: None,
main_window_y: None,
@@ -349,6 +455,44 @@ impl Config {
// UI zoom: 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
if !Self::is_valid_hex_color(&self.accent_color) {
self.accent_color = "#ff4d00".to_string();

View File

@@ -15,7 +15,7 @@ use tauri::{
AppHandle, Emitter, Manager, State,
};
use tauri_plugin_notification::NotificationExt;
use timer::{AppView, TickResult, TimerManager, TimerSnapshot};
use timer::{AppView, MicrobreakTickResult, TickResult, TimerManager, TimerSnapshot};
pub struct AppState {
pub timer: Arc<Mutex<TimerManager>>,
@@ -127,7 +127,13 @@ fn set_view(state: State<AppState>, view: AppView) {
#[tauri::command]
fn get_stats(state: State<AppState>) -> stats::StatsSnapshot {
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]
@@ -136,6 +142,126 @@ fn get_daily_history(state: State<AppState>, days: u32) -> Vec<stats::DayRecord>
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 ────────────────────────────────────
#[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.
/// F10: Optionally renders a green checkmark when daily goal is met.
fn render_tray_icon(
progress: f64,
is_break: bool,
is_paused: bool,
accent: (u8, u8, u8),
break_color: (u8, u8, u8),
goal_met: bool,
) -> Vec<u8> {
let size: usize = 32;
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
}
@@ -249,14 +399,19 @@ fn update_tray(
snapshot: &TimerSnapshot,
accent: (u8, u8, u8),
break_color: (u8, u8, u8),
goal_met: bool,
) {
// Update tooltip
let tooltip = match snapshot.state {
timer::TimerState::Running => {
if snapshot.deferred_break_pending {
"Core Cooldown — Break deferred (fullscreen)".to_string()
} else {
let m = snapshot.time_remaining / 60;
let s = snapshot.time_remaining % 60;
format!("Core Cooldown — {:02}:{:02} until break", m, s)
}
}
timer::TimerState::Paused => {
if snapshot.idle_paused {
"Core Cooldown — Paused (idle)".to_string()
@@ -279,7 +434,7 @@ fn update_tray(
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 _ = tray.set_icon(Some(icon));
}
@@ -369,41 +524,133 @@ pub fn run() {
let handle = app.handle().clone();
let timer_ref = app.state::<AppState>().timer.clone();
let stats_ref = app.state::<AppState>().stats.clone();
let data_dir_clone = data_dir.clone();
std::thread::spawn(move || {
let mut dim_window_open = false;
let mut microbreak_window_open = false;
let mut break_deferred_notified = false;
loop {
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 result = timer.tick();
let mb = timer.tick_microbreak();
let snap = timer.snapshot();
let ac = timer.config.accent_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
let accent = parse_hex_color(&accent_hex, (255, 77, 0));
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
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
match tick_result {
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);
// 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);
break_deferred_notified = false;
}
TickResult::BreakEnded => {
// Restore normal window state and close break window
handle_break_end(&handle);
// F9: Close multi-monitor overlays
close_multi_monitor_overlays(&handle);
// Record completed break in stats
{
let break_result = {
let timer = timer_ref.lock().unwrap();
let goal = if daily_goal_enabled { daily_goal } else { 0 };
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
.notification()
@@ -464,6 +711,22 @@ pub fn run() {
.show();
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 => {}
}
}
@@ -493,6 +756,9 @@ pub fn run() {
set_view,
get_stats,
get_daily_history,
get_weekly_summary,
set_auto_start,
get_auto_start_status,
get_cursor_position,
save_window_position,
])
@@ -688,3 +954,114 @@ fn toggle_mini_window(app: &AppHandle) {
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();
}
}
}

View File

@@ -42,8 +42,31 @@ pub struct StatsSnapshot {
pub compliance_rate: f64,
pub current_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 {
/// Portable: stats file lives next to the exe
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 was_below_goal = day.breaks_completed < daily_goal;
day.breaks_completed += 1;
day.total_break_time_secs += duration_secs;
let now_at_goal = day.breaks_completed >= daily_goal;
self.update_streak();
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) {
@@ -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 today = self.data.days.get(&key);
@@ -176,6 +220,8 @@ impl Stats {
compliance_rate: compliance,
current_streak: self.data.current_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
}
/// 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
}
}

View File

@@ -51,6 +51,27 @@ pub struct TimerSnapshot {
pub natural_break_occurred: bool,
pub smart_breaks_enabled: bool,
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
@@ -63,6 +84,7 @@ pub struct BreakStartedPayload {
pub strict_mode: bool,
pub snooze_duration: u32,
pub fullscreen_mode: bool,
pub is_long_break: bool,
}
pub struct TimerManager {
@@ -84,6 +106,17 @@ pub struct TimerManager {
// Smart breaks: track when idle started for natural break detection
pub idle_start_time: Option<Instant>,
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 {
@@ -92,6 +125,7 @@ impl TimerManager {
let freq = config.break_frequency_seconds();
let pending = config.clone();
let auto_start = config.auto_start;
let microbreak_freq = config.microbreak_frequency as u64 * 60;
Self {
state: if auto_start {
@@ -113,6 +147,17 @@ impl TimerManager {
idle_paused: false,
idle_start_time: None,
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.
pub fn tick(&mut self) -> TickResult {
// Idle detection and natural break detection
@@ -223,15 +280,18 @@ impl TimerManager {
TickResult::None
}
} 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();
TickResult::BreakStarted(BreakStartedPayload {
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,
})
TickResult::BreakStarted(self.make_break_payload())
}
}
TimerState::BreakActive => {
@@ -242,28 +302,139 @@ impl TimerManager {
// Break completed naturally
self.has_had_break = true;
self.seconds_since_last_break = 0;
self.advance_pomodoro_cycle();
self.reset_timer();
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) {
// 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.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.time_until_break_end = self.break_total_duration;
self.prebreak_notification_active = false;
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) {
self.state = TimerState::Running;
self.current_view = AppView::Dashboard;
self.time_until_next_break = self.config.break_frequency_seconds();
self.prebreak_notification_active = false;
self.pomodoro_is_long_break = false;
}
pub fn toggle_timer(&mut self) {
@@ -281,14 +452,7 @@ impl TimerManager {
pub fn start_break_now(&mut self) -> Option<BreakStartedPayload> {
if self.state == TimerState::Running || self.state == TimerState::Paused {
self.start_break();
Some(BreakStartedPayload {
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,
})
Some(self.make_break_payload())
} else {
None
}
@@ -311,10 +475,15 @@ impl TimerManager {
// "End break" — counts as completed
self.has_had_break = true;
self.seconds_since_last_break = 0;
self.advance_pomodoro_cycle();
self.reset_timer();
true
} else if !past_half {
// "Cancel break" — doesn't count
// F3: Pomodoro reset-on-skip
if self.config.pomodoro_enabled && self.config.pomodoro_reset_on_skip {
self.pomodoro_cycle_position = 0;
}
self.reset_timer();
true
} else {
@@ -420,6 +589,25 @@ impl TimerManager {
&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 {
state: self.state,
current_view: self.current_view,
@@ -431,8 +619,16 @@ impl TimerManager {
prebreak_warning: self.prebreak_notification_active,
snoozes_used: self.snoozes_used,
can_snooze: self.can_snooze(),
break_title: display_config.break_title.clone(),
break_message: display_config.break_message.clone(),
break_title: if self.pomodoro_is_long_break {
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_time_remaining: self.time_until_break_end,
break_total_duration: self.break_total_duration,
@@ -442,6 +638,27 @@ impl TimerManager {
natural_break_occurred: self.natural_break_occurred,
smart_breaks_enabled: display_config.smart_breaks_enabled,
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,
PreBreakWarning { seconds_until_break: u64 },
NaturalBreakDetected { duration_seconds: u64 },
BreakDeferred, // F2
}
/// F1: Microbreak tick result
pub enum MicrobreakTickResult {
None,
MicrobreakStarted,
MicrobreakEnded,
}
/// Result of checking idle state
@@ -486,3 +711,101 @@ pub fn get_idle_seconds() -> u64 {
pub fn get_idle_seconds() -> u64 {
0
}
/// F2: Check if the foreground window is a fullscreen application
#[cfg(windows)]
pub fn is_foreground_fullscreen() -> bool {
use std::mem;
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,
}

View File

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

View File

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

View File

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

View File

@@ -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-[#8a8a8a]
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-[#222] bg-[#111] shadow-xl shadow-black/50 overflow-hidden"
role="listbox" aria-label="Activity category">
{#each categories as cat, i}
<button
class="w-full text-left px-3.5 py-2.5 text-[13px] transition-colors outline-none
{newActivityCategory === cat ? 'text-white bg-[#1a1a1a]' : 'text-[#8a8a8a] hover:bg-[#1a1a1a] hover:text-white'}
{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-[#8a8a8a]
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-[#8a8a8a] border border-[#161616] hover:border-[#333] hover:text-white'}"
onclick={() => toggleCategory(cat)}
aria-expanded={isExpanded}
aria-controls="activity-panel-{cat}"
>
<span class="uppercase">{getCategoryLabel(cat)}</span>
<span class="ml-1.5 text-[10px] {isExpanded ? 'text-[#8a8a8a]' : 'text-[#8a8a8a] opacity-60'}">
{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-[#8a8a8a] 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 min-w-[32px] min-h-[32px] text-[13px] transition-opacity {activity.is_favorite ? 'opacity-100' : 'opacity-30 hover:opacity-60'}"
onclick={() => toggleCustomFavorite(activity.id)}
aria-label="{activity.is_favorite ? 'Remove from favorites' : 'Add to favorites'}"
>
</button>
<span class="flex-1 text-[12px] {activity.enabled ? 'text-white' : 'text-[#8a8a8a] line-through'}">
{activity.text}
</span>
<button
class="flex items-center justify-center min-w-[32px] min-h-[32px] text-[#8a8a8a] hover:text-[#f85149] opacity-0 group-hover:opacity-100 focus-visible:opacity-100 transition-all text-[11px]"
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-[#8a8a8a] uppercase">Built-in</div>
</div>
{/if}
{#each catBuiltins as activity (activity.text)}
<div class="flex items-center gap-1 px-1.5 py-0.5 hover:bg-[#111]">
<button
class="flex items-center justify-center min-w-[32px] min-h-[32px] text-[13px] transition-opacity {isBuiltinFavorite(activity.text) ? 'opacity-100' : 'opacity-30 hover:opacity-60'}"
onclick={() => toggleBuiltinFavorite(activity.text)}
aria-label="{isBuiltinFavorite(activity.text) ? 'Remove from favorites' : 'Add to favorites'}"
>
</button>
<span class="flex-1 text-[12px] {!isBuiltinDisabled(activity.text) ? 'text-[#8a8a8a]' : 'text-[#8a8a8a] line-through opacity-60'}">
{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>

View File

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

View File

@@ -0,0 +1,56 @@
<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
class="fixed inset-0 flex flex-col items-center justify-center"
style="background: rgba(0, 0, 0, {$config.backdrop_opacity});"
>
<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>

View File

@@ -4,8 +4,10 @@
import { timer, currentView, formatTime, type TimerSnapshot } from "../stores/timer";
import { config } from "../stores/config";
import TimerRing from "./TimerRing.svelte";
import { onMount } from "svelte";
import { scaleIn, fadeIn, pressable } from "../utils/animate";
import { pickRandomActivity, getCategoryIcon, getCategoryLabel, type BreakActivity } from "../utils/activities";
import BreathingGuide from "./BreathingGuide.svelte";
interface Props {
standalone?: boolean;
@@ -15,14 +17,14 @@
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;
// Cycle activity every 30 seconds during break
$effect(() => {
if ($config.show_break_activities && $timer.state === "breakActive") {
activityCycleTimer = setInterval(() => {
currentActivity = pickRandomActivity(currentActivity);
currentActivity = pickRandomActivity(currentActivity, $config);
}, 30_000);
}
return () => {
@@ -33,6 +35,9 @@
};
});
// F3: Long break indicator
const isLongBreak = $derived($timer.isLongBreak);
async function cancelBreak() {
const snap = await invoke<TimerSnapshot>("cancel_break");
timer.set(snap);
@@ -64,38 +69,112 @@
const showButtons = $derived(!$config.strict_mode);
// Breathing guide bindable state
let breathPhase = $state("Inhale");
let breathCountdown = $state(4);
let breathScale = $state(0.6);
// Map raw 0.61.0 scale to 0.91.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
const barGradient = $derived(
`linear-gradient(to right, ${$config.break_color}, ${$config.accent_color})`,
);
const isModal = $derived(!$config.fullscreen_mode && !standalone);
// Focus trap: keep Tab cycling within break screen
let breakContainer = $state<HTMLElement>(undefined!);
$effect(() => {
if (!breakContainer) return;
function trapFocus(e: KeyboardEvent) {
if (e.key !== "Tab") return;
const focusable = breakContainer.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey) {
if (document.activeElement === first) { e.preventDefault(); last.focus(); }
} else {
if (document.activeElement === last) { e.preventDefault(); first.focus(); }
}
}
breakContainer.addEventListener("keydown", trapFocus);
return () => breakContainer.removeEventListener("keydown", trapFocus);
});
</script>
{#if standalone}
<!-- ── Standalone break window: horizontal card, transparent background ── -->
<div class="standalone-card" use:scaleIn={{ duration: 0.5 }}>
<div class="standalone-card" use:scaleIn={{ duration: 0.5 }} bind:this={breakContainer}>
<!-- Ripples emanate from the ring, visible outside the card -->
<div class="standalone-ring-area">
<div class="ripple-container">
<div class="ripple-container" aria-hidden="true">
<div class="break-ripple ripple-1" style="--ripple-color: {$config.break_color}"></div>
<div class="break-ripple ripple-2" style="--ripple-color: {$config.break_color}"></div>
<div class="break-ripple ripple-3" style="--ripple-color: {$config.break_color}"></div>
</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
progress={breakRingProgress}
size={140}
strokeWidth={5}
accentColor={$config.break_color}
label="Break timer"
valueText="{formatTime($timer.breakTimeRemaining)} remaining"
>
<div class="break-breathe-counter">
<div class="flex flex-col items-center">
<span
class="font-semibold leading-none tabular-nums text-white text-[26px]"
style={$config.countdown_font ? `font-family: '${$config.countdown_font}', monospace;` : ""}
>
{formatTime($timer.breakTimeRemaining)}
</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-live="polite" aria-atomic="true"
aria-label="Breathing guide: {breathPhase} {breathCountdown > 0 ? breathCountdown + ' seconds' : ''}"
>
{breathPhase}{breathCountdown > 0 ? ` ${breathCountdown}s` : ""}
</span>
{/if}
</div>
</TimerRing>
</div>
@@ -103,19 +182,19 @@
<!-- Right side: text + buttons -->
<div class="standalone-content">
<h2 class="text-[17px] font-medium text-white mb-1.5">
<h2 class="text-[17px] font-medium text-white mb-1.5" tabindex="-1">
{$timer.breakTitle}
</h2>
<p class="text-[12px] leading-relaxed text-[#666] mb-4 max-w-[240px]">
<p class="text-[12px] leading-relaxed text-[#8a8a8a] mb-4 max-w-[240px]">
{$timer.breakMessage}
</p>
{#if $config.show_break_activities}
<div class="rounded-xl border border-[#ffffff08] bg-[#ffffff06] px-4 py-2.5 mb-4 max-w-[260px]">
<div class="text-[9px] font-medium tracking-[0.15em] text-[#555] uppercase mb-1">
<div class="text-[9px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase mb-1">
{getCategoryLabel(currentActivity.category)}
</div>
<p class="text-[12px] leading-relaxed text-[#999]">
<p class="text-[12px] leading-relaxed text-[#8a8a8a]" aria-live="polite">
{currentActivity.text}
</p>
</div>
@@ -126,9 +205,9 @@
<button
use:pressable
class="rounded-full border border-[#333] px-5 py-2 text-[11px]
tracking-wider text-[#666] uppercase
tracking-wider text-[#8a8a8a] uppercase
transition-colors duration-200
hover:border-[#444] hover:text-[#aaa]"
hover:border-[#444] hover:text-[#ccc]"
onclick={cancelBreak}
>
{cancelBtnText}
@@ -147,7 +226,7 @@
{/if}
</div>
{#if $config.snooze_limit > 0}
<p class="mt-2 text-[9px] text-[#333]">
<p class="mt-2 text-[9px] text-[#8a8a8a]">
{$timer.snoozesUsed}/{$config.snooze_limit} snoozes used
</p>
{/if}
@@ -155,7 +234,7 @@
</div>
<!-- Bottom progress bar with clip-path -->
<div class="standalone-progress-container">
<div class="standalone-progress-container" role="progressbar" aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round($timer.breakProgress * 100)} aria-label="Break progress">
<div class="standalone-progress-track">
<div
class="h-full transition-[width] duration-1000 ease-linear"
@@ -170,6 +249,7 @@
<div
class="relative h-full flex items-center justify-center"
style="background: #000;"
bind:this={breakContainer}
>
<div
class="relative flex flex-col items-center"
@@ -177,20 +257,37 @@
>
<!-- Break ring with breathing pulse + ripples -->
<div class="mb-6 relative" use:scaleIn={{ duration: 0.6, delay: 0.1 }}>
<div class="absolute inset-0 flex items-center justify-center pointer-events-none">
<div class="absolute inset-0 flex items-center justify-center pointer-events-none" aria-hidden="true">
<div class="break-ripple ripple-1" style="--ripple-color: {$config.break_color}; --ripple-size: {isModal ? 160 : 200}px"></div>
<div class="break-ripple ripple-2" style="--ripple-color: {$config.break_color}; --ripple-size: {isModal ? 160 : 200}px"></div>
<div class="break-ripple ripple-3" style="--ripple-color: {$config.break_color}; --ripple-size: {isModal ? 160 : 200}px"></div>
</div>
<div class="break-breathe relative">
<!-- 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
progress={breakRingProgress}
size={isModal ? 160 : 200}
strokeWidth={isModal ? 5 : 6}
accentColor={$config.break_color}
label="Break timer"
valueText="{formatTime($timer.breakTimeRemaining)} remaining"
>
<div class="break-breathe-counter">
<div class="flex flex-col items-center">
<span
class="font-semibold leading-none tabular-nums text-white"
class:text-[30px]={isModal}
@@ -199,17 +296,39 @@
>
{formatTime($timer.breakTimeRemaining)}
</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-live="polite" aria-atomic="true"
aria-label="Breathing guide: {breathPhase} {breathCountdown > 0 ? breathCountdown + ' seconds' : ''}"
>
{breathPhase}{breathCountdown > 0 ? ` ${breathCountdown}s` : ""}
</span>
{/if}
</div>
</TimerRing>
</div>
</div>
<h2 class="mb-2 text-lg font-medium text-white" use:fadeIn={{ delay: 0.25, y: 10 }}>
<!-- 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 }}>
{$timer.breakTitle}
</h2>
<p
class="max-w-[300px] text-center text-[13px] leading-relaxed text-[#555]"
class="max-w-[300px] text-center text-[13px] leading-relaxed text-[#8a8a8a]"
class:mb-4={$config.show_break_activities}
class:mb-8={!$config.show_break_activities}
use:fadeIn={{ delay: 0.35, y: 10 }}
@@ -222,10 +341,10 @@
class="mb-8 mx-auto max-w-[320px] rounded-2xl border border-[#1a1a1a] bg-[#0a0a0a] px-5 py-3.5 text-center"
use:fadeIn={{ delay: 0.4, y: 10 }}
>
<div class="mb-1.5 text-[10px] font-medium tracking-[0.15em] text-[#444] uppercase">
<div class="mb-1.5 text-[10px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
{getCategoryLabel(currentActivity.category)}
</div>
<p class="text-[13px] leading-relaxed text-[#999]">
<p class="text-[13px] leading-relaxed text-[#8a8a8a]" aria-live="polite">
{currentActivity.text}
</p>
</div>
@@ -236,9 +355,9 @@
<button
use:pressable
class="rounded-full border border-[#222] px-6 py-2.5 text-[12px]
tracking-wider text-[#555] uppercase
tracking-wider text-[#8a8a8a] uppercase
transition-colors duration-200
hover:border-[#333] hover:text-[#999]"
hover:border-[#333] hover:text-[#ccc]"
onclick={cancelBreak}
>
{cancelBtnText}
@@ -257,7 +376,7 @@
{/if}
</div>
{#if $config.snooze_limit > 0}
<p class="mt-3 text-[10px] text-[#2a2a2a]">
<p class="mt-3 text-[10px] text-[#8a8a8a]">
{$timer.snoozesUsed}/{$config.snooze_limit} snoozes used
</p>
{/if}
@@ -265,7 +384,7 @@
<!-- Bottom progress bar for modal -->
{#if isModal}
<div class="break-modal-progress-container">
<div class="break-modal-progress-container" role="progressbar" aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round($timer.breakProgress * 100)} aria-label="Break progress">
<div class="break-modal-progress-track">
<div
class="h-full transition-[width] duration-1000 ease-linear"
@@ -278,7 +397,7 @@
<!-- Fullscreen progress bar - anchored to bottom of screen -->
{#if !isModal}
<div class="absolute bottom-8 left-12 right-12 h-[3px] rounded-full overflow-hidden">
<div class="absolute bottom-8 left-12 right-12 h-[3px] rounded-full overflow-hidden" role="progressbar" aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round($timer.breakProgress * 100)} aria-label="Break progress">
<div class="w-full h-full" style="background: rgba(255,255,255,0.03);">
<div
class="h-full transition-[width] duration-1000 ease-linear"
@@ -383,23 +502,6 @@
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 ── */
.break-ripple {
position: absolute;

View 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>

View File

@@ -0,0 +1,166 @@
<script lang="ts">
import { milestoneEvent, dailyGoalEvent } from "../stores/timer";
import { config } from "../stores/config";
const showMilestone = $derived($milestoneEvent !== null && $config.milestone_celebrations);
const showGoal = $derived($dailyGoalEvent && $config.milestone_celebrations);
const streakDays = $derived($milestoneEvent ?? 0);
// 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}
<div class="celebration-overlay" role="alert" aria-live="assertive">
<!-- 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}
<div class="goal-overlay" role="alert" aria-live="assertive">
<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>
</div>
</div>
{/if}
<style>
.celebration-overlay {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
pointer-events: none;
animation: celebration-fade 3.5s ease forwards;
}
@keyframes celebration-fade {
0%, 70% { opacity: 1; }
100% { opacity: 0; }
}
.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;
pointer-events: none;
animation: goal-slide 3.5s ease forwards;
}
@keyframes goal-slide {
0% { transform: translateX(-50%) translateY(-20px); opacity: 0; }
10% { transform: translateX(-50%) translateY(0); opacity: 1; }
75% { opacity: 1; }
100% { transform: translateX(-50%) translateY(-10px); opacity: 0; }
}
.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;
}
.confetti-particle {
display: none;
}
}
</style>

View File

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

View File

@@ -10,6 +10,7 @@
import { config } from "../stores/config";
import TimerRing from "./TimerRing.svelte";
import { scaleIn, fadeIn, pressable, glowHover } from "../utils/animate";
import { listen } from "@tauri-apps/api/event";
async function toggleTimer() {
const snap = await invoke<TimerSnapshot>("toggle_timer");
@@ -28,7 +29,9 @@
}
const statusText = $derived(
$timer.idlePaused
$timer.deferredBreakPending
? "DEFERRED"
: $timer.idlePaused
? "IDLE"
: $timer.prebreakWarning
? "BREAK SOON"
@@ -37,6 +40,44 @@
: "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)
let lastAnnouncedStatus = $state("");
let statusAnnouncement = $state("");
$effect(() => {
if (statusText !== lastAnnouncedStatus) {
lastAnnouncedStatus = statusText;
statusAnnouncement = `Timer status: ${statusText}. ${formatTime($timer.timeRemaining)} remaining.`;
}
});
const toggleBtnText = $derived(
$timer.state === "running" ? "PAUSE" : "START",
);
@@ -87,6 +128,9 @@
<svelte:window bind:innerWidth={windowW} bind:innerHeight={windowH} />
<h1 class="sr-only" tabindex="-1">Dashboard</h1>
<div aria-live="polite" class="sr-only">{statusAnnouncement}</div>
<div class="relative flex h-full flex-col items-center justify-center">
<!-- Outer: responsive scaling (JS-driven), Inner: mount animation -->
<div style="transform: scale({ringScale}); transform-origin: center center; margin-bottom: {ringMargin}px;">
@@ -96,17 +140,20 @@
size={280}
strokeWidth={8}
accentColor={$config.accent_color}
label="Focus timer"
valueText="{formatTime($timer.timeRemaining)} remaining"
>
<!-- Counter-scale wrapper: text shrinks less than ring -->
<div style="transform: scale({textCounterScale}); transform-origin: center center;">
<!-- Eye icon -->
<svg
aria-hidden="true"
class="mx-auto mb-3 eye-blink"
width="26"
height="26"
viewBox="0 0 24 24"
fill="none"
stroke="#444"
stroke="#888"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
@@ -130,14 +177,74 @@
<!-- Status label -->
<span
class="block text-center text-[11px] font-medium tracking-[0.25em]"
class:text-[#444]={!$timer.prebreakWarning &&
$timer.state === "running"}
class:text-[#333]={!$timer.prebreakWarning &&
$timer.state === "paused"}
class:text-[#8a8a8a]={!$timer.prebreakWarning && !$timer.deferredBreakPending}
class:text-warning={$timer.prebreakWarning}
class:text-[#fca311]={$timer.deferredBreakPending}
>
{statusText}
</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-[#8a8a8a] tabular-nums">
{$timer.pomodoroCyclePosition + 1}/{$timer.pomodoroTotalInCycle}
</span>
</div>
{/if}
<!-- Microbreak countdown -->
{#if $timer.microbreakEnabled && !$timer.microbreakActive && $timer.state === "running"}
<div class="flex items-center gap-1 text-[9px] text-[#8a8a8a]">
<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-[#8a8a8a]">Goal</span>
<div class="w-16 h-[2px] rounded-full overflow-hidden" style="background: #161616;">
<div
class="h-full rounded-full transition-[width] duration-500"
style="width: {Math.min(100, (dailyGoalProgress / $config.daily_goal_breaks) * 100)}%; background: {$config.accent_color};"
></div>
</div>
<span class="text-[9px] text-[#8a8a8a] tabular-nums">
{dailyGoalProgress}/{$config.daily_goal_breaks}
</span>
{/if}
</div>
{/if}
</div>
</div>
</TimerRing>
</div>
@@ -146,7 +253,7 @@
<!-- Last break info -->
<div use:fadeIn={{ delay: 0.4, y: 10 }}>
{#if $timer.hasHadBreak}
<p style="margin-bottom: {ringMargin}px;" class="text-[12px] text-[#2a2a2a]">
<p style="margin-bottom: {ringMargin}px;" class="text-[12px] text-[#8a8a8a]">
Last break {formatDurationAgo($timer.secondsSinceLastBreak)}
</p>
{:else}
@@ -157,12 +264,13 @@
<!-- Natural break notification toast -->
{#if showNaturalBreakToast}
<div
role="alert"
class="absolute top-6 left-1/2 -translate-x-1/2 rounded-2xl border px-5 py-3 text-center backdrop-blur-xl transition-all duration-500"
style="border-color: rgba(63, 185, 80, 0.3); background: rgba(63, 185, 80, 0.1);"
use:scaleIn={{ duration: 0.3, delay: 0 }}
>
<div class="flex items-center gap-2">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#3fb950" stroke-width="2">
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#3fb950" stroke-width="2">
<path d="M20 6L9 17l-5-5"/>
</svg>
<span class="text-[13px] text-[#3fb950]">Natural break detected - timer reset</span>
@@ -190,12 +298,13 @@
aria-label="Start break now"
use:pressable
class="flex h-11 w-11 items-center justify-center rounded-full
border border-[#222] text-[#383838]
border border-[#222] text-[#8a8a8a]
transition-colors duration-200
hover:border-[#333] hover:text-[#666]"
hover:border-[#333] hover:text-[#aaa]"
onclick={startBreakNow}
>
<svg
aria-hidden="true"
width="18"
height="18"
viewBox="0 0 24 24"
@@ -216,15 +325,16 @@
aria-label="Statistics"
use:pressable
class="flex h-11 w-11 items-center justify-center rounded-full
border border-[#222] text-[#383838]
border border-[#222] text-[#8a8a8a]
transition-colors duration-200
hover:border-[#333] hover:text-[#666]"
hover:border-[#333] hover:text-[#aaa]"
onclick={() => {
invoke("set_view", { view: "stats" });
currentView.set("stats");
}}
>
<svg
aria-hidden="true"
width="17"
height="17"
viewBox="0 0 24 24"
@@ -247,12 +357,13 @@
aria-label="Settings"
use:pressable
class="flex h-11 w-11 items-center justify-center rounded-full
border border-[#222] text-[#383838]
border border-[#222] text-[#8a8a8a]
transition-colors duration-200
hover:border-[#333] hover:text-[#666]"
hover:border-[#333] hover:text-[#aaa]"
onclick={openSettings}
>
<svg
aria-hidden="true"
width="17"
height="17"
viewBox="0 0 24 24"

View File

@@ -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>

View File

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

View File

@@ -0,0 +1,80 @@
<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">
<div class="flex items-center gap-3 mb-2">
<svg aria-hidden="true" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#7c6aef" stroke-width="1.5">
<path d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
<span class="text-[15px] font-medium text-white">Look away — 20 feet for {timeRemaining}s</span>
</div>
{#if activity && $config.microbreak_show_activity}
<p class="text-[12px] text-[#8a8a8a] 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>

View File

@@ -16,6 +16,9 @@
let breakColor = $state("#7c6aef");
let countdownFont = $state("");
let draggable = $state(false);
let pomodoroEnabled = $state(false);
let pomodoroCyclePosition = $state(0);
let pomodoroTotalInCycle = $state(4);
// Use config store directly for live updates
const uiZoom = $derived($config.ui_zoom);
@@ -132,6 +135,9 @@
timeText = formatTime(snap.timeRemaining);
progress = snap.progress;
}
pomodoroEnabled = snap.pomodoroEnabled;
pomodoroCyclePosition = snap.pomodoroCyclePosition;
pomodoroTotalInCycle = snap.pomodoroTotalInCycle;
}
// Click opens main window
@@ -192,8 +198,7 @@ const fontStyle = $derived(
});
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="w-full h-full flex items-center justify-center overflow-hidden">
<div class="w-full h-full flex items-center justify-center overflow-hidden" role="status" aria-label="Mini timer: {timeText} {state === 'breakActive' ? 'break active' : state === 'running' ? 'running' : 'paused'}">
<div
style="
width: {100 / zoomScale}%;
@@ -206,8 +211,10 @@ const fontStyle = $derived(
class="flex items-center justify-center w-full h-full"
style="padding: 22px 14px 22px 24px;"
>
<!-- svelte-ignore a11y_no_static_element_interactions a11y_no_noninteractive_element_interactions -->
<div
class="mini-pill flex h-full w-full items-center select-none"
role="application"
class:mini-draggable={draggable}
style="
background: rgba(0, 0, 0, 0.85);
@@ -226,6 +233,7 @@ const fontStyle = $derived(
<div class="relative flex-shrink-0" style="width: {ringSize}px; height: {ringSize}px;">
<!-- Glow SVG (larger for blur room) -->
<svg
aria-hidden="true"
width={viewSize}
height={viewSize}
class="pointer-events-none absolute"
@@ -295,6 +303,7 @@ const fontStyle = $derived(
<!-- Non-glow SVG: track + crisp ring -->
<svg
aria-hidden="true"
width={ringSize}
height={ringSize}
class="absolute"
@@ -330,6 +339,12 @@ const fontStyle = $derived(
>
{timeText}
</span>
<!-- F3: Pomodoro cycle indicator -->
{#if pomodoroEnabled}
<span class="ml-1.5 text-[10px] tabular-nums" style="color: #8a8a8a;">
{pomodoroCyclePosition + 1}/{pomodoroTotalInCycle}
</span>
{/if}
</div>
</div>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -9,9 +9,13 @@
todaySkipped: number;
todaySnoozed: number;
todayBreakTimeSecs: number;
todayNaturalBreaks: number;
todayNaturalBreakTimeSecs: number;
complianceRate: number;
currentStreak: number;
bestStreak: number;
dailyGoalProgress: number;
dailyGoalMet: boolean;
}
interface DayRecord {
@@ -22,13 +26,27 @@
totalBreakTimeSecs: number;
}
interface WeekSummary {
weekStart: string;
totalCompleted: number;
totalSkipped: number;
totalBreakTimeSecs: number;
complianceRate: number;
avgDailyCompleted: number;
}
let stats = $state<StatsSnapshot | null>(null);
let history = $state<DayRecord[]>([]);
let monthHistory = $state<DayRecord[]>([]);
let weeklySummaries = $state<WeekSummary[]>([]);
let activeTab = $state<"today" | "weekly" | "monthly">("today");
async function loadStats() {
try {
stats = await invoke<StatsSnapshot>("get_stats");
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) {
console.error("Failed to load stats:", e);
}
@@ -56,7 +74,21 @@
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();
$effect(() => {
@@ -64,6 +96,22 @@
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[]) {
const ctx = canvas.getContext("2d");
if (!ctx) return;
@@ -78,8 +126,8 @@
ctx.clearRect(0, 0, w, h);
const maxBreaks = Math.max(1, ...data.map((d) => d.breaksCompleted + d.breaksSkipped));
const barWidth = Math.floor((w - 40) / data.length) - 8;
const barGap = 8;
const barWidth = Math.max(2, Math.floor((w - 40) / data.length) - (data.length > 10 ? 2 : 8));
const barGap = data.length > 10 ? 2 : 8;
const chartHeight = h - 30;
const accentColor = $config.accent_color || "#ff4d00";
@@ -90,33 +138,103 @@
const completedH = total > 0 ? (day.breaksCompleted / maxBreaks) * chartHeight : 0;
const skippedH = total > 0 ? (day.breaksSkipped / maxBreaks) * chartHeight : 0;
// Completed bar
if (completedH > 0) {
ctx.fillStyle = accentColor;
ctx.beginPath();
const barY = chartHeight - completedH;
roundedRect(ctx, x, barY, barWidth, completedH, 4);
roundedRect(ctx, x, barY, barWidth, completedH, Math.min(4, barWidth / 2));
ctx.fill();
}
// Skipped bar (stacked on top)
if (skippedH > 0) {
ctx.fillStyle = "#333";
ctx.beginPath();
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();
}
// Day label
ctx.fillStyle = "#444";
// Day label — show every Nth for 30-day
if (data.length <= 7 || i % 5 === 0) {
ctx.fillStyle = "#8a8a8a";
ctx.font = "10px -apple-system, sans-serif";
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);
}
});
}
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();
});
}
const chartAriaLabel = $derived(() => {
if (history.length === 0) return "No break history data available";
const total = history.reduce((sum, d) => sum + d.breaksCompleted, 0);
const skipped = history.reduce((sum, d) => sum + d.breaksSkipped, 0);
return `Weekly break chart: ${total} completed and ${skipped} skipped over ${history.length} days`;
});
// 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(
ctx: CanvasRenderingContext2D,
x: number,
@@ -149,10 +267,11 @@
aria-label="Back to dashboard"
use:pressable
class="mr-3 flex h-8 w-8 items-center justify-center rounded-full
text-[#444] transition-colors hover:text-white"
text-[#8a8a8a] transition-colors hover:text-white"
onclick={goBack}
>
<svg
aria-hidden="true"
width="16"
height="16"
viewBox="0 0 24 24"
@@ -167,20 +286,37 @@
</button>
<h1
data-tauri-drag-region
tabindex="-1"
class="flex-1 text-lg font-medium text-white"
>
Statistics
</h1>
</div>
<!-- Tab navigation -->
<div 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
use:pressable
class="rounded-lg px-4 py-1.5 text-[11px] tracking-wider uppercase transition-all duration-200
{activeTab === tab
? 'bg-[#1a1a1a] text-white'
: 'text-[#8a8a8a] hover:text-white'}"
onclick={() => activeTab = tab as any}
>
{label}
</button>
{/each}
</div>
<!-- Scrollable content -->
<div class="flex-1 overflow-y-auto px-5 pb-6" use:dragScroll>
<div class="space-y-3">
{#if activeTab === "today"}
<!-- Today's summary -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0 }}>
<h3
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#666] uppercase"
>
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
Today
</h3>
@@ -189,7 +325,7 @@
<div class="text-[28px] font-semibold text-white tabular-nums">
{stats?.todayCompleted ?? 0}
</div>
<div class="text-[11px] text-[#777]">Breaks taken</div>
<div class="text-[11px] text-[#8a8a8a]">Breaks taken</div>
</div>
<div class="text-center">
<div class="text-[28px] font-semibold tabular-nums"
@@ -197,35 +333,66 @@
>
{compliancePercent}%
</div>
<div class="text-[11px] text-[#777]">Compliance</div>
<div class="text-[11px] text-[#8a8a8a]">Compliance</div>
</div>
<div class="text-center">
<div class="text-[28px] font-semibold text-white tabular-nums">
{breakTimeFormatted()}
</div>
<div class="text-[11px] text-[#777]">Break time</div>
<div class="text-[11px] text-[#8a8a8a]">Break time</div>
</div>
<div class="text-center">
<div class="text-[28px] font-semibold text-white tabular-nums">
{stats?.todaySkipped ?? 0}
</div>
<div class="text-[11px] text-[#777]">Skipped</div>
<div class="text-[11px] text-[#8a8a8a]">Skipped</div>
</div>
</div>
</section>
<!-- F10: Daily goal -->
{#if $config.daily_goal_enabled}
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.04 }}>
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
Daily Goal
</h3>
<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-[#8a8a8a]">
{stats?.dailyGoalMet ? "Goal reached!" : `${$config.daily_goal_breaks - (stats?.dailyGoalProgress ?? 0)} more to go`}
</div>
</div>
</div>
</section>
{/if}
<!-- Streak -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.06 }}>
<h3
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#666] uppercase"
>
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
Streak
</h3>
<div class="flex items-center justify-between">
<div>
<div class="text-[13px] text-white">Current streak</div>
<div class="text-[11px] text-[#777]">Consecutive days with breaks</div>
<div class="text-[11px] text-[#8a8a8a]">Consecutive days with breaks</div>
</div>
<div class="text-[24px] font-semibold tabular-nums" style="color: {$config.accent_color}">
{stats?.currentStreak ?? 0}
@@ -237,28 +404,61 @@
<div class="flex items-center justify-between">
<div>
<div class="text-[13px] text-white">Best streak</div>
<div class="text-[11px] text-[#777]">All-time record</div>
<div class="text-[11px] text-[#8a8a8a]">All-time record</div>
</div>
<div class="text-[24px] font-semibold text-white tabular-nums">
{stats?.bestStreak ?? 0}
</div>
</div>
<!-- F10: Next milestone -->
{#if nextMilestone()}
<div class="my-4 h-px bg-[#161616]"></div>
<div class="flex items-center justify-between">
<div>
<div class="text-[13px] text-white">Next milestone</div>
<div class="text-[11px] text-[#8a8a8a]">{nextMilestone()} day streak</div>
</div>
<div class="text-[13px] text-[#8a8a8a] tabular-nums">
{nextMilestone()! - (stats?.currentStreak ?? 0)} days away
</div>
</div>
{/if}
</section>
<!-- Weekly chart -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.12 }}>
<h3
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#666] uppercase"
>
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
Last 7 Days
</h3>
<!-- svelte-ignore a11y_no_interactive_element_to_noninteractive_role -->
<canvas
bind:this={chartCanvas}
class="h-[140px] w-full"
role="img"
aria-label={chartAriaLabel()}
></canvas>
<div class="mt-3 flex items-center justify-center gap-4 text-[10px] text-[#777]">
{#if history.length > 0}
<table class="sr-only">
<caption>Break history for the last {history.length} days</caption>
<thead>
<tr><th>Date</th><th>Completed</th><th>Skipped</th></tr>
</thead>
<tbody>
{#each history as day}
<tr>
<td>{day.date}</td>
<td>{day.breaksCompleted}</td>
<td>{day.breaksSkipped}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
<div class="mt-3 flex items-center justify-center gap-4 text-[10px] text-[#8a8a8a]">
<div class="flex items-center gap-1.5">
<div class="h-2 w-2 rounded-sm" style="background: {$config.accent_color}"></div>
Completed
@@ -269,6 +469,142 @@
</div>
</div>
</section>
{:else if activeTab === "weekly"}
<!-- Weekly summaries -->
{#each weeklySummaries as week, i}
{@const prevWeek = weeklySummaries[i + 1]}
{@const trend = prevWeek ? week.complianceRate - prevWeek.complianceRate : 0}
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: i * 0.06 }}>
<h3 class="mb-3 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
Week of {week.weekStart}
</h3>
<div class="grid grid-cols-3 gap-3 mb-3">
<div class="text-center">
<div class="text-[22px] font-semibold text-white tabular-nums">{week.totalCompleted}</div>
<div class="text-[10px] text-[#8a8a8a]">Completed</div>
</div>
<div class="text-center">
<div class="text-[22px] font-semibold text-white tabular-nums">{week.totalSkipped}</div>
<div class="text-[10px] text-[#8a8a8a]">Skipped</div>
</div>
<div class="text-center">
<div class="text-[22px] font-semibold tabular-nums" style="color: {$config.accent_color}">
{Math.round(week.complianceRate * 100)}%
</div>
<div class="text-[10px] text-[#8a8a8a]">Compliance</div>
</div>
</div>
<div class="flex items-center justify-between text-[11px]">
<span class="text-[#8a8a8a]">Avg {week.avgDailyCompleted.toFixed(1)} breaks/day</span>
{#if prevWeek}
<span class="flex items-center gap-1"
style="color: {trend > 0 ? '#3fb950' : trend < 0 ? '#f85149' : '#8a8a8a'};"
>
{#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}
{:else}
<!-- Monthly: 30-day chart -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0 }}>
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
Last 30 Days
</h3>
<!-- 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>
<div class="mt-3 flex items-center justify-center gap-4 text-[10px] text-[#8a8a8a]">
<div class="flex items-center gap-1.5">
<div class="h-2 w-2 rounded-sm" style="background: {$config.accent_color}"></div>
Completed
</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 }}>
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
Activity Heatmap
</h3>
<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>
<div class="mt-3 flex items-center justify-center gap-2 text-[10px] text-[#8a8a8a]">
<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 }}>
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
Monthly Summary
</h3>
<div class="grid grid-cols-2 gap-4">
<div class="text-center">
<div class="text-[22px] font-semibold text-white tabular-nums">{monthTotalCompleted}</div>
<div class="text-[10px] text-[#8a8a8a]">Total breaks</div>
</div>
<div class="text-center">
<div class="text-[22px] font-semibold tabular-nums" style="color: {$config.accent_color}">
{monthAvgCompliance()}%
</div>
<div class="text-[10px] text-[#8a8a8a]">Avg compliance</div>
</div>
<div class="text-center">
<div class="text-[22px] font-semibold text-white tabular-nums">
{Math.floor(monthTotalTime / 60)} min
</div>
<div class="text-[10px] text-[#8a8a8a]">Total break time</div>
</div>
<div class="text-center">
<div class="text-[22px] font-semibold text-white tabular-nums">
{(monthTotalCompleted / 30).toFixed(1)}
</div>
<div class="text-[10px] text-[#8a8a8a]">Avg daily breaks</div>
</div>
</div>
</section>
{/if}
</div>
</div>
</div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,7 +11,13 @@ export interface DaySchedule {
ranges: TimeRange[];
}
export type { TimeRange, DaySchedule };
export interface CustomActivity {
id: string;
category: string;
text: string;
is_favorite: boolean;
enabled: boolean;
}
export interface Config {
break_duration: number;
@@ -24,7 +30,7 @@ export interface Config {
allow_end_early: boolean;
immediately_start_breaks: 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;
color_scheme: string;
backdrop_opacity: number;
@@ -49,6 +55,45 @@ export interface Config {
background_blobs_enabled: boolean;
mini_click_through: boolean;
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 = {
@@ -63,13 +108,13 @@ const defaultConfig: Config = {
immediately_start_breaks: false,
working_hours_enabled: false,
working_hours_schedule: [
{ enabled: true, ranges: [{ start: "09:00", end: "18:00" }] }, // Monday
{ enabled: true, ranges: [{ start: "09:00", end: "18:00" }] }, // Tuesday
{ enabled: true, ranges: [{ start: "09:00", end: "18:00" }] }, // Wednesday
{ enabled: true, ranges: [{ start: "09:00", end: "18:00" }] }, // Thursday
{ enabled: true, ranges: [{ start: "09:00", end: "18:00" }] }, // Friday
{ enabled: false, ranges: [{ start: "09:00", end: "18:00" }] }, // Saturday
{ enabled: false, ranges: [{ start: "09:00", end: "18:00" }] }, // Sunday
{ enabled: true, ranges: [{ start: "09:00", end: "18:00" }] },
{ enabled: true, ranges: [{ start: "09:00", end: "18:00" }] },
{ enabled: true, ranges: [{ start: "09:00", end: "18:00" }] },
{ enabled: true, ranges: [{ start: "09:00", end: "18:00" }] },
{ enabled: true, ranges: [{ start: "09:00", end: "18:00" }] },
{ enabled: false, ranges: [{ start: "09:00", end: "18:00" }] },
{ enabled: false, ranges: [{ start: "09:00", end: "18:00" }] },
],
dark_mode: true,
color_scheme: "Ocean",
@@ -95,6 +140,45 @@ const defaultConfig: Config = {
background_blobs_enabled: true,
mini_click_through: true,
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);

View File

@@ -26,6 +26,27 @@ export interface TimerSnapshot {
naturalBreakOccurred: boolean;
smartBreaksEnabled: boolean;
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 = {
@@ -50,6 +71,22 @@ const defaultSnapshot: TimerSnapshot = {
naturalBreakOccurred: false,
smartBreaksEnabled: true,
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);
@@ -114,7 +151,42 @@ export async function initTimerStore() {
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
export function formatTime(secs: number): string {

View File

@@ -106,10 +106,55 @@ export function getCategoryLabel(cat: BreakActivity["category"]): string {
return categoryLabels[cat];
}
/** Pick a random activity, optionally excluding a previous one */
export function pickRandomActivity(exclude?: BreakActivity): BreakActivity {
const pool = exclude
? breakActivities.filter((a) => a.text !== exclude.text)
: breakActivities;
return pool[Math.floor(Math.random() * pool.length)];
/** Pick a random activity, optionally excluding a previous one.
* When config is provided, respects disabled/favorite/custom activity settings. */
export function pickRandomActivity(
exclude?: BreakActivity,
config?: {
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)];
}

View File

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

View File

@@ -2,19 +2,35 @@ import "./app.css";
import App from "./App.svelte";
import MiniTimer from "./lib/components/MiniTimer.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";
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 isBreak = params.has("break");
if (isMini || isBreak) {
if (isMini || isBreak || isMicrobreak || isDim || isBreakOverlay) {
// Transparent body so rounded shapes show through the transparent window
document.body.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, {
target: document.getElementById("app")!,