10 Commits

Author SHA1 Message Date
Your Name
2f7aa074bc v0.2.0: WCAG 2.2 AAA accessibility + toggle fix + version bump
- Upgrade accessibility from WCAG 2.1 AA to WCAG 2.2 AAA conformance
- 42 accessibility fixes across 18 frontend components
- Enhanced contrast ratios (7:1 body text, 4.5:1 large text, 3:1 non-text)
- 44px minimum touch/click targets on all interactive elements
- Full WAI-ARIA 1.2: tablist, radiogroup, alertdialog, progressbar, switch
- Screen-reader-only data tables behind chart, dynamic page titles
- Skip navigation link, semantic heading hierarchy (h1 > h2)
- Celebration popups persist on hover/focus with keyboard dismiss
- Fix ToggleSwitch visual height (44px hit area, 28px visual track)
- Update README with detailed WCAG 2.2 AAA accessibility section
- Include WCAG design doc and implementation plan in docs/plans/
2026-02-18 19:18:15 +02:00
Your Name
d26b73288d fix: build.rs cfg() checks HOST not TARGET - use CARGO_CFG_TARGET_ENV
The #[cfg(target_env = "gnu")] attribute in build.rs checks the host
compiler environment, not the build target. On MSVC-hosted Rust
targeting x86_64-pc-windows-gnu, this was silently false, causing:

1. WebView2Loader static linking swap to never run
2. Resource compilation fix (windres COFF output) to never run

embed-resource finds MSVC's rc.exe via Windows SDK and produces .res
files that GNU ld can't link. Fix: detect .res format (null header)
and re-compile with MinGW windres --output-format=coff.
2026-02-18 19:18:15 +02:00
Your Name
8a04edc2bc a11y: final cleanup - remaining hardcoded colors
- FontSelector: text-[#8a8a8a]→text-text-sec, border-[#222]→border-border
- StatsView: Canvas fillStyle and inline trend color #8a8a8a→#a8a8a8
- ActivityManager: border-[#222]→border-border, #f85149→#ff6b6b danger color
- Settings: #f85149→#ff6b6b danger color on reset/delete buttons
2026-02-18 19:18:15 +02:00
Your Name
aadc1eaac0 a11y: Tasks 9,13-17 - Settings ARIA, MiniTimer, overlays, ColorPicker, ActivityManager
- Settings: breathing radiogroup/radio, sound aria-pressed, contrast tokens,
  placeholder contrast, reset aria-live, abbreviation tags, title tooltips,
  back button 44px target
- MiniTimer: paused text #555→#a8a8a8 for AAA contrast
- MicrobreakOverlay: alertdialog role, sr-only heading, aria-describedby
- BreakOverlay: alertdialog role, sr-only heading, aria-label
- ColorPicker: enlarge swatches 22→28px with 44px hit areas, aria-pressed
- ActivityManager: enlarge action buttons to 44px targets, contrast tokens
2026-02-18 19:18:15 +02:00
Your Name
acf06c8d32 a11y: Tasks 7-12 - Dashboard, Settings, StatsView, BreakScreen, Celebration
- Dashboard: text-text-sec tokens, nav landmark, toast hover persistence,
  goal progressbar ARIA, pomodoro sr-only text
- Settings: h3→h2 heading hierarchy, section aria-labelledby with ids,
  Working Hours heading added
- StatsView: h3→h2, tablist/tab/tabpanel ARIA pattern, sr-only data tables
  for 30-day chart and heatmap, contrast tokens
- BreakScreen: strict-mode focus safety span, breathing phase-only
  announcements, contrast tokens
- Celebration: JS-controlled hover/focus persistence, dismiss buttons,
  Escape key, removed pointer-events:none
- Titlebar: removed redundant role="banner" on <header>
2026-02-18 19:18:15 +02:00
Your Name
95f684450c a11y: Tasks 2-6 - App shell, Titlebar, ToggleSwitch, Stepper, animate.ts
- Add skip-to-content link and dynamic document title (App.svelte)
- Wrap titlebar in header landmark, enlarge traffic lights to 44px (Titlebar.svelte)
- Enlarge toggle switch to 52x28, improve OFF knob contrast (ToggleSwitch.svelte)
- Enlarge stepper buttons to 36px, add keyboard hold-to-repeat (Stepper.svelte)
- Add keyboard feedback to pressable, focus glow to glowHover (animate.ts)
2026-02-18 19:18:15 +02:00
Your Name
3ae9db3be0 a11y: update theme tokens and global styles for WCAG AAA 2026-02-18 19:18:15 +02:00
51541c9b66 Update README.md 2026-02-07 13:27:24 +00:00
Your Name
743477cd4e Add emojis to all top-level README section headers 2026-02-07 15:24:03 +02:00
Your Name
666b2418b9 Add emojis to feature section headers in README 2026-02-07 15:22:11 +02:00
25 changed files with 2086 additions and 487 deletions

125
README.md
View File

@@ -21,7 +21,7 @@
<img src="https://img.shields.io/badge/svelte-5-FF3E00?style=flat-square&logo=svelte&logoColor=white" alt="Svelte 5" /> <img src="https://img.shields.io/badge/svelte-5-FF3E00?style=flat-square&logo=svelte&logoColor=white" alt="Svelte 5" />
<img src="https://img.shields.io/badge/rust-2021-000000?style=flat-square&logo=rust&logoColor=white" alt="Rust" /> <img src="https://img.shields.io/badge/rust-2021-000000?style=flat-square&logo=rust&logoColor=white" alt="Rust" />
<img src="https://img.shields.io/badge/tailwind-v4-06B6D4?style=flat-square&logo=tailwindcss&logoColor=white" alt="Tailwind v4" /> <img src="https://img.shields.io/badge/tailwind-v4-06B6D4?style=flat-square&logo=tailwindcss&logoColor=white" alt="Tailwind v4" />
<img src="https://img.shields.io/badge/WCAG_2.1-AA-228B22?style=flat-square" alt="WCAG 2.1 AA" /> <img src="https://img.shields.io/badge/WCAG_2.2-AAA-228B22?style=flat-square&logo=w3c&logoColor=white" alt="WCAG 2.2 AAA" />
</p> </p>
<p align="center"> <p align="center">
@@ -32,7 +32,7 @@
--- ---
## Why does this exist? ## 💡 Why does this exist?
Repetitive strain injury and eye strain are not personal failings. They are the predictable result of systems that treat human attention as an extractable resource. Every person at a screen deserves a tool that gently interrupts the grind - one that serves *them*, not a subscription model, not an analytics dashboard, not a corporate wellness KPI. Repetitive strain injury and eye strain are not personal failings. They are the predictable result of systems that treat human attention as an extractable resource. Every person at a screen deserves a tool that gently interrupts the grind - one that serves *them*, not a subscription model, not an analytics dashboard, not a corporate wellness KPI.
@@ -42,7 +42,7 @@ Core Cooldown is a single portable `.exe`. No installer, no account, no telemetr
--- ---
## Philosophy ## 🧭 Philosophy
Core Cooldown exists because rest is not a luxury. It is a fundamental need that no productivity framework, no employer, and no software platform should gatekeep behind a paywall or a subscription. Core Cooldown exists because rest is not a luxury. It is a fundamental need that no productivity framework, no employer, and no software platform should gatekeep behind a paywall or a subscription.
@@ -59,7 +59,7 @@ Tools for human wellbeing should never be enclosed, never be scarce, and never s
--- ---
## Screenshots ## 📸 Screenshots
<p align="center"> <p align="center">
<img src="screenshots/01-dashboard.png" alt="Dashboard - Main Timer" width="420" /><br /> <img src="screenshots/01-dashboard.png" alt="Dashboard - Main Timer" width="420" /><br />
@@ -98,9 +98,9 @@ Tools for human wellbeing should never be enclosed, never be scarce, and never s
--- ---
## Features ## Features
### Timer & Breaks ### ⏱️ Timer & Breaks
| | Feature | Description | | | Feature | Description |
|:--|:--------|:------------| |:--|:--------|:------------|
@@ -116,7 +116,7 @@ Tools for human wellbeing should never be enclosed, never be scarce, and never s
<br /> <br />
### Pomodoro Mode ### 🍅 Pomodoro Mode
A full Pomodoro timer built in. Alternates short breaks with a longer recovery break after a configurable number of focus sessions. A full Pomodoro timer built in. Alternates short breaks with a longer recovery break after a configurable number of focus sessions.
@@ -128,7 +128,7 @@ A full Pomodoro timer built in. Alternates short breaks with a longer recovery b
<br /> <br />
### Microbreaks (20-20-20 Rule) ### 👁️ Microbreaks (20-20-20 Rule)
Quick eye rest reminders between full breaks. The 20-20-20 rule: every 20 minutes, look at something 20 feet away for 20 seconds. Quick eye rest reminders between full breaks. The 20-20-20 rule: every 20 minutes, look at something 20 feet away for 20 seconds.
@@ -141,7 +141,7 @@ Quick eye rest reminders between full breaks. The 20-20-20 rule: every 20 minute
<br /> <br />
### Breathing Guide ### 🌬️ Breathing Guide
A visual breathing exercise during breaks. The breathing text pulses with the rhythm - scaling up on inhale, holding on hold, scaling down on exhale - with a color gradient that interpolates between your accent and break colors. A visual breathing exercise during breaks. The breathing text pulses with the rhythm - scaling up on inhale, holding on hold, scaling down on exhale - with a color gradient that interpolates between your accent and break colors.
@@ -155,7 +155,7 @@ A visual breathing exercise during breaks. The breathing text pulses with the rh
<br /> <br />
### Idle Detection & Smart Breaks ### 💤 Idle Detection & Smart Breaks
| | Feature | Description | | | Feature | Description |
|:--|:--------|:------------| |:--|:--------|:------------|
@@ -166,7 +166,7 @@ A visual breathing exercise during breaks. The breathing text pulses with the rh
<br /> <br />
### Presentation Mode ### 🎬 Presentation Mode
Detects fullscreen applications (presentations, video calls, games) and defers breaks until you exit. Detects fullscreen applications (presentations, video calls, games) and defers breaks until you exit.
@@ -177,7 +177,7 @@ Detects fullscreen applications (presentations, video calls, games) and defers b
<br /> <br />
### Break Activities ### 🤸 Break Activities
Each break shows a randomized suggestion from a curated library of **71 activities** across four categories: Each break shows a randomized suggestion from a curated library of **71 activities** across four categories:
@@ -198,7 +198,7 @@ Activities cycle every 30 seconds and never repeat consecutively.
<br /> <br />
### Goals & Streaks ### 🏆 Goals & Streaks
| | Feature | Description | | | Feature | Description |
|:--|:--------|:------------| |:--|:--------|:------------|
@@ -209,7 +209,7 @@ Activities cycle every 30 seconds and never repeat consecutively.
<br /> <br />
### Screen Dimming ### 🌑 Screen Dimming
A gentle pre-break nudge that gradually dims your screen before the break starts. A gentle pre-break nudge that gradually dims your screen before the break starts.
@@ -219,7 +219,7 @@ A gentle pre-break nudge that gradually dims your screen before the break starts
<br /> <br />
### Working Hours Schedule ### 📅 Working Hours Schedule
A per-day schedule with multiple time ranges per day. The timer only runs during your configured hours - outside those hours, it pauses automatically. A per-day schedule with multiple time ranges per day. The timer only runs during your configured hours - outside those hours, it pauses automatically.
@@ -229,7 +229,7 @@ A per-day schedule with multiple time ranges per day. The timer only runs during
<br /> <br />
### Statistics & History ### 📊 Statistics & History
| | Metric | Description | | | Metric | Description |
|:--|:-------|:------------| |:--|:-------|:------------|
@@ -243,7 +243,7 @@ All statistics stored locally in a plain JSON file next to the executable.
<br /> <br />
### Sound Effects ### 🔊 Sound Effects
Synthesized notification sounds via Web Audio API - no bundled audio files, no network requests. Synthesized notification sounds via Web Audio API - no bundled audio files, no network requests.
@@ -253,7 +253,7 @@ Sounds play on break start, pre-break warning, and break completion. Volume conf
<br /> <br />
### Global Keyboard Shortcuts ### ⌨️ Global Keyboard Shortcuts
| Shortcut | Action | | Shortcut | Action |
|:---------|:-------| |:---------|:-------|
@@ -265,7 +265,7 @@ Works system-wide, even when Core Cooldown is not focused.
<br /> <br />
### System Tray ### 🔵 System Tray
- **Dynamic icon** - 32x32 progress arc rendered in real-time (orange = focus, purple = break, dimmed = paused) - **Dynamic icon** - 32x32 progress arc rendered in real-time (orange = focus, purple = break, dimmed = paused)
- **Countdown tooltip** - hover over tray icon to see time remaining - **Countdown tooltip** - hover over tray icon to see time remaining
@@ -273,7 +273,7 @@ Works system-wide, even when Core Cooldown is not focused.
<br /> <br />
### Mini Mode ### 📌 Mini Mode
A compact floating timer (200x50px) that sits on top of your other windows. A compact floating timer (200x50px) that sits on top of your other windows.
@@ -284,7 +284,7 @@ A compact floating timer (200x50px) that sits on top of your other windows.
<br /> <br />
### Appearance & Customization ### 🎨 Appearance & Customization
| Setting | Range | | Setting | Range |
|:--------|:------| |:--------|:------|
@@ -297,7 +297,7 @@ A compact floating timer (200x50px) that sits on top of your other windows.
<br /> <br />
### Notifications ### 🔔 Notifications
Native Windows toast notifications for: Native Windows toast notifications for:
- Pre-break warnings (configurable seconds before break) - Pre-break warnings (configurable seconds before break)
@@ -308,7 +308,7 @@ Native Windows toast notifications for:
<br /> <br />
### Window Behavior ### 🪟 Window Behavior
- **Frameless window** with custom titlebar and drag region - **Frameless window** with custom titlebar and drag region
- **Transparent background** with frosted glass effects (backdrop-blur) - **Transparent background** with frosted glass effects (backdrop-blur)
@@ -318,27 +318,60 @@ Native Windows toast notifications for:
<br /> <br />
### Accessibility ### Accessibility
Core Cooldown targets **WCAG 2.1 Level AA** compliance. A break timer for preventing repetitive strain injury should be usable by everyone - including those who already live with disabilities. <p>
<img src="https://img.shields.io/badge/WCAG_2.2-AAA_Conformance-228B22?style=for-the-badge&logo=w3c&logoColor=white" alt="WCAG 2.2 AAA Conformance" />
<img src="https://img.shields.io/badge/since-v0.2.0-blue?style=for-the-badge" alt="Since v0.2.0" />
</p>
| | Feature | Description | Core Cooldown targets **WCAG 2.2 Level AAA** conformance - the highest level of the Web Content Accessibility Guidelines. A break timer for preventing repetitive strain injury should be usable by everyone, including those who already live with disabilities.
|:--|:--------|:------------|
| ⌨️ | **Full keyboard navigation** | Every control reachable and operable via keyboard alone. Arrow keys navigate dropdowns, adjust color pickers, steppers, and time spinners. Tab/Shift+Tab cycles through all interactive elements. | > **Why AAA?** Most applications stop at AA. We went further because the people who benefit most from a break timer - those with repetitive strain injuries, chronic pain, or vision impairments - are the same people who need the strongest accessibility support. AAA isn't a checkbox. It's the right thing to do.
| 🔍 | **Visible focus indicators** | Global `:focus-visible` outlines on all interactive elements - no hidden or suppressed focus rings |
| 🗣️ | **Screen reader support** | `aria-live` regions announce timer state, 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. | #### Contrast & Visual Design
| 🎯 | **Focus management** | View transitions move focus to the new view's heading. Break screen traps focus to prevent interaction with obscured content. 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. | | | Feature | Standard | Description |
| 🖥️ | **Windows High Contrast** | `forced-colors: active` media query maps all theme tokens to system colors | |:--|:--------|:---------|:------------|
| 🐢 | **Reduced motion** | `prefers-reduced-motion` disables all CSS animations/transitions, all JavaScript-driven Web Animations API effects, and momentum scroll physics. No functionality lost - just calmer. | | 🎨 | **Enhanced text contrast** | AAA 7:1 | All body text meets 7:1 contrast ratio against dark backgrounds. Secondary text `#a8a8a8` on `#000` = 7.28:1. |
| 🏷️ | **Descriptive labels** | All toggle switches, steppers, buttons, dropdowns, and form controls have descriptive accessible names | | 🔤 | **Large text contrast** | AAA 4.5:1 | Headings and large text (18px+) meet 4.5:1 minimum. Timer countdown, break titles validated. |
| 👆 | **Touch targets** | Interactive elements meet minimum 32x32px hit areas for comfortable interaction | | 🎯 | **Non-text contrast** | AA 3:1 | UI components (toggles, steppers, rings, chart bars, color swatches) all meet 3:1 against adjacent colors. |
| 🖥️ | **Windows High Contrast** | AAA | `forced-colors: active` maps all theme tokens to system colors. Full usability in all Windows contrast themes. |
| 🐢 | **Reduced motion** | AAA | `prefers-reduced-motion` disables all CSS animations, JS Web Animations API effects, and momentum scroll. Zero functionality loss. |
#### Keyboard & Navigation
| | Feature | Standard | Description |
|:--|:--------|:---------|:------------|
| ⌨️ | **Full keyboard access** | AAA 2.1.3 | Every control operable via keyboard alone - no exceptions. Arrow keys for color pickers, steppers, radio groups. Tab/Shift+Tab cycles all interactive elements. |
| 🔍 | **Visible focus indicators** | AAA 2.4.13 | 2px solid white outline with dark shadow fallback on every interactive element. No hidden or suppressed focus rings. |
| ⏭️ | **Skip navigation** | AA 2.4.1 | Skip-to-content link bypasses the titlebar on Tab. |
| 🏠 | **Focus management** | AA 2.4.3 | View transitions auto-focus the new view's heading. Break screen traps focus. Dropdown focus returns to trigger on close. |
| 🏷️ | **Heading structure** | AAA 2.4.10 | Semantic `h1` > `h2` hierarchy across all views. Settings sections use `h2` with `aria-labelledby`. |
#### Screen Readers & Assistive Technology
| | Feature | Standard | Description |
|:--|:--------|:---------|:------------|
| 🗣️ | **Live regions** | AA 4.1.3 | `aria-live` announces timer state, breathing phase, break activities, status changes, and celebration events. |
| 📊 | **Semantic roles** | AA 4.1.2 | `progressbar` on timer rings, `switch` on toggles, `tablist`/`tab`/`tabpanel` on stats view, `radiogroup`/`radio` on breathing patterns, `alertdialog` on overlays. |
| 📋 | **Data tables** | A 1.3.1 | Screen-reader-only data tables behind the 7-day chart provide the same information non-visually. |
| 🏷️ | **Accessible names** | AA 4.1.2 | Every toggle, stepper, button, swatch, and form control has a descriptive `aria-label` or visible label. Sound presets use `aria-pressed`. |
| 📝 | **Page titles** | AAA 2.4.2 | Dynamic `document.title` updates per view ("Core Cooldown - Dashboard", "- Settings", etc.). |
#### Target Sizes & Interaction
| | Feature | Standard | Description |
|:--|:--------|:---------|:------------|
| 👆 | **44px touch targets** | AAA 2.5.8 | All interactive elements (buttons, toggles, steppers, color swatches, titlebar controls) have minimum 44x44px hit areas. Visual size may be smaller - the clickable area extends invisibly. |
| 🎯 | **Celebration persistence** | - | Milestone/goal popups stay visible on hover or focus, with keyboard-accessible dismiss buttons and Escape key support. |
| ⏱️ | **Hold-to-repeat** | - | Stepper +/- buttons support press-and-hold for continuous increment, with keyboard Enter/Space triggering the same behavior. |
<br /> <br />
--- ---
## Portability ## 📦 Portability
Core Cooldown is **fully portable**. The executable carries everything it needs and stores everything it creates right next to itself: Core Cooldown is **fully portable**. The executable carries everything it needs and stores everything it creates right next to itself:
@@ -360,7 +393,7 @@ anywhere-you-want/
--- ---
## Installation ## 🚀 Installation
``` ```
1. Download core-cooldown.exe from the Releases page 1. Download core-cooldown.exe from the Releases page
@@ -376,7 +409,7 @@ That's it. No elevated permissions. No runtime dependencies. The first launch ma
--- ---
## Building from Source ## 🔧 Building from Source
<details> <details>
<summary><strong>Prerequisites</strong></summary> <summary><strong>Prerequisites</strong></summary>
@@ -435,7 +468,7 @@ Output: `src-tauri/target/x86_64-pc-windows-gnu/release/core-cooldown.exe`
--- ---
## Architecture ## 🏗️ Architecture
A split-architecture desktop app: Rust backend for system integration and timer logic, Svelte frontend rendered in a native WebView. A split-architecture desktop app: Rust backend for system integration and timer logic, Svelte frontend rendered in a native WebView.
@@ -450,7 +483,7 @@ A split-architecture desktop app: Rust backend for system integration and timer
│ │ (WebView) │ │ (WebView) │ │ (WebView) │ │ │ │ (WebView) │ │ (WebView) │ │ (WebView) │ │
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ │ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
│ │ │ │ │ │ │ │ │ │
│ └─────────┬───────┴──────────────────┘ │ └─────────┬───────┴──────────────────┘ │
│ │ Tauri IPC │ │ │ Tauri IPC │
│ ┌─────────┴─────────┐ │ │ ┌─────────┴─────────┐ │
│ │ Rust Backend │ │ │ │ Rust Backend │ │
@@ -510,7 +543,7 @@ A split-architecture desktop app: Rust backend for system integration and timer
--- ---
## Configuration Reference ## ⚙️ Configuration Reference
All settings stored in `config.json` next to the executable. The settings panel exposes every option with live validation, but the file is plain JSON and can be edited by hand. All settings stored in `config.json` next to the executable. The settings panel exposes every option with live validation, but the file is plain JSON and can be edited by hand.
@@ -677,7 +710,7 @@ All settings stored in `config.json` next to the executable. The settings panel
--- ---
## Dependencies ## 📚 Dependencies
<details> <details>
<summary><strong>Rust crates</strong></summary> <summary><strong>Rust crates</strong></summary>
@@ -713,7 +746,7 @@ All settings stored in `config.json` next to the executable. The settings panel
--- ---
## Contributing ## 🤝 Contributing
This project belongs to no one and everyone. If you find it useful and want to make it better, you are welcome. This project belongs to no one and everyone. If you find it useful and want to make it better, you are welcome.
@@ -723,7 +756,7 @@ No contribution agreements to sign. No corporate CLAs. No licensing traps. Every
- Report bugs or rough edges - Report bugs or rough edges
- Suggest new break activities (especially with physiotherapy or ergonomics knowledge) - Suggest new break activities (especially with physiotherapy or ergonomics knowledge)
- Improve accessibility (WCAG 2.1 AA foundation is in place - help us push further) - Improve accessibility (WCAG 2.2 AAA foundation is in place - help us maintain it)
- Port idle detection to macOS/Linux - Port idle detection to macOS/Linux
- Translate the interface - Translate the interface
- Share it with someone who needs it - Share it with someone who needs it
@@ -734,7 +767,7 @@ The best software is built through mutual aid - people helping people because it
--- ---
## License ## 📜 License
<p align="center"> <p align="center">
<a href="https://creativecommons.org/publicdomain/zero/1.0/"> <a href="https://creativecommons.org/publicdomain/zero/1.0/">

View File

@@ -0,0 +1,218 @@
# WCAG 2.2 AAA Compliance Design
**Date:** 2026-02-18
**Scope:** `break-timer/` (frontend + CSS only, no Rust backend changes)
**Goal:** Achieve WCAG 2.2 AAA conformance while preserving the existing dark-theme visual identity.
## Audit Summary
42 issues found (8 Critical, 14 Major, 20 Minor) across 28 source files. The app already has solid AA foundations: focus indicators, reduced motion support, forced colors mode, ARIA roles on custom widgets, screen-reader text, and keyboard support for complex components.
## Design Decisions
1. **Secondary text color:** `#8a8a8a` -> `#a8a8a8` (7.28:1 ratio, minimal visual change)
2. **Target sizes:** Enlarge controls to 44px (visual + padding), including 20px traffic lights, 28px swatches, 36px steppers
3. **Auto-dismiss toasts:** Persist until dismissed when user hovers/focuses; auto-fade only if untouched
---
## Section 1: Color & Contrast System
### Theme Token Changes (`app.css`)
| Token | Current | New | Ratio on #000 |
|-------|---------|-----|---------------|
| `--color-text-sec` | `#8a8a8a` | `#a8a8a8` | 7.28:1 |
| `--color-text-dim` | `#3a3a3a` | `#5c5c5c` | 3.5:1 (decorative) |
| `--color-border` | `#222222` | `#3a3a3a` | 2.63:1 (non-text) |
| New: `--color-input-border` | — | `#444444` | 3.14:1 |
| New: `--color-surface-lt` | — | `#1e1e1e` | 1.28:1 (bg-to-bg) |
### Hardcoded Color Replacements
- All `text-[#8a8a8a]` -> `text-text-sec` (Tailwind theme token)
- All `border-[#222]` -> `border-border`
- All `border-[#161616]` -> `border-[#333]` (card dividers: 3:1)
- All `bg-[#141414]` (stepper bg) -> `bg-[#1a1a1a]` with `border border-[#3a3a3a]`
- Toggle OFF knob: `#444` -> `#666`
- Mini paused text: `#555` -> `#a8a8a8`
- Placeholder text: `#2a2a2a` -> `#555` (3.37:1)
- Danger color: `#f85149` -> `#ff6b6b` (7.41:1)
### Focus Indicator Safety
When accent color is too dark, add white outer shadow fallback:
```css
:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
box-shadow: 0 0 0 4px rgba(255,255,255,0.3);
}
```
---
## Section 2: Target Size Enlargement
| Component | Current | New Visual | Hit Area |
|-----------|---------|-----------|----------|
| Titlebar traffic lights | 15x15px | 20x20px | 44x44px (padding) |
| Color swatches | 22x22px | 28x28px | 44px spacing |
| Stepper +/- buttons | 28x28px | 36x36px | 44x44px (padding) |
| Toggle switch | 48x24px | 52x28px | 52x44px (padding) |
| Back button | 32x32px | 40x40px | 44x44px |
| Stats tab buttons | ~60x30px | ~60x40px | 44px height |
| Activity star/remove | 32x32px | 36x36px | 44x44px (padding) |
| Time range buttons | 32x32px | 36x36px | 44x44px (padding) |
Strategy: Use `min-h-[44px] min-w-[44px]` on interactive elements with padding for visual sizing.
---
## Section 3: Heading Hierarchy & Document Structure
### Heading Fixes
- Each view keeps `<h1 class="sr-only" tabindex="-1">`
- Settings/Stats: Change all `<h3>` to `<h2>`
- Working Hours section: Add missing heading
- BreakScreen `<h2>` stays correct
### Landmark Regions
- Titlebar: Wrap in `<header role="banner">`
- Dashboard bottom buttons: Wrap in `<nav aria-label="Main actions">`
- Stats tab bar: `<nav>` with `role="tablist"` / `role="tab"` / `role="tabpanel"`
- All sections: Add `aria-labelledby` pointing to heading `id`
### Document Title
Add `$effect` in `App.svelte` to set `document.title = "Core Cooldown - ${viewName}"` on view change.
---
## Section 4: Keyboard & Focus Fixes
### C5: Strict Mode Keyboard Trap
When `strict_mode` is true and buttons are hidden, render a visually hidden focusable `<span tabindex="0">` with sr-only text "Break in progress, please wait" so focus trap always has one element.
### Stepper Keyboard Hold-to-Repeat
Add `onkeydown` handler that starts repeat on held ArrowUp/ArrowDown/Enter/Space.
### `pressable` Keyboard Feedback
Add `keydown`/`keyup` listeners for Enter/Space to trigger same scale animation.
### `glowHover` Keyboard Focus
Add `focusin`/`focusout` listeners alongside `mouseenter`/`mouseleave`.
### Missing ARIA States
- Sound presets: `aria-pressed={$config.sound_preset === preset}`
- Breathing patterns: `role="radiogroup"` wrapper, `role="radio"` + `aria-checked` on buttons
- Color swatches: `aria-pressed={value === color}`
---
## Section 5: Timing & Notification Control
### Toast Persistence
All auto-dismissing elements (natural break toast, celebration overlay, goal badge):
1. Track `hovering` via `mouseenter/leave` + `focusin/focusout`
2. Auto-dismiss timer pauses while hovering
3. Close button appears (sr-only "Dismiss notification")
4. Escape key dismisses
5. `role="alert"` + `aria-live="assertive"` kept
### Breathing Guide Noise Fix
Change breathing `aria-live` span to only announce phase changes (Inhale/Hold/Exhale), not countdown ticks. Track `lastAnnouncedPhase` and update live text only on phase name change.
---
## Section 6: ARIA Patterns & Semantic Enrichment
### Section Accessible Names
Add `id` to each heading, `aria-labelledby` on parent `<section>`.
### Stats Tab Pattern
- `role="tablist"` wrapper
- `role="tab"` + `aria-selected` + `aria-controls` on each tab
- `role="tabpanel"` + `id` + `aria-labelledby` on panels
### Missing Data Tables
Add sr-only `<table>` for 30-day chart and heatmap (matching existing 7-day pattern).
### Additional ARIA
- Daily goal bar: `role="progressbar"` + aria-value attributes
- BreakOverlay: `role="alertdialog"` + `aria-label` + `<h2>`
- MicrobreakOverlay: `role="alertdialog"` + `aria-label` + heading
- Reset button: `aria-live="polite"` for confirmation state
---
## Section 7: Visual Presentation (1.4.8)
### Line Spacing
Default `leading-relaxed` (1.625) on body. Override with `leading-none` only on countdown numerics.
### Text Selection
Remove global `user-select: none`. Keep only on drag regions and decorative elements.
### Skip Navigation
Add skip link as first child of `<main>`: `<a href="#main-content" class="sr-only focus:not-sr-only">Skip to content</a>`.
---
## Section 8: Supplementary
### Abbreviations
Add `<abbr>` tags for standalone "s", "min", "h", "m" units. Add `title` on first use of Pomodoro, 20-20-20 in Settings.
### Help Tooltips
Add `title` attributes on complex settings (Pomodoro, Smart breaks, Microbreaks, Breathing).
### Known Limitations
- Canvas chart text spacing: Not fixable (Canvas API limitation). Sr-only data tables provide equivalent access.
- Timer interruptions: Pause button effectively suppresses all breaks. No separate DND mode needed.
---
## Files Affected
| File | Change Scope |
|------|-------------|
| `app.css` | Tokens, line-height, user-select, skip link, focus ring |
| `App.svelte` | Skip link, document title |
| `Titlebar.svelte` | Larger traffic lights, header landmark |
| `Dashboard.svelte` | Contrast, nav landmark, toast persistence, pomodoro alt text, goal progressbar |
| `BreakScreen.svelte` | Contrast, strict-mode focus, breathing aria-live fix |
| `Settings.svelte` | h2 headings, section labels, contrast, sizes, radio groups, abbr, title, reset aria-live |
| `StatsView.svelte` | h2 headings, tablist, data tables, contrast |
| `ToggleSwitch.svelte` | Larger (52x28), knob contrast |
| `Stepper.svelte` | Larger (36px), keyboard hold-repeat, contrast |
| `ColorPicker.svelte` | Larger swatches, aria-pressed, contrast |
| `FontSelector.svelte` | Contrast |
| `TimeSpinner.svelte` | Contrast |
| `ActivityManager.svelte` | Larger buttons, contrast, scroll fix |
| `MiniTimer.svelte` | Contrast |
| `MicrobreakOverlay.svelte` | Heading, role, label |
| `BreakOverlay.svelte` | Heading, role, label |
| `Celebration.svelte` | Toast persistence |
| `animate.ts` | pressable keyboard, glowHover focus |

View File

@@ -0,0 +1,967 @@
# WCAG 2.2 AAA Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Achieve WCAG 2.2 AAA conformance across all frontend components while preserving the existing dark-theme visual identity.
**Architecture:** All changes are CSS + Svelte template only — no Rust backend changes. Theme tokens in `app.css` propagate through Tailwind's `@theme` system. Components use Svelte 5 runes (`$state`, `$derived`, `$effect`, `$props`). Accessibility patterns follow WAI-ARIA 1.2 (tablist, radiogroup, alertdialog, progressbar).
**Tech Stack:** Svelte 5, Tailwind CSS v4 (`@theme` tokens in CSS, no config file), TypeScript, Web Animations API (`motion` library)
**Note:** This project has no frontend test suite. Verification is done via `npm run build` (Vite build) and manual inspection. Each task ends with a build check and a commit.
---
### Task 1: Theme Tokens & Global Styles (`app.css`)
**Files:**
- Modify: `break-timer/src/app.css` (entire file)
This is the foundation — all subsequent tasks depend on these token changes.
**Step 1: Update theme tokens**
In `app.css`, inside the `@theme { }` block (lines 320):
- Change `--color-text-sec: #8a8a8a``--color-text-sec: #a8a8a8` (7.28:1 on black)
- Change `--color-text-dim: #3a3a3a``--color-text-dim: #5c5c5c` (3.5:1 decorative)
- Change `--color-border: #222222``--color-border: #3a3a3a` (2.63:1 non-text)
- Change `--color-danger: #f85149``--color-danger: #ff6b6b` (7.41:1)
- Add new token: `--color-input-border: #444444;`
- Add new token: `--color-surface-lt: #1e1e1e;`
**Step 2: Update body styles**
In the `html, body` block (lines 2235):
- Change `user-select: none` to only apply on drag regions:
- REMOVE `user-select: none;` and `-webkit-user-select: none;` from body
- ADD a new rule for drag regions only:
```css
[data-tauri-drag-region],
[data-tauri-drag-region] * {
user-select: none;
-webkit-user-select: none;
}
```
- Add `line-height: 1.625;` (leading-relaxed) to body for AAA 1.4.8
**Step 3: Enhance focus indicators**
Replace the `:focus-visible` block (lines 7376) with:
```css
:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.3);
}
```
The `box-shadow` provides a white fallback when the accent color has low contrast against dark backgrounds.
**Step 4: Add skip link styles**
After the `.sr-only` block, add:
```css
.skip-link {
position: absolute;
top: -100%;
left: 50%;
transform: translateX(-50%);
z-index: 10000;
padding: 8px 16px;
background: var(--color-accent);
color: #000;
font-size: 13px;
font-weight: 600;
border-radius: 0 0 8px 8px;
text-decoration: none;
transition: top 0.15s ease;
}
.skip-link:focus {
top: 0;
}
```
**Step 5: Verify build**
Run: `cd break-timer && npm run build`
Expected: Build succeeds with no errors.
**Step 6: Commit**
```bash
git add break-timer/src/app.css
git commit -m "a11y: update theme tokens and global styles for WCAG AAA
- Bump --color-text-sec to #a8a8a8 (7.28:1 contrast)
- Bump --color-text-dim to #5c5c5c (3.5:1 decorative)
- Bump --color-border to #3a3a3a (2.63:1 non-text)
- Bump --color-danger to #ff6b6b (7.41:1)
- Add --color-input-border and --color-surface-lt tokens
- Add white shadow fallback on :focus-visible
- Add leading-relaxed default line-height
- Scope user-select:none to drag regions only
- Add skip-link focus styles"
```
---
### Task 2: App Shell (`App.svelte`)
**Files:**
- Modify: `break-timer/src/App.svelte`
**Step 1: Add skip link**
Inside the `<main>` element (line 87), add skip link as first child and an id target:
```svelte
<main class="relative h-full bg-black">
<a href="#main-content" class="skip-link">Skip to content</a>
...
```
Then on the zoom container `<div>` (line 92), add `id="main-content"`:
```svelte
<div
id="main-content"
class="relative h-full overflow-hidden origin-top-left"
```
**Step 2: Add document title effect**
After the existing focus management `$effect` (after line 76), add:
```typescript
// WCAG 2.4.2: Document title reflects current view
$effect(() => {
const viewNames: Record<string, string> = {
dashboard: "Dashboard",
breakScreen: "Break",
settings: "Settings",
stats: "Statistics",
};
document.title = `Core Cooldown — ${viewNames[effectiveView] ?? "Dashboard"}`;
});
```
**Step 3: Verify build**
Run: `cd break-timer && npm run build`
**Step 4: Commit**
```bash
git add break-timer/src/App.svelte
git commit -m "a11y: add skip link and dynamic document title for WCAG AAA"
```
---
### Task 3: Titlebar (`Titlebar.svelte`)
**Files:**
- Modify: `break-timer/src/lib/components/Titlebar.svelte`
**Step 1: Wrap in header landmark**
Change the outer `<div>` (line 8) to `<header role="banner">`.
**Step 2: Enlarge traffic lights to meet 44px target size**
Change each traffic light button from `h-[15px] w-[15px]` to `h-[20px] w-[20px]` visual size with `min-h-[44px] min-w-[44px]` hit area via padding. The trick: keep the visual circle at 20px but wrap in a 44px invisible tap area.
Replace each button's class. For example the Maximize button (line 27):
```svelte
<button
aria-label="Maximize"
class="traffic-btn group/btn relative flex h-[44px] w-[44px] items-center justify-center"
onclick={() => appWindow.toggleMaximize()}
>
<span class="flex h-[20px] w-[20px] items-center justify-center rounded-full bg-[#27C93F] transition-all duration-150 group-hover/btn:brightness-110">
<svg ...>
</span>
</button>
```
Apply the same pattern to Minimize and Close buttons. The gap between buttons changes from `gap-[8px]` to `gap-0` since the 44px buttons provide their own spacing.
**Step 3: Verify build**
Run: `cd break-timer && npm run build`
**Step 4: Commit**
```bash
git add break-timer/src/lib/components/Titlebar.svelte
git commit -m "a11y: add header landmark and enlarge traffic lights to 44px hit areas"
```
---
### Task 4: ToggleSwitch (`ToggleSwitch.svelte`)
**Files:**
- Modify: `break-timer/src/lib/components/ToggleSwitch.svelte`
**Step 1: Enlarge to 52x28 and fix knob contrast**
Change button dimensions from `h-[24px] w-[48px]` to `h-[28px] w-[52px]` with `min-h-[44px]` padding for hit area.
Change the knob span from `h-[19px] w-[19px]` to `h-[22px] w-[22px]`.
Change translate-x for ON state from `translate-x-[26px]` to `translate-x-[27px]`.
Change mt from `mt-[2.5px]` to `mt-[3px]`.
Change OFF knob color from `bg-[#444]` to `bg-[#666]` (better contrast).
The button should have `min-h-[44px]` via a wrapper approach or padding.
**Step 2: Verify build**
Run: `cd break-timer && npm run build`
**Step 3: Commit**
```bash
git add break-timer/src/lib/components/ToggleSwitch.svelte
git commit -m "a11y: enlarge toggle switch and improve OFF knob contrast"
```
---
### Task 5: Stepper (`Stepper.svelte`)
**Files:**
- Modify: `break-timer/src/lib/components/Stepper.svelte`
**Step 1: Enlarge buttons and fix contrast**
Change both +/- button dimensions from `h-7 w-7` (28px) to `h-9 w-9` (36px) with `min-h-[44px] min-w-[44px]` padding.
Change `bg-[#141414]` to `bg-[#1a1a1a] border border-[#3a3a3a]` for better non-text contrast.
Change `text-[#8a8a8a]` to `text-text-sec` (uses updated theme token).
**Step 2: Add keyboard hold-to-repeat**
Add `onkeydown` handlers to both buttons that trigger the same `startHold`/`stopHold` logic for Enter, Space, ArrowUp, ArrowDown keys:
```typescript
function handleKeydown(fn: () => void, e: KeyboardEvent) {
if (["Enter", " ", "ArrowUp", "ArrowDown"].includes(e.key)) {
e.preventDefault();
startHold(e.key === "ArrowDown" || e.key === "ArrowUp" ? fn : fn);
}
}
function handleKeyup(e: KeyboardEvent) {
if (["Enter", " ", "ArrowUp", "ArrowDown"].includes(e.key)) {
stopHold();
}
}
```
Add `onkeydown` and `onkeyup` to both buttons. The decrease button uses ArrowDown for decrement, the increase button uses ArrowUp for increment.
**Step 3: Verify build**
Run: `cd break-timer && npm run build`
**Step 4: Commit**
```bash
git add break-timer/src/lib/components/Stepper.svelte
git commit -m "a11y: enlarge stepper buttons and add keyboard hold-to-repeat"
```
---
### Task 6: Animation Actions (`animate.ts`)
**Files:**
- Modify: `break-timer/src/lib/utils/animate.ts`
**Step 1: Add keyboard support to `pressable`**
In the `pressable` function (line 117), after the mousedown/mouseup/mouseleave listeners (lines 143145), add keydown/keyup for Enter and Space:
```typescript
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onDown();
}
}
function onKeyUp(e: KeyboardEvent) {
if (e.key === "Enter" || e.key === " ") {
onUp();
}
}
node.addEventListener("keydown", onKeyDown);
node.addEventListener("keyup", onKeyUp);
```
Update `destroy()` to remove these listeners too.
**Step 2: Add focus support to `glowHover`**
In the `glowHover` function (line 165), after mouseenter/mouseleave listeners (lines 201202), add focusin/focusout:
```typescript
node.addEventListener("focusin", onEnter);
node.addEventListener("focusout", onLeave);
```
Update `destroy()` to remove these listeners too.
**Step 3: Verify build**
Run: `cd break-timer && npm run build`
**Step 4: Commit**
```bash
git add break-timer/src/lib/utils/animate.ts
git commit -m "a11y: add keyboard feedback to pressable and focus glow to glowHover"
```
---
### Task 7: Dashboard (`Dashboard.svelte`)
**Files:**
- Modify: `break-timer/src/lib/components/Dashboard.svelte`
**Step 1: Replace hardcoded `#8a8a8a` with theme token**
Replace all `text-[#8a8a8a]` with `text-text-sec` throughout the file. There are ~10 instances on lines 180, 208, 216, 234, 241, 256, 301, 329, 361.
Replace `border-[#222]` with `border-border` on the three bottom buttons (lines 302, 328, 360).
**Step 2: Wrap bottom buttons in nav landmark**
Around the three bottom action buttons (lines 296382), wrap in:
```svelte
<nav aria-label="Main actions">
<!-- Bottom left: start break now -->
...
<!-- Bottom center: stats -->
...
<!-- Bottom right: settings -->
...
</nav>
```
The three `<div class="absolute bottom-5 ...">` blocks move inside the `<nav>`.
**Step 3: Make natural break toast persistent on hover**
Replace the simple timeout logic (lines 118126) with hover-aware persistence:
```typescript
let toastHovering = $state(false);
$effect(() => {
if ($timer.naturalBreakOccurred) {
showNaturalBreakToast = true;
if (naturalBreakToastTimeout) clearTimeout(naturalBreakToastTimeout);
naturalBreakToastTimeout = setTimeout(() => {
if (!toastHovering) showNaturalBreakToast = false;
}, 5000);
}
});
```
On the toast div (line 266), add:
```svelte
onmouseenter={() => toastHovering = true}
onmouseleave={() => { toastHovering = false; showNaturalBreakToast = false; }}
onfocusin={() => toastHovering = true}
onfocusout={() => { toastHovering = false; showNaturalBreakToast = false; }}
```
Add a close button inside the toast and an Escape key handler.
**Step 4: Add progressbar role to daily goal bar**
On the goal progress bar div (line 235), add ARIA:
```svelte
<div class="w-16 h-[2px] rounded-full overflow-hidden" style="background: #161616;"
role="progressbar"
aria-label="Daily goal progress"
aria-valuemin={0}
aria-valuemax={$config.daily_goal_breaks}
aria-valuenow={dailyGoalProgress}
>
```
**Step 5: Add sr-only text for pomodoro dots**
After the pomodoro dots visual `<div>` (around line 192), add:
```svelte
<span class="sr-only">
Pomodoro cycle: session {$timer.pomodoroCyclePosition + 1} of {$timer.pomodoroTotalInCycle}
</span>
```
**Step 6: Verify build**
Run: `cd break-timer && npm run build`
**Step 7: Commit**
```bash
git add break-timer/src/lib/components/Dashboard.svelte
git commit -m "a11y: dashboard contrast, nav landmark, toast persistence, goal progressbar"
```
---
### Task 8: Settings — Part 1: Headings & Structure (`Settings.svelte`)
**Files:**
- Modify: `break-timer/src/lib/components/Settings.svelte`
This is a large file (1176 lines). Split into two commits for reviewability.
**Step 1: Change all `<h3>` to `<h2>`**
Replace every `<h3 class="mb-4 text-[11px] ...` with `<h2 class="mb-4 text-[11px] ...` and corresponding `</h3>` with `</h2>`. There are 17 instances at approximate lines: 163, 218, 280, 346, 435, 444, 498, 590, 668, 732, 825, 862, 906, 976(Working Hours has no heading — add one), 1078, 1119, 1138.
**Step 2: Add `id` to headings and `aria-labelledby` to sections**
Each `<section>` gets an `aria-labelledby` pointing to its heading's `id`. Pattern:
```svelte
<section aria-labelledby="settings-timer" class="rounded-2xl p-5 ...">
<h2 id="settings-timer" class="mb-4 ...">Timer</h2>
```
Do this for all 18 sections. Use ids: `settings-timer`, `settings-pomodoro`, `settings-microbreaks`, `settings-breakscreen`, `settings-activities`, `settings-breathing`, `settings-behavior`, `settings-alerts`, `settings-sound`, `settings-idle`, `settings-presentation`, `settings-goals`, `settings-appearance`, `settings-workinghours`, `settings-minimode`, `settings-general`, `settings-shortcuts`.
**Step 3: Add missing heading for Working Hours section**
The Working Hours section (line 975) has no `<h3>`/`<h2>`. Add:
```svelte
<h2 id="settings-workinghours" class="mb-4 text-[11px] font-medium tracking-[0.15em] text-text-sec uppercase">
Working Hours
</h2>
```
**Step 4: Verify build**
Run: `cd break-timer && npm run build`
**Step 5: Commit**
```bash
git add break-timer/src/lib/components/Settings.svelte
git commit -m "a11y: settings heading hierarchy (h3→h2) and section landmarks"
```
---
### Task 9: Settings — Part 2: ARIA, Contrast, Sizes (`Settings.svelte`)
**Files:**
- Modify: `break-timer/src/lib/components/Settings.svelte`
**Step 1: Breathing pattern — radiogroup/radio**
Wrap the breathing pattern buttons (line 464) in a `role="radiogroup"` container:
```svelte
<div role="radiogroup" aria-label="Breathing pattern" class="flex flex-col gap-1.5">
```
Each breathing pattern button gets `role="radio"` and `aria-checked`:
```svelte
<button
role="radio"
aria-checked={$config.breathing_pattern === bp.id}
...
```
**Step 2: Sound preset — aria-pressed**
Each sound preset button (line 709) gets `aria-pressed`:
```svelte
<button
aria-pressed={$config.sound_preset === preset}
...
```
**Step 3: Replace hardcoded colors**
- All `text-[#8a8a8a]` → `text-text-sec` (many instances throughout)
- All `bg-[#161616]` dividers → `bg-border` (use `--color-border` token)
- All `border-[#161616]` on inputs → `border-border`
- All `placeholder:text-[#2a2a2a]` → `placeholder:text-[#555]` (3.37:1)
- All `bg-[#141414]` → use updated stepper component (already handled in Task 5)
- Back button `h-8 w-8` → `h-10 w-10 min-h-[44px] min-w-[44px]` (line 127)
**Step 4: Reset button aria-live**
Wrap the reset button text in an `aria-live="polite"` region, or add `aria-live="polite"` to the button itself so screen readers announce the confirmation state change:
```svelte
<button
aria-live="polite"
...
>
{resetConfirming ? "Tap again to confirm reset" : "Reset to defaults"}
</button>
```
**Step 5: Add abbreviation tags**
For standalone unit abbreviations in setting descriptions, wrap with `<abbr>`:
- `"s"` (seconds) → `<abbr title="seconds">s</abbr>`
- `"min"` → `<abbr title="minutes">min</abbr>`
Apply on first occurrence in: alert timing description (line 613), idle timeout (line 755), snooze duration (line 537).
**Step 6: Add title tooltips on complex settings**
Add `title` attributes on the section-level labels for Pomodoro, Smart breaks, Microbreaks, Breathing:
- Pomodoro `<h2>`: `title="Pomodoro technique alternates focused work sessions with short and long breaks"`
- Microbreaks `<h2>`: `title="20-20-20 rule: every 20 minutes, look 20 feet away for 20 seconds"`
- Smart breaks `<div>`: `title="Automatically counts time away from computer as a break"`
- Breathing guide `<h2>`: `title="Visual breathing exercise during breaks to reduce stress"`
**Step 7: Verify build**
Run: `cd break-timer && npm run build`
**Step 8: Commit**
```bash
git add break-timer/src/lib/components/Settings.svelte
git commit -m "a11y: settings ARIA patterns, contrast, abbreviations, and tooltips"
```
---
### Task 10: StatsView — Tabs & Data Tables (`StatsView.svelte`)
**Files:**
- Modify: `break-timer/src/lib/components/StatsView.svelte`
**Step 1: Change all `<h3>` to `<h2>`**
Replace all `<h3>` with `<h2>` and `</h3>` with `</h2>` (~12 instances).
**Step 2: Implement tablist pattern**
Replace the tab navigation (lines 297310):
```svelte
<div role="tablist" aria-label="Statistics time range" class="flex gap-1 px-5 mb-3" use:fadeIn={{ duration: 0.3, y: 6 }}>
{#each [["today", "Today"], ["weekly", "Weekly"], ["monthly", "Monthly"]] as [tab, label]}
<button
role="tab"
aria-selected={activeTab === tab}
aria-controls="tabpanel-{tab}"
id="tab-{tab}"
use:pressable
class="rounded-lg px-4 py-2 min-h-[44px] text-[11px] tracking-wider uppercase transition-all duration-200
{activeTab === tab
? 'bg-[#1a1a1a] text-white'
: 'text-text-sec hover:text-white'}"
onclick={() => activeTab = tab as any}
>
{label}
</button>
{/each}
</div>
```
Wrap each tab's content in a `role="tabpanel"`:
```svelte
{#if activeTab === "today"}
<div role="tabpanel" id="tabpanel-today" aria-labelledby="tab-today">
...
</div>
```
Same for `weekly` and `monthly`.
**Step 3: Add sr-only data tables for 30-day chart and heatmap**
After the 30-day chart canvas (around line 533), add:
```svelte
{#if monthHistory.length > 0}
<table class="sr-only">
<caption>Break history for the last {monthHistory.length} days</caption>
<thead>
<tr><th>Date</th><th>Completed</th><th>Skipped</th></tr>
</thead>
<tbody>
{#each monthHistory as day}
<tr><td>{day.date}</td><td>{day.breaksCompleted}</td><td>{day.breaksSkipped}</td></tr>
{/each}
</tbody>
</table>
{/if}
```
After the heatmap canvas (around line 559), add:
```svelte
{#if monthHistory.length > 0}
<table class="sr-only">
<caption>Activity heatmap for the last {monthHistory.length} days</caption>
<thead>
<tr><th>Date</th><th>Breaks completed</th></tr>
</thead>
<tbody>
{#each monthHistory as day}
<tr><td>{day.date}</td><td>{day.breaksCompleted}</td></tr>
{/each}
</tbody>
</table>
{/if}
```
**Step 4: Replace hardcoded colors**
- All `text-[#8a8a8a]` → `text-text-sec`
- `bg-[#161616]` dividers → `bg-border`
- Back button: `h-8 w-8` → `h-10 w-10 min-h-[44px] min-w-[44px]`
**Step 5: Verify build**
Run: `cd break-timer && npm run build`
**Step 6: Commit**
```bash
git add break-timer/src/lib/components/StatsView.svelte
git commit -m "a11y: stats tablist pattern, sr-only data tables, contrast updates"
```
---
### Task 11: BreakScreen (`BreakScreen.svelte`)
**Files:**
- Modify: `break-timer/src/lib/components/BreakScreen.svelte`
**Step 1: Add strict mode focus safety**
When `strict_mode` is true and buttons are hidden, the focus trap has zero focusable elements. After the `{#if showButtons}` block (around line 353 for in-app, line 203 for standalone), add an else block:
```svelte
{:else}
<span tabindex="0" class="sr-only" aria-live="polite">
Break in progress, please wait
</span>
{/if}
```
This ensures the focus trap always has at least one focusable element.
**Step 2: Fix breathing aria-live to only announce phase changes**
The breathing guide `<span aria-live="polite">` (lines 172 and 304) currently announces every countdown tick. Add a tracked variable that only updates on phase change:
In the script section, add:
```typescript
let lastAnnouncedPhase = $state("");
let breathAnnouncement = $derived(
breathPhase !== lastAnnouncedPhase
? (() => { lastAnnouncedPhase = breathPhase; return `${breathPhase}`; })()
: ""
);
```
Actually, better approach — use a separate `$effect` and a dedicated announcement state:
```typescript
let breathAnnouncement = $state("");
$effect(() => {
// Only announce when the breathing phase name changes, not countdown ticks
breathAnnouncement = breathPhase;
});
```
Then change the aria-live span to use a separate invisible span for SR announcements:
```svelte
<span class="sr-only" aria-live="polite" aria-atomic="true">{breathAnnouncement}</span>
```
Keep the visible breathing text without `aria-live` (remove `aria-live="polite"` from the visible span).
**Step 3: Replace hardcoded colors**
- `text-[#8a8a8a]` → `text-text-sec`
- `border-[#222]` → `border-border`
**Step 4: Verify build**
Run: `cd break-timer && npm run build`
**Step 5: Commit**
```bash
git add break-timer/src/lib/components/BreakScreen.svelte
git commit -m "a11y: break screen strict-mode focus safety and breathing phase announcements"
```
---
### Task 12: Celebration Toast Persistence (`Celebration.svelte`)
**Files:**
- Modify: `break-timer/src/lib/components/Celebration.svelte`
**Step 1: Add hover/focus-aware auto-dismiss**
Replace the CSS-driven 3.5s fade with JS-controlled timing. Add state:
```typescript
let milestoneHovering = $state(false);
let goalHovering = $state(false);
```
For the `.celebration-overlay` div, add:
```svelte
onmouseenter={() => milestoneHovering = true}
onmouseleave={() => milestoneHovering = false}
onfocusin={() => milestoneHovering = true}
onfocusout={() => milestoneHovering = false}
```
Remove `pointer-events: none` from `.celebration-overlay` and `.goal-overlay` styles.
Add `$effect` blocks that manage visibility based on hover state — when not hovering, set a timeout to add a CSS class that triggers fade-out.
Add a close button (sr-only label "Dismiss notification") to both overlays, and an Escape key handler.
**Step 2: Verify build**
Run: `cd break-timer && npm run build`
**Step 3: Commit**
```bash
git add break-timer/src/lib/components/Celebration.svelte
git commit -m "a11y: celebration overlays persist on hover/focus with dismiss controls"
```
---
### Task 13: MiniTimer Contrast (`MiniTimer.svelte`)
**Files:**
- Modify: `break-timer/src/lib/components/MiniTimer.svelte`
**Step 1: Fix paused text contrast**
Change line 338: `color: {state === 'paused' ? '#555' : '#fff'}` to `color: {state === 'paused' ? '#a8a8a8' : '#fff'}`.
Replace `#8a8a8a` with the text-sec token value `#a8a8a8` on line 344 for pomodoro indicator.
**Step 2: Verify build**
Run: `cd break-timer && npm run build`
**Step 3: Commit**
```bash
git add break-timer/src/lib/components/MiniTimer.svelte
git commit -m "a11y: fix mini timer paused text contrast for AAA"
```
---
### Task 14: MicrobreakOverlay (`MicrobreakOverlay.svelte`)
**Files:**
- Modify: `break-timer/src/lib/components/MicrobreakOverlay.svelte`
**Step 1: Add alertdialog role, heading, and label**
Change the outer `.microbreak-card` div (line 41):
```svelte
<div class="microbreak-card" role="alertdialog" aria-label="Microbreak" aria-describedby="microbreak-msg">
<h2 class="sr-only">Microbreak</h2>
<div class="flex items-center gap-3 mb-2">
...
<span id="microbreak-msg" class="text-[15px] font-medium text-white">
Look away — 20 feet for {timeRemaining}s
</span>
</div>
```
Replace `text-[#8a8a8a]` with `text-text-sec`.
**Step 2: Verify build**
Run: `cd break-timer && npm run build`
**Step 3: Commit**
```bash
git add break-timer/src/lib/components/MicrobreakOverlay.svelte
git commit -m "a11y: microbreak overlay gets alertdialog role and heading"
```
---
### Task 15: BreakOverlay (`BreakOverlay.svelte`)
**Files:**
- Modify: `break-timer/src/lib/components/BreakOverlay.svelte`
**Step 1: Add alertdialog role, heading, and label**
Change the outer div (line 39):
```svelte
<div
role="alertdialog"
aria-label="Break in progress"
class="fixed inset-0 flex flex-col items-center justify-center"
style="background: rgba(0, 0, 0, {$config.backdrop_opacity});"
>
<h2 class="sr-only">Break in Progress</h2>
<p class="text-[16px] font-medium text-white mb-4">Break in progress</p>
```
**Step 2: Verify build**
Run: `cd break-timer && npm run build`
**Step 3: Commit**
```bash
git add break-timer/src/lib/components/BreakOverlay.svelte
git commit -m "a11y: break overlay gets alertdialog role and heading"
```
---
### Task 16: ColorPicker (`ColorPicker.svelte`)
**Files:**
- Modify: `break-timer/src/lib/components/ColorPicker.svelte`
**Step 1: Enlarge swatches and add aria-pressed**
Change swatch buttons (line 247) from `h-[22px] w-[22px]` to `h-[28px] w-[28px]` with a `min-h-[44px] min-w-[44px]` clickable area (via padding or wrapper).
Add `aria-pressed={value === color}` to each swatch button.
Change the flex container gap from `gap-[6px]` to `gap-[8px]` to accommodate larger swatches.
Replace `text-[#8a8a8a]` with `text-text-sec`.
**Step 2: Verify build**
Run: `cd break-timer && npm run build`
**Step 3: Commit**
```bash
git add break-timer/src/lib/components/ColorPicker.svelte
git commit -m "a11y: enlarge color swatches, add aria-pressed"
```
---
### Task 17: ActivityManager Target Sizes (`ActivityManager.svelte`)
**Files:**
- Modify: `break-timer/src/lib/components/ActivityManager.svelte`
**Step 1: Enlarge star/remove buttons**
Find all favorite (★) and remove (✕) buttons. Change from any `w-8 h-8` / `w-7 h-7` to `w-9 h-9 min-h-[44px] min-w-[44px]`.
Replace `text-[#8a8a8a]` with `text-text-sec`.
**Step 2: Verify build**
Run: `cd break-timer && npm run build`
**Step 3: Commit**
```bash
git add break-timer/src/lib/components/ActivityManager.svelte
git commit -m "a11y: enlarge activity manager action buttons to 44px targets"
```
---
### Task 18: Final Build Verification & Cleanup
**Step 1: Full build**
Run: `cd break-timer && npm run build`
Expected: Build succeeds with zero errors.
**Step 2: Verify all theme token propagation**
Search for any remaining hardcoded `#8a8a8a` in Svelte files — there should be none (all replaced with `text-text-sec` or inline `#a8a8a8`).
Run: `grep -r "#8a8a8a" break-timer/src/` — should return zero results.
**Step 3: Verify no remaining h3 headings in Settings/Stats**
Run: `grep -n "<h3" break-timer/src/lib/components/Settings.svelte break-timer/src/lib/components/StatsView.svelte` — should return zero results.
**Step 4: Final commit if any cleanup needed**
```bash
git add -A break-timer/src/
git commit -m "a11y: final cleanup pass for WCAG 2.2 AAA compliance"
```
---
## Dependency Graph
```
Task 1 (app.css tokens)
├── Task 2 (App.svelte)
├── Task 3 (Titlebar)
├── Task 4 (ToggleSwitch)
├── Task 5 (Stepper)
├── Task 6 (animate.ts)
├── Task 7 (Dashboard) ← depends on Task 6
├── Task 8 (Settings part 1)
├── Task 9 (Settings part 2) ← depends on Task 8
├── Task 10 (StatsView)
├── Task 11 (BreakScreen)
├── Task 12 (Celebration)
├── Task 13 (MiniTimer)
├── Task 14 (MicrobreakOverlay)
├── Task 15 (BreakOverlay)
├── Task 16 (ColorPicker)
└── Task 17 (ActivityManager)
Task 18 (Final verification) ← depends on all above
```
Tasks 217 are mostly independent of each other (except 7 depends on 6, and 9 depends on 8). They all depend on Task 1 being done first.

View File

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

2
src-tauri/Cargo.lock generated
View File

@@ -480,7 +480,7 @@ dependencies = [
[[package]] [[package]]
name = "core-cooldown" name = "core-cooldown"
version = "0.1.2" version = "0.2.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "core-cooldown" name = "core-cooldown"
version = "0.1.3" version = "0.2.0"
edition = "2021" edition = "2021"
[lib] [lib]

View File

@@ -9,17 +9,70 @@ fn main() {
// On GNU targets, replace the WebView2Loader import library with the static // On GNU targets, replace the WebView2Loader import library with the static
// library so the loader is baked into the exe — no DLL to ship. // library so the loader is baked into the exe — no DLL to ship.
#[cfg(target_env = "gnu")] if std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("gnu") {
swap_webview2_to_static(); swap_webview2_to_static();
}
tauri_build::build() tauri_build::build();
// When targeting GNU, embed-resource may find MSVC's rc.exe (via Windows
// SDK) and produce a .res file instead of COFF .o. GNU ld can't link .res
// files. Fix: re-compile with windres if needed.
// Note: cfg() in build scripts checks the HOST, not target. Use env var.
if std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("gnu") {
fix_resource_lib();
}
}
/// If resource.lib is in .res format (produced by MSVC rc.exe), re-compile
/// the .rc source with MinGW windres to produce a COFF object that GNU ld
/// can link.
fn fix_resource_lib() {
use std::path::PathBuf;
use std::process::Command;
let out_dir = std::env::var("OUT_DIR").unwrap_or_default();
let out_path = PathBuf::from(&out_dir);
let rc_file = out_path.join("resource.rc");
let lib_file = out_path.join("resource.lib");
if !rc_file.exists() || !lib_file.exists() {
return;
}
// Check if the file is already COFF (starts with COFF machine type or
// archive signature). A .res file starts with 0x00000000.
if let Ok(header) = std::fs::read(&lib_file) {
if header.len() >= 4 && header[0..4] == [0, 0, 0, 0] {
// This is a .res file, not COFF — re-compile with windres
let windres = "C:/Users/lashman/mingw-w64/mingw64/bin/windres.exe";
let status = Command::new(windres)
.args([
"-i", &rc_file.to_string_lossy(),
"-o", &lib_file.to_string_lossy(),
"--output-format=coff",
])
.status();
match status {
Ok(s) if s.success() => {
println!("cargo:warning=Re-compiled resource.rc with windres (COFF output)");
}
Ok(s) => {
println!("cargo:warning=windres failed with exit code: {}", s);
}
Err(e) => {
println!("cargo:warning=Failed to run windres: {}", e);
}
}
}
}
} }
/// Replace `WebView2Loader.dll.lib` (dynamic import lib) with the contents of /// Replace `WebView2Loader.dll.lib` (dynamic import lib) with the contents of
/// `WebView2LoaderStatic.lib` (static archive) in the webview2-com-sys build /// `WebView2LoaderStatic.lib` (static archive) in the webview2-com-sys build
/// output. The linker then statically links the WebView2 loader code, removing /// output. The linker then statically links the WebView2 loader code, removing
/// the runtime dependency on WebView2Loader.dll. /// the runtime dependency on WebView2Loader.dll.
#[cfg(target_env = "gnu")]
fn swap_webview2_to_static() { fn swap_webview2_to_static() {
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::PathBuf;

View File

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

View File

@@ -75,6 +75,17 @@
}); });
}); });
// WCAG 2.4.2: Document title reflects current view
$effect(() => {
const viewNames: Record<string, string> = {
dashboard: "Dashboard",
breakScreen: "Break",
settings: "Settings",
stats: "Statistics",
};
document.title = `Core Cooldown — ${viewNames[effectiveView] ?? "Dashboard"}`;
});
// When fullscreen_mode is OFF, the separate break window handles breaks, // When fullscreen_mode is OFF, the separate break window handles breaks,
// so the main window should keep showing whatever view it was on (dashboard). // so the main window should keep showing whatever view it was on (dashboard).
const effectiveView = $derived( const effectiveView = $derived(
@@ -85,11 +96,13 @@
</script> </script>
<main class="relative h-full bg-black"> <main class="relative h-full bg-black">
<a href="#main-content" class="skip-link">Skip to content</a>
{#if $config.background_blobs_enabled} {#if $config.background_blobs_enabled}
<BackgroundBlobs accentColor={$config.accent_color} breakColor={$config.break_color} /> <BackgroundBlobs accentColor={$config.accent_color} breakColor={$config.break_color} />
{/if} {/if}
<Titlebar /> <Titlebar />
<div <div
id="main-content"
class="relative h-full overflow-hidden origin-top-left" class="relative h-full overflow-hidden origin-top-left"
style=" style="
transform: scale({zoomScale}); transform: scale({zoomScale});

View File

@@ -5,17 +5,19 @@
--color-surface: #0e0e0e; --color-surface: #0e0e0e;
--color-card: #141414; --color-card: #141414;
--color-card-lt: #1c1c1c; --color-card-lt: #1c1c1c;
--color-border: #222222; --color-border: #3a3a3a;
--color-accent: #ff4d00; --color-accent: #ff4d00;
--color-accent-lt: #ff7733; --color-accent-lt: #ff7733;
--color-accent-dim: #ff4d0018; --color-accent-dim: #ff4d0018;
--color-accent-glow: #ff4d0040; --color-accent-glow: #ff4d0040;
--color-success: #3fb950; --color-success: #3fb950;
--color-warning: #f0a500; --color-warning: #f0a500;
--color-danger: #f85149; --color-danger: #ff6b6b;
--color-text-pri: #ffffff; --color-text-pri: #ffffff;
--color-text-sec: #8a8a8a; --color-text-sec: #a8a8a8;
--color-text-dim: #3a3a3a; --color-text-dim: #5c5c5c;
--color-input-border: #444444;
--color-surface-lt: #1e1e1e;
--color-caption-bg: #050505; --color-caption-bg: #050505;
} }
@@ -30,6 +32,11 @@ body {
Arial, sans-serif; Arial, sans-serif;
overflow: hidden; overflow: hidden;
height: 100%; height: 100%;
line-height: 1.625;
}
[data-tauri-drag-region],
[data-tauri-drag-region] * {
user-select: none; user-select: none;
-webkit-user-select: none; -webkit-user-select: none;
} }
@@ -69,10 +76,31 @@ body {
border-width: 0; border-width: 0;
} }
/* ── Accessibility: Skip link ── */
.skip-link {
position: absolute;
top: -100%;
left: 50%;
transform: translateX(-50%);
z-index: 10000;
padding: 8px 16px;
background: var(--color-accent);
color: #000;
font-size: 13px;
font-weight: 600;
border-radius: 0 0 8px 8px;
text-decoration: none;
transition: top 0.15s ease;
}
.skip-link:focus {
top: 0;
}
/* ── Accessibility: Focus indicators ── */ /* ── Accessibility: Focus indicators ── */
:focus-visible { :focus-visible {
outline: 2px solid var(--color-accent); outline: 2px solid var(--color-accent);
outline-offset: 2px; outline-offset: 2px;
box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.3);
} }
/* ── Accessibility: Reduced motion ── */ /* ── Accessibility: Reduced motion ── */

View File

@@ -409,7 +409,7 @@
<button <button
bind:this={dropdownTriggerRef} bind:this={dropdownTriggerRef}
use:pressable use:pressable
class="flex items-center gap-1.5 rounded-xl border border-[#161616] bg-black px-3 py-2.5 text-[13px] text-[#8a8a8a] class="flex items-center gap-1.5 rounded-xl border border-[#161616] bg-black px-3 py-2.5 text-[13px] text-text-sec
hover:border-[#333] hover:text-white transition-colors" hover:border-[#333] hover:text-white transition-colors"
onclick={() => { dropdownOpen = !dropdownOpen; }} onclick={() => { dropdownOpen = !dropdownOpen; }}
aria-haspopup="listbox" aria-haspopup="listbox"
@@ -422,12 +422,12 @@
</svg> </svg>
</button> </button>
{#if dropdownOpen} {#if dropdownOpen}
<div class="absolute top-full left-0 mt-1 z-50 min-w-[140px] rounded-xl border border-[#222] bg-[#111] shadow-xl shadow-black/50 overflow-hidden" <div class="absolute top-full left-0 mt-1 z-50 min-w-[140px] rounded-xl border border-border bg-[#111] shadow-xl shadow-black/50 overflow-hidden"
role="listbox" aria-label="Activity category"> role="listbox" aria-label="Activity category">
{#each categories as cat, i} {#each categories as cat, i}
<button <button
class="w-full text-left px-3.5 py-2.5 text-[13px] transition-colors outline-none class="w-full text-left px-3.5 py-2.5 text-[13px] transition-colors outline-none
{newActivityCategory === cat ? 'text-white bg-[#1a1a1a]' : 'text-[#8a8a8a] hover:bg-[#1a1a1a] hover:text-white'} {newActivityCategory === cat ? 'text-white bg-[#1a1a1a]' : 'text-text-sec hover:bg-[#1a1a1a] hover:text-white'}
{focusedOptionIndex === i ? 'bg-[#1a1a1a] text-white' : ''}" {focusedOptionIndex === i ? 'bg-[#1a1a1a] text-white' : ''}"
role="option" role="option"
aria-selected={newActivityCategory === cat} aria-selected={newActivityCategory === cat}
@@ -442,7 +442,7 @@
</div> </div>
<button <button
use:pressable use:pressable
class="flex items-center justify-center w-10 h-10 rounded-xl border border-[#161616] text-[18px] text-[#8a8a8a] class="flex items-center justify-center w-10 h-10 rounded-xl border border-[#161616] text-[18px] text-text-sec
hover:border-[#333] hover:text-white transition-colors disabled:opacity-30" hover:border-[#333] hover:text-white transition-colors disabled:opacity-30"
onclick={addCustomActivity} onclick={addCustomActivity}
disabled={!newActivityText.trim()} disabled={!newActivityText.trim()}
@@ -463,13 +463,13 @@
class="rounded-xl px-3 py-2 text-[11px] tracking-wider transition-all duration-200 class="rounded-xl px-3 py-2 text-[11px] tracking-wider transition-all duration-200
{isExpanded {isExpanded
? 'bg-[#1a1a1a] text-white border border-[#333]' ? 'bg-[#1a1a1a] text-white border border-[#333]'
: 'bg-[#0a0a0a] text-[#8a8a8a] border border-[#161616] hover:border-[#333] hover:text-white'}" : 'bg-[#0a0a0a] text-text-sec border border-[#161616] hover:border-[#333] hover:text-white'}"
onclick={() => toggleCategory(cat)} onclick={() => toggleCategory(cat)}
aria-expanded={isExpanded} aria-expanded={isExpanded}
aria-controls="activity-panel-{cat}" aria-controls="activity-panel-{cat}"
> >
<span class="uppercase">{getCategoryLabel(cat)}</span> <span class="uppercase">{getCategoryLabel(cat)}</span>
<span class="ml-1.5 text-[10px] {isExpanded ? 'text-[#8a8a8a]' : 'text-[#8a8a8a] opacity-60'}"> <span class="ml-1.5 text-[10px] {isExpanded ? 'text-text-sec' : 'text-text-sec opacity-60'}">
{total - disabled}/{total} {total - disabled}/{total}
</span> </span>
</button> </button>
@@ -486,7 +486,7 @@
<div class="rounded-xl border border-[#161616] bg-[#0a0a0a] overflow-hidden" use:blockParentDrag> <div class="rounded-xl border border-[#161616] bg-[#0a0a0a] overflow-hidden" use:blockParentDrag>
{#if catCustoms.length > 0} {#if catCustoms.length > 0}
<div class="px-3 pt-2.5 pb-1"> <div class="px-3 pt-2.5 pb-1">
<div class="text-[9px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">Custom</div> <div class="text-[9px] font-medium tracking-[0.15em] text-text-sec uppercase">Custom</div>
</div> </div>
{/if} {/if}
@@ -497,17 +497,17 @@
{#each catCustoms as activity (activity.id)} {#each catCustoms as activity (activity.id)}
<div class="flex items-center gap-1 px-1.5 py-0.5 group hover:bg-[#111]"> <div class="flex items-center gap-1 px-1.5 py-0.5 group hover:bg-[#111]">
<button <button
class="flex items-center justify-center min-w-[32px] min-h-[32px] text-[13px] transition-opacity {activity.is_favorite ? 'opacity-100' : 'opacity-30 hover:opacity-60'}" class="flex items-center justify-center w-9 h-9 min-w-[44px] min-h-[44px] text-[13px] transition-opacity {activity.is_favorite ? 'opacity-100' : 'opacity-30 hover:opacity-60'}"
onclick={() => toggleCustomFavorite(activity.id)} onclick={() => toggleCustomFavorite(activity.id)}
aria-label="{activity.is_favorite ? 'Remove from favorites' : 'Add to favorites'}" aria-label="{activity.is_favorite ? 'Remove from favorites' : 'Add to favorites'}"
> >
</button> </button>
<span class="flex-1 text-[12px] {activity.enabled ? 'text-white' : 'text-[#8a8a8a] line-through'}"> <span class="flex-1 text-[12px] {activity.enabled ? 'text-white' : 'text-text-sec line-through'}">
{activity.text} {activity.text}
</span> </span>
<button <button
class="flex items-center justify-center min-w-[32px] min-h-[32px] text-[#8a8a8a] hover:text-[#f85149] opacity-0 group-hover:opacity-100 focus-visible:opacity-100 transition-all text-[11px]" class="flex items-center justify-center w-9 h-9 min-w-[44px] min-h-[44px] text-text-sec hover:text-[#ff6b6b] opacity-0 group-hover:opacity-100 focus-visible:opacity-100 transition-all text-[11px]"
onclick={() => removeCustomActivity(activity.id)} onclick={() => removeCustomActivity(activity.id)}
aria-label="Remove {activity.text}" aria-label="Remove {activity.text}"
> >
@@ -523,20 +523,20 @@
{#if catCustoms.length > 0 && catBuiltins.length > 0} {#if catCustoms.length > 0 && catBuiltins.length > 0}
<div class="px-3 pt-2 pb-1"> <div class="px-3 pt-2 pb-1">
<div class="text-[9px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">Built-in</div> <div class="text-[9px] font-medium tracking-[0.15em] text-text-sec uppercase">Built-in</div>
</div> </div>
{/if} {/if}
{#each catBuiltins as activity (activity.text)} {#each catBuiltins as activity (activity.text)}
<div class="flex items-center gap-1 px-1.5 py-0.5 hover:bg-[#111]"> <div class="flex items-center gap-1 px-1.5 py-0.5 hover:bg-[#111]">
<button <button
class="flex items-center justify-center min-w-[32px] min-h-[32px] text-[13px] transition-opacity {isBuiltinFavorite(activity.text) ? 'opacity-100' : 'opacity-30 hover:opacity-60'}" class="flex items-center justify-center w-9 h-9 min-w-[44px] min-h-[44px] text-[13px] transition-opacity {isBuiltinFavorite(activity.text) ? 'opacity-100' : 'opacity-30 hover:opacity-60'}"
onclick={() => toggleBuiltinFavorite(activity.text)} onclick={() => toggleBuiltinFavorite(activity.text)}
aria-label="{isBuiltinFavorite(activity.text) ? 'Remove from favorites' : 'Add to favorites'}" aria-label="{isBuiltinFavorite(activity.text) ? 'Remove from favorites' : 'Add to favorites'}"
> >
</button> </button>
<span class="flex-1 text-[12px] {!isBuiltinDisabled(activity.text) ? 'text-[#8a8a8a]' : 'text-[#8a8a8a] line-through opacity-60'}"> <span class="flex-1 text-[12px] {!isBuiltinDisabled(activity.text) ? 'text-text-sec' : 'text-text-sec line-through opacity-60'}">
{activity.text} {activity.text}
</span> </span>
<ToggleSwitch <ToggleSwitch

View File

@@ -37,9 +37,12 @@
</script> </script>
<div <div
role="alertdialog"
aria-label="Break in progress"
class="fixed inset-0 flex flex-col items-center justify-center" class="fixed inset-0 flex flex-col items-center justify-center"
style="background: rgba(0, 0, 0, {$config.backdrop_opacity});" style="background: rgba(0, 0, 0, {$config.backdrop_opacity});"
> >
<h2 class="sr-only">Break in Progress</h2>
<p class="text-[16px] font-medium text-white mb-4">Break in progress</p> <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"> <span class="text-[42px] font-semibold tabular-nums text-white leading-none mb-6">

View File

@@ -74,6 +74,18 @@
let breathCountdown = $state(4); let breathCountdown = $state(4);
let breathScale = $state(0.6); let breathScale = $state(0.6);
// Only announce phase name changes (not countdown ticks) to screen readers
let breathAnnouncement = $state("");
let lastBreathPhase = $state("");
$effect(() => {
// Extract just the phase name (e.g., "Inhale" from "Inhale 4")
const phaseName = breathPhase?.split(' ')[0] ?? "";
if (phaseName && phaseName !== lastBreathPhase) {
lastBreathPhase = phaseName;
breathAnnouncement = phaseName;
}
});
// Map raw 0.61.0 scale to 0.91.6 range for visible breathing text // 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)); const textScale = $derived(0.9 + (breathScale - 0.6) * (0.7 / 0.4));
@@ -169,11 +181,11 @@
<span <span
class="block mt-1.5 text-[9px] tracking-wider uppercase text-center font-medium" 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;" 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-hidden="true"
aria-label="Breathing guide: {breathPhase} {breathCountdown > 0 ? breathCountdown + ' seconds' : ''}"
> >
{breathPhase}{breathCountdown > 0 ? ` ${breathCountdown}s` : ""} {breathPhase}{breathCountdown > 0 ? ` ${breathCountdown}s` : ""}
</span> </span>
<span class="sr-only" aria-live="polite" aria-atomic="true">{breathAnnouncement}</span>
{/if} {/if}
</div> </div>
</TimerRing> </TimerRing>
@@ -185,16 +197,16 @@
<h2 class="text-[17px] font-medium text-white mb-1.5" tabindex="-1"> <h2 class="text-[17px] font-medium text-white mb-1.5" tabindex="-1">
{$timer.breakTitle} {$timer.breakTitle}
</h2> </h2>
<p class="text-[12px] leading-relaxed text-[#8a8a8a] mb-4 max-w-[240px]"> <p class="text-[12px] leading-relaxed text-text-sec mb-4 max-w-[240px]">
{$timer.breakMessage} {$timer.breakMessage}
</p> </p>
{#if $config.show_break_activities} {#if $config.show_break_activities}
<div class="rounded-xl border border-[#ffffff08] bg-[#ffffff06] px-4 py-2.5 mb-4 max-w-[260px]"> <div class="rounded-xl border border-[#ffffff08] bg-[#ffffff06] px-4 py-2.5 mb-4 max-w-[260px]">
<div class="text-[9px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase mb-1"> <div class="text-[9px] font-medium tracking-[0.15em] text-text-sec uppercase mb-1">
{getCategoryLabel(currentActivity.category)} {getCategoryLabel(currentActivity.category)}
</div> </div>
<p class="text-[12px] leading-relaxed text-[#8a8a8a]" aria-live="polite"> <p class="text-[12px] leading-relaxed text-text-sec" aria-live="polite">
{currentActivity.text} {currentActivity.text}
</p> </p>
</div> </div>
@@ -205,7 +217,7 @@
<button <button
use:pressable use:pressable
class="rounded-full border border-[#333] px-5 py-2 text-[11px] class="rounded-full border border-[#333] px-5 py-2 text-[11px]
tracking-wider text-[#8a8a8a] uppercase tracking-wider text-text-sec uppercase
transition-colors duration-200 transition-colors duration-200
hover:border-[#444] hover:text-[#ccc]" hover:border-[#444] hover:text-[#ccc]"
onclick={cancelBreak} onclick={cancelBreak}
@@ -226,10 +238,15 @@
{/if} {/if}
</div> </div>
{#if $config.snooze_limit > 0} {#if $config.snooze_limit > 0}
<p class="mt-2 text-[9px] text-[#8a8a8a]"> <p class="mt-2 text-[9px] text-text-sec">
{$timer.snoozesUsed}/{$config.snooze_limit} snoozes used {$timer.snoozesUsed}/{$config.snooze_limit} snoozes used
</p> </p>
{/if} {/if}
{:else}
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<span tabindex="0" class="sr-only" aria-live="polite">
Break in progress, please wait
</span>
{/if} {/if}
</div> </div>
@@ -302,11 +319,11 @@
class:text-[10px]={!isModal} class:text-[10px]={!isModal}
class:text-[9px]={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;" 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-hidden="true"
aria-label="Breathing guide: {breathPhase} {breathCountdown > 0 ? breathCountdown + ' seconds' : ''}"
> >
{breathPhase}{breathCountdown > 0 ? ` ${breathCountdown}s` : ""} {breathPhase}{breathCountdown > 0 ? ` ${breathCountdown}s` : ""}
</span> </span>
<span class="sr-only" aria-live="polite" aria-atomic="true">{breathAnnouncement}</span>
{/if} {/if}
</div> </div>
</TimerRing> </TimerRing>
@@ -328,7 +345,7 @@
</h2> </h2>
<p <p
class="max-w-[300px] text-center text-[13px] leading-relaxed text-[#8a8a8a]" class="max-w-[300px] text-center text-[13px] leading-relaxed text-text-sec"
class:mb-4={$config.show_break_activities} class:mb-4={$config.show_break_activities}
class:mb-8={!$config.show_break_activities} class:mb-8={!$config.show_break_activities}
use:fadeIn={{ delay: 0.35, y: 10 }} use:fadeIn={{ delay: 0.35, y: 10 }}
@@ -341,10 +358,10 @@
class="mb-8 mx-auto max-w-[320px] rounded-2xl border border-[#1a1a1a] bg-[#0a0a0a] px-5 py-3.5 text-center" class="mb-8 mx-auto max-w-[320px] rounded-2xl border border-[#1a1a1a] bg-[#0a0a0a] px-5 py-3.5 text-center"
use:fadeIn={{ delay: 0.4, y: 10 }} use:fadeIn={{ delay: 0.4, y: 10 }}
> >
<div class="mb-1.5 text-[10px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase"> <div class="mb-1.5 text-[10px] font-medium tracking-[0.15em] text-text-sec uppercase">
{getCategoryLabel(currentActivity.category)} {getCategoryLabel(currentActivity.category)}
</div> </div>
<p class="text-[13px] leading-relaxed text-[#8a8a8a]" aria-live="polite"> <p class="text-[13px] leading-relaxed text-text-sec" aria-live="polite">
{currentActivity.text} {currentActivity.text}
</p> </p>
</div> </div>
@@ -354,8 +371,8 @@
<div class="flex items-center gap-3" use:fadeIn={{ delay: 0.45, y: 10 }}> <div class="flex items-center gap-3" use:fadeIn={{ delay: 0.45, y: 10 }}>
<button <button
use:pressable use:pressable
class="rounded-full border border-[#222] px-6 py-2.5 text-[12px] class="rounded-full border border-border px-6 py-2.5 text-[12px]
tracking-wider text-[#8a8a8a] uppercase tracking-wider text-text-sec uppercase
transition-colors duration-200 transition-colors duration-200
hover:border-[#333] hover:text-[#ccc]" hover:border-[#333] hover:text-[#ccc]"
onclick={cancelBreak} onclick={cancelBreak}
@@ -376,10 +393,15 @@
{/if} {/if}
</div> </div>
{#if $config.snooze_limit > 0} {#if $config.snooze_limit > 0}
<p class="mt-3 text-[10px] text-[#8a8a8a]"> <p class="mt-3 text-[10px] text-text-sec">
{$timer.snoozesUsed}/{$config.snooze_limit} snoozes used {$timer.snoozesUsed}/{$config.snooze_limit} snoozes used
</p> </p>
{/if} {/if}
{:else}
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<span tabindex="0" class="sr-only" aria-live="polite">
Break in progress, please wait
</span>
{/if} {/if}
<!-- Bottom progress bar for modal --> <!-- Bottom progress bar for modal -->

View File

@@ -2,10 +2,99 @@
import { milestoneEvent, dailyGoalEvent } from "../stores/timer"; import { milestoneEvent, dailyGoalEvent } from "../stores/timer";
import { config } from "../stores/config"; import { config } from "../stores/config";
const showMilestone = $derived($milestoneEvent !== null && $config.milestone_celebrations); const storeMilestone = $derived($milestoneEvent !== null && $config.milestone_celebrations);
const showGoal = $derived($dailyGoalEvent && $config.milestone_celebrations); const storeGoal = $derived($dailyGoalEvent && $config.milestone_celebrations);
const streakDays = $derived($milestoneEvent ?? 0); const streakDays = $derived($milestoneEvent ?? 0);
// Local visibility state (decoupled from store for hover persistence)
let showMilestone = $state(false);
let showGoal = $state(false);
let milestoneHovering = $state(false);
let goalHovering = $state(false);
let milestoneFading = $state(false);
let goalFading = $state(false);
// Timeout handles for auto-dismiss
let milestoneTimeout: ReturnType<typeof setTimeout> | null = null;
let goalTimeout: ReturnType<typeof setTimeout> | null = null;
const DISMISS_DELAY = 3500; // matches original animation duration
const FADE_DURATION = 600; // fade-out transition time
function dismissMilestone() {
milestoneFading = true;
setTimeout(() => {
showMilestone = false;
milestoneFading = false;
}, FADE_DURATION);
}
function dismissGoal() {
goalFading = true;
setTimeout(() => {
showGoal = false;
goalFading = false;
}, FADE_DURATION);
}
function startMilestoneTimer() {
if (milestoneTimeout) clearTimeout(milestoneTimeout);
milestoneTimeout = setTimeout(() => {
milestoneTimeout = null;
dismissMilestone();
}, DISMISS_DELAY);
}
function startGoalTimer() {
if (goalTimeout) clearTimeout(goalTimeout);
goalTimeout = setTimeout(() => {
goalTimeout = null;
dismissGoal();
}, DISMISS_DELAY);
}
// When the store signals a milestone, show locally and start auto-dismiss
$effect(() => {
if (storeMilestone) {
showMilestone = true;
milestoneFading = false;
startMilestoneTimer();
}
});
// When the store signals daily goal, show locally and start auto-dismiss
$effect(() => {
if (storeGoal) {
showGoal = true;
goalFading = false;
startGoalTimer();
}
});
// Pause/resume milestone dismiss on hover/focus
$effect(() => {
if (milestoneHovering) {
if (milestoneTimeout) {
clearTimeout(milestoneTimeout);
milestoneTimeout = null;
}
} else if (showMilestone && !milestoneFading) {
startMilestoneTimer();
}
});
// Pause/resume goal dismiss on hover/focus
$effect(() => {
if (goalHovering) {
if (goalTimeout) {
clearTimeout(goalTimeout);
goalTimeout = null;
}
} else if (showGoal && !goalFading) {
startGoalTimer();
}
});
// Generate confetti particles on milestone // Generate confetti particles on milestone
const confettiColors = ["#ff4d00", "#7c6aef", "#3fb950", "#fca311", "#f72585", "#4361ee"]; const confettiColors = ["#ff4d00", "#7c6aef", "#3fb950", "#fca311", "#f72585", "#4361ee"];
const confettiParticles = $derived( const confettiParticles = $derived(
@@ -23,7 +112,27 @@
</script> </script>
{#if showMilestone} {#if showMilestone}
<div class="celebration-overlay" role="alert" aria-live="assertive"> <!-- svelte-ignore a11y_no_static_element_interactions a11y_no_noninteractive_element_interactions -->
<div
class="celebration-overlay"
class:fading={milestoneFading}
role="alert"
aria-live="assertive"
tabindex="-1"
onmouseenter={() => milestoneHovering = true}
onmouseleave={() => milestoneHovering = false}
onfocusin={() => milestoneHovering = true}
onfocusout={() => milestoneHovering = false}
onkeydown={(e) => { if (e.key === 'Escape') dismissMilestone(); }}
>
<button
onclick={() => dismissMilestone()}
class="absolute top-3 right-3 w-8 h-8 flex items-center justify-center text-white/50 hover:text-white rounded-full"
aria-label="Dismiss notification"
>
&times;
</button>
<!-- Confetti burst --> <!-- Confetti burst -->
<div class="confetti-container"> <div class="confetti-container">
{#each confettiParticles as p (p.id)} {#each confettiParticles as p (p.id)}
@@ -51,12 +160,31 @@
{/if} {/if}
{#if showGoal && !showMilestone} {#if showGoal && !showMilestone}
<div class="goal-overlay" role="alert" aria-live="assertive"> <!-- svelte-ignore a11y_no_static_element_interactions a11y_no_noninteractive_element_interactions -->
<div
class="goal-overlay"
class:fading={goalFading}
role="alert"
aria-live="assertive"
tabindex="-1"
onmouseenter={() => goalHovering = true}
onmouseleave={() => goalHovering = false}
onfocusin={() => goalHovering = true}
onfocusout={() => goalHovering = false}
onkeydown={(e) => { if (e.key === 'Escape') dismissGoal(); }}
>
<div class="goal-badge"> <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"> <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"/> <path d="M20 6L9 17l-5-5"/>
</svg> </svg>
<span class="text-[14px] font-medium text-[#3fb950] ml-2">Daily goal reached!</span> <span class="text-[14px] font-medium text-[#3fb950] ml-2">Daily goal reached!</span>
<button
onclick={() => dismissGoal()}
class="w-6 h-6 flex items-center justify-center text-[#3fb950]/50 hover:text-[#3fb950] rounded-full ml-2"
aria-label="Dismiss notification"
>
&times;
</button>
</div> </div>
</div> </div>
{/if} {/if}
@@ -69,13 +197,18 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
z-index: 9999; z-index: 9999;
pointer-events: none; opacity: 1;
animation: celebration-fade 3.5s ease forwards; transition: opacity 0.6s ease;
animation: celebration-enter 0.3s ease forwards;
} }
@keyframes celebration-fade { .celebration-overlay.fading {
0%, 70% { opacity: 1; } opacity: 0;
100% { opacity: 0; } }
@keyframes celebration-enter {
0% { opacity: 0; }
100% { opacity: 1; }
} }
.confetti-container { .confetti-container {
@@ -130,15 +263,18 @@
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
z-index: 9999; z-index: 9999;
pointer-events: none; opacity: 1;
animation: goal-slide 3.5s ease forwards; transition: opacity 0.6s ease;
animation: goal-enter 0.35s ease forwards;
} }
@keyframes goal-slide { .goal-overlay.fading {
opacity: 0;
}
@keyframes goal-enter {
0% { transform: translateX(-50%) translateY(-20px); opacity: 0; } 0% { transform: translateX(-50%) translateY(-20px); opacity: 0; }
10% { transform: translateX(-50%) translateY(0); opacity: 1; } 100% { transform: translateX(-50%) translateY(0); opacity: 1; }
75% { opacity: 1; }
100% { transform: translateX(-50%) translateY(-10px); opacity: 0; }
} }
.goal-badge { .goal-badge {
@@ -158,6 +294,11 @@
.goal-overlay { .goal-overlay {
animation: none; animation: none;
opacity: 1; opacity: 1;
transition: none;
}
.celebration-overlay.fading,
.goal-overlay.fading {
opacity: 0;
} }
.confetti-particle { .confetti-particle {
display: none; display: none;

View File

@@ -231,7 +231,7 @@
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<div class="text-[13px] text-white">{label}</div> <div class="text-[13px] text-white">{label}</div>
<div class="font-mono text-[11px] text-[#8a8a8a]">{value}</div> <div class="font-mono text-[11px] text-text-sec">{value}</div>
</div> </div>
<div <div
class="h-6 w-6 rounded-full border border-[#333] shadow-[0_0_8px_0px] transition-shadow duration-300" class="h-6 w-6 rounded-full border border-[#333] shadow-[0_0_8px_0px] transition-shadow duration-300"
@@ -240,31 +240,41 @@
</div> </div>
<!-- Preset swatches --> <!-- Preset swatches -->
<div class="flex flex-wrap gap-[6px]"> <div class="flex flex-wrap gap-[8px]">
{#each presets as color} {#each presets as color}
<button <button
type="button" type="button"
class="h-[22px] w-[22px] rounded-full transition-all duration-150 class="group flex items-center justify-center min-h-[44px] min-w-[44px] bg-transparent border-none p-0"
{value === color
? 'ring-[1.5px] ring-white ring-offset-1 ring-offset-black scale-110'
: 'hover:scale-110 opacity-80 hover:opacity-100'}"
style="background: {color};"
onclick={() => selectPreset(color)} onclick={() => selectPreset(color)}
aria-label="Select {getColorName(color)}" aria-label="Select {getColorName(color)}"
></button> aria-pressed={value === color}
>
<span
class="block h-[28px] w-[28px] rounded-full transition-all duration-150 pointer-events-none
{value === color
? 'ring-[1.5px] ring-white ring-offset-1 ring-offset-black scale-110'
: 'group-hover:scale-110 opacity-80 group-hover:opacity-100'}"
style="background: {color};"
></span>
</button>
{/each} {/each}
<!-- Custom toggle swatch — ring shows when picker open OR value is custom --> <!-- Custom toggle swatch — ring shows when picker open OR value is custom -->
<button <button
type="button" type="button"
class="h-[22px] w-[22px] rounded-full transition-all duration-150 class="group flex items-center justify-center min-h-[44px] min-w-[44px] bg-transparent border-none p-0"
{showCustom || !isPreset
? 'ring-[1.5px] ring-white ring-offset-1 ring-offset-black scale-110'
: 'hover:scale-110 opacity-80 hover:opacity-100'}"
style="background: conic-gradient(from 0deg, #f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00);"
onclick={() => { showCustom = !showCustom; }} onclick={() => { showCustom = !showCustom; }}
aria-label="Custom color" aria-label="Custom color"
></button> aria-pressed={showCustom || !isPreset}
>
<span
class="block h-[28px] w-[28px] rounded-full transition-all duration-150 pointer-events-none
{showCustom || !isPreset
? 'ring-[1.5px] ring-white ring-offset-1 ring-offset-black scale-110'
: 'opacity-80 group-hover:opacity-100 group-hover:scale-110'}"
style="background: conic-gradient(from 0deg, #f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00);"
></span>
</button>
</div> </div>
<!-- Inline custom picker — slides open/closed --> <!-- Inline custom picker — slides open/closed -->

View File

@@ -112,6 +112,7 @@
// Natural break notification // Natural break notification
let showNaturalBreakToast = $state(false); let showNaturalBreakToast = $state(false);
let toastHovering = $state(false);
let naturalBreakToastTimeout: ReturnType<typeof setTimeout> | null = null; let naturalBreakToastTimeout: ReturnType<typeof setTimeout> | null = null;
// Watch for natural break detection // Watch for natural break detection
@@ -120,7 +121,9 @@
showNaturalBreakToast = true; showNaturalBreakToast = true;
if (naturalBreakToastTimeout) clearTimeout(naturalBreakToastTimeout); if (naturalBreakToastTimeout) clearTimeout(naturalBreakToastTimeout);
naturalBreakToastTimeout = setTimeout(() => { naturalBreakToastTimeout = setTimeout(() => {
if (!toastHovering) {
showNaturalBreakToast = false; showNaturalBreakToast = false;
}
}, 5000); }, 5000);
} }
}); });
@@ -177,7 +180,7 @@
<!-- Status label --> <!-- Status label -->
<span <span
class="block text-center text-[11px] font-medium tracking-[0.25em]" class="block text-center text-[11px] font-medium tracking-[0.25em]"
class:text-[#8a8a8a]={!$timer.prebreakWarning && !$timer.deferredBreakPending} class:text-text-sec={!$timer.prebreakWarning && !$timer.deferredBreakPending}
class:text-warning={$timer.prebreakWarning} class:text-warning={$timer.prebreakWarning}
class:text-[#fca311]={$timer.deferredBreakPending} class:text-[#fca311]={$timer.deferredBreakPending}
> >
@@ -205,15 +208,16 @@
></div> ></div>
{/each} {/each}
</div> </div>
<span class="text-[9px] text-[#8a8a8a] tabular-nums"> <span class="text-[9px] text-text-sec tabular-nums">
{$timer.pomodoroCyclePosition + 1}/{$timer.pomodoroTotalInCycle} {$timer.pomodoroCyclePosition + 1}/{$timer.pomodoroTotalInCycle}
</span> </span>
</div> </div>
<span class="sr-only">Pomodoro cycle: session {$timer.pomodoroCyclePosition + 1} of {$timer.pomodoroTotalInCycle}</span>
{/if} {/if}
<!-- Microbreak countdown --> <!-- Microbreak countdown -->
{#if $timer.microbreakEnabled && !$timer.microbreakActive && $timer.state === "running"} {#if $timer.microbreakEnabled && !$timer.microbreakActive && $timer.state === "running"}
<div class="flex items-center gap-1 text-[9px] text-[#8a8a8a]"> <div class="flex items-center gap-1 text-[9px] text-text-sec">
<svg aria-hidden="true" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> <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"/> <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"/> <circle cx="12" cy="12" r="3"/>
@@ -231,14 +235,22 @@
</svg> </svg>
<span class="text-[9px] text-[#3fb950]">Goal met</span> <span class="text-[9px] text-[#3fb950]">Goal met</span>
{:else} {:else}
<span class="text-[9px] text-[#8a8a8a]">Goal</span> <span class="text-[9px] text-text-sec">Goal</span>
<div class="w-16 h-[2px] rounded-full overflow-hidden" style="background: #161616;"> <div
class="w-16 h-[2px] rounded-full overflow-hidden"
style="background: #161616;"
role="progressbar"
aria-label="Daily goal progress"
aria-valuemin={0}
aria-valuemax={$config.daily_goal_breaks}
aria-valuenow={dailyGoalProgress}
>
<div <div
class="h-full rounded-full transition-[width] duration-500" class="h-full rounded-full transition-[width] duration-500"
style="width: {Math.min(100, (dailyGoalProgress / $config.daily_goal_breaks) * 100)}%; background: {$config.accent_color};" style="width: {Math.min(100, (dailyGoalProgress / $config.daily_goal_breaks) * 100)}%; background: {$config.accent_color};"
></div> ></div>
</div> </div>
<span class="text-[9px] text-[#8a8a8a] tabular-nums"> <span class="text-[9px] text-text-sec tabular-nums">
{dailyGoalProgress}/{$config.daily_goal_breaks} {dailyGoalProgress}/{$config.daily_goal_breaks}
</span> </span>
{/if} {/if}
@@ -253,7 +265,7 @@
<!-- Last break info --> <!-- Last break info -->
<div use:fadeIn={{ delay: 0.4, y: 10 }}> <div use:fadeIn={{ delay: 0.4, y: 10 }}>
{#if $timer.hasHadBreak} {#if $timer.hasHadBreak}
<p style="margin-bottom: {ringMargin}px;" class="text-[12px] text-[#8a8a8a]"> <p style="margin-bottom: {ringMargin}px;" class="text-[12px] text-text-sec">
Last break {formatDurationAgo($timer.secondsSinceLastBreak)} Last break {formatDurationAgo($timer.secondsSinceLastBreak)}
</p> </p>
{:else} {:else}
@@ -263,17 +275,24 @@
<!-- Natural break notification toast --> <!-- Natural break notification toast -->
{#if showNaturalBreakToast} {#if showNaturalBreakToast}
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div <div
role="alert" role="alert"
class="absolute top-6 left-1/2 -translate-x-1/2 rounded-2xl border px-5 py-3 text-center backdrop-blur-xl transition-all duration-500" class="absolute top-6 left-1/2 -translate-x-1/2 rounded-2xl border px-5 py-3 text-center backdrop-blur-xl transition-all duration-500"
style="border-color: rgba(63, 185, 80, 0.3); background: rgba(63, 185, 80, 0.1);" style="border-color: rgba(63, 185, 80, 0.3); background: rgba(63, 185, 80, 0.1);"
use:scaleIn={{ duration: 0.3, delay: 0 }} use:scaleIn={{ duration: 0.3, delay: 0 }}
onmouseenter={() => toastHovering = true}
onmouseleave={() => { toastHovering = false; showNaturalBreakToast = false; }}
onfocusin={() => toastHovering = true}
onfocusout={() => { toastHovering = false; showNaturalBreakToast = false; }}
onkeydown={(e) => { if (e.key === 'Escape') showNaturalBreakToast = false; }}
> >
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#3fb950" stroke-width="2"> <svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#3fb950" stroke-width="2">
<path d="M20 6L9 17l-5-5"/> <path d="M20 6L9 17l-5-5"/>
</svg> </svg>
<span class="text-[13px] text-[#3fb950]">Natural break detected - timer reset</span> <span class="text-[13px] text-[#3fb950]">Natural break detected - timer reset</span>
<button onclick={() => showNaturalBreakToast = false} class="ml-2 text-text-sec hover:text-white" aria-label="Dismiss notification">&times;</button>
</div> </div>
</div> </div>
{/if} {/if}
@@ -292,13 +311,15 @@
{toggleBtnText} {toggleBtnText}
</button> </button>
<!-- Bottom navigation buttons -->
<nav aria-label="Main actions" class="contents">
<!-- Bottom left: start break now --> <!-- Bottom left: start break now -->
<div class="absolute bottom-5 left-5" use:fadeIn={{ delay: 0.5, y: 8 }}> <div class="absolute bottom-5 left-5" use:fadeIn={{ delay: 0.5, y: 8 }}>
<button <button
aria-label="Start break now" aria-label="Start break now"
use:pressable use:pressable
class="flex h-11 w-11 items-center justify-center rounded-full class="flex h-11 w-11 items-center justify-center rounded-full
border border-[#222] text-[#8a8a8a] border border-border text-text-sec
transition-colors duration-200 transition-colors duration-200
hover:border-[#333] hover:text-[#aaa]" hover:border-[#333] hover:text-[#aaa]"
onclick={startBreakNow} onclick={startBreakNow}
@@ -325,7 +346,7 @@
aria-label="Statistics" aria-label="Statistics"
use:pressable use:pressable
class="flex h-11 w-11 items-center justify-center rounded-full class="flex h-11 w-11 items-center justify-center rounded-full
border border-[#222] text-[#8a8a8a] border border-border text-text-sec
transition-colors duration-200 transition-colors duration-200
hover:border-[#333] hover:text-[#aaa]" hover:border-[#333] hover:text-[#aaa]"
onclick={() => { onclick={() => {
@@ -357,7 +378,7 @@
aria-label="Settings" aria-label="Settings"
use:pressable use:pressable
class="flex h-11 w-11 items-center justify-center rounded-full class="flex h-11 w-11 items-center justify-center rounded-full
border border-[#222] text-[#8a8a8a] border border-border text-text-sec
transition-colors duration-200 transition-colors duration-200
hover:border-[#333] hover:text-[#aaa]" hover:border-[#333] hover:text-[#aaa]"
onclick={openSettings} onclick={openSettings}
@@ -380,6 +401,7 @@
</svg> </svg>
</button> </button>
</div> </div>
</nav>
</div> </div>
<style> <style>

View File

@@ -52,7 +52,7 @@
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<div class="text-[13px] text-white">Countdown font</div> <div class="text-[13px] text-white">Countdown font</div>
<div class="text-[11px] text-[#8a8a8a]"> <div class="text-[11px] text-text-sec">
{value || "System default"} {value || "System default"}
</div> </div>
</div> </div>
@@ -60,7 +60,7 @@
type="button" type="button"
aria-expanded={expanded} aria-expanded={expanded}
aria-label={expanded ? "Close font browser" : "Browse fonts"} aria-label={expanded ? "Close font browser" : "Browse fonts"}
class="rounded-lg border border-[#1a1a1a] bg-black px-3 py-1.5 text-[12px] text-[#8a8a8a] class="rounded-lg border border-[#1a1a1a] bg-black px-3 py-1.5 text-[12px] text-text-sec
transition-colors hover:border-[#333] hover:text-white" transition-colors hover:border-[#333] hover:text-white"
onclick={() => { expanded = !expanded; }} onclick={() => { expanded = !expanded; }}
> >
@@ -83,7 +83,7 @@
transition-all duration-150 transition-all duration-150
{value === font.family {value === font.family
? 'border-white/30 bg-[#141414]' ? 'border-white/30 bg-[#141414]'
: 'border-[#141414] bg-[#0a0a0a] hover:border-[#222] hover:bg-[#0f0f0f]'}" : 'border-[#141414] bg-[#0a0a0a] hover:border-border hover:bg-[#0f0f0f]'}"
onclick={() => selectFont(font.family)} onclick={() => selectFont(font.family)}
> >
<span <span
@@ -92,7 +92,7 @@
> >
25:00 25:00
</span> </span>
<span class="text-[9px] tracking-wider text-[#8a8a8a] uppercase"> <span class="text-[9px] tracking-wider text-text-sec uppercase">
{font.label} {font.label}
</span> </span>
</button> </button>

View File

@@ -38,17 +38,18 @@
}); });
</script> </script>
<div class="microbreak-card"> <div class="microbreak-card" role="alertdialog" aria-label="Microbreak" aria-describedby="microbreak-msg">
<h2 class="sr-only">Microbreak</h2>
<div class="flex items-center gap-3 mb-2"> <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"> <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"/> <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"/> <circle cx="12" cy="12" r="3"/>
</svg> </svg>
<span class="text-[15px] font-medium text-white">Look away — 20 feet for {timeRemaining}s</span> <span id="microbreak-msg" class="text-[15px] font-medium text-white">Look away — 20 feet for {timeRemaining}s</span>
</div> </div>
{#if activity && $config.microbreak_show_activity} {#if activity && $config.microbreak_show_activity}
<p class="text-[12px] text-[#8a8a8a] mb-3 ml-[34px]"> <p class="text-[12px] text-text-sec mb-3 ml-[34px]">
{activity.text} {activity.text}
</p> </p>
{/if} {/if}

View File

@@ -335,13 +335,13 @@ const fontStyle = $derived(
<!-- Countdown text --> <!-- Countdown text -->
<span <span
class="ml-2.5 text-[18px] font-semibold leading-none tabular-nums" class="ml-2.5 text-[18px] font-semibold leading-none tabular-nums"
style="color: {state === 'paused' ? '#555' : '#fff'}; {fontStyle}" style="color: {state === 'paused' ? '#a8a8a8' : '#fff'}; {fontStyle}"
> >
{timeText} {timeText}
</span> </span>
<!-- F3: Pomodoro cycle indicator --> <!-- F3: Pomodoro cycle indicator -->
{#if pomodoroEnabled} {#if pomodoroEnabled}
<span class="ml-1.5 text-[10px] tabular-nums" style="color: #8a8a8a;"> <span class="ml-1.5 text-[10px] tabular-nums" style="color: #a8a8a8;">
{pomodoroCyclePosition + 1}/{pomodoroTotalInCycle} {pomodoroCyclePosition + 1}/{pomodoroTotalInCycle}
</span> </span>
{/if} {/if}

View File

@@ -124,8 +124,8 @@
<button <button
aria-label="Back to dashboard" aria-label="Back to dashboard"
use:pressable use:pressable
class="mr-3 flex h-8 w-8 items-center justify-center rounded-full class="mr-3 flex h-10 w-10 min-h-[44px] min-w-[44px] items-center justify-center rounded-full
text-[#8a8a8a] transition-colors hover:text-white" text-text-sec transition-colors hover:text-white"
onclick={goBack} onclick={goBack}
> >
<svg <svg
@@ -159,16 +159,16 @@
<div class="space-y-3"> <div class="space-y-3">
<!-- 1. Timer --> <!-- 1. Timer -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0 }}> <section aria-labelledby="settings-timer" class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0 }}>
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase"> <h2 id="settings-timer" class="mb-4 text-[11px] font-medium tracking-[0.15em] text-text-sec uppercase">
Timer Timer
</h3> </h2>
<div class="flex items-center"> <div class="flex items-center">
<div class="flex-1"> <div class="flex-1">
<div class="text-[13px] text-white">Break frequency</div> <div class="text-[13px] text-white">Break frequency</div>
<div class="text-[11px] text-[#8a8a8a]"> <div class="text-[11px] text-text-sec">
Every {$config.break_frequency} min Every {$config.break_frequency} <abbr title="minutes">min</abbr>
</div> </div>
</div> </div>
<Stepper <Stepper
@@ -180,12 +180,12 @@
/> />
</div> </div>
<div class="my-4 h-px bg-[#161616]"></div> <div class="my-4 h-px bg-border"></div>
<div class="flex items-center"> <div class="flex items-center">
<div class="flex-1"> <div class="flex-1">
<div class="text-[13px] text-white">Break duration</div> <div class="text-[13px] text-white">Break duration</div>
<div class="text-[11px] text-[#8a8a8a]"> <div class="text-[11px] text-text-sec">
{$config.break_duration} min {$config.break_duration} min
</div> </div>
</div> </div>
@@ -198,12 +198,12 @@
/> />
</div> </div>
<div class="my-4 h-px bg-[#161616]"></div> <div class="my-4 h-px bg-border"></div>
<div class="flex items-center"> <div class="flex items-center">
<div class="flex-1"> <div class="flex-1">
<div class="text-[13px] text-white">Auto-start</div> <div class="text-[13px] text-white">Auto-start</div>
<div class="text-[11px] text-[#8a8a8a]">Start timer on launch</div> <div class="text-[11px] text-text-sec">Start timer on launch</div>
</div> </div>
<ToggleSwitch <ToggleSwitch
bind:checked={$config.auto_start} bind:checked={$config.auto_start}
@@ -214,61 +214,61 @@
</section> </section>
<!-- 2. Pomodoro Mode --> <!-- 2. Pomodoro Mode -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.03 }}> <section aria-labelledby="settings-pomodoro" class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.03 }}>
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase"> <h2 id="settings-pomodoro" title="Pomodoro technique alternates focused work sessions with short and long breaks" class="mb-4 text-[11px] font-medium tracking-[0.15em] text-text-sec uppercase">
Pomodoro Mode Pomodoro Mode
</h3> </h2>
<div class="flex items-center"> <div class="flex items-center">
<div class="flex-1"> <div class="flex-1">
<div class="text-[13px] text-white">Enable Pomodoro</div> <div class="text-[13px] text-white">Enable Pomodoro</div>
<div class="text-[11px] text-[#8a8a8a]">Short breaks then a long break</div> <div class="text-[11px] text-text-sec">Short breaks then a long break</div>
</div> </div>
<ToggleSwitch bind:checked={$config.pomodoro_enabled} label="Pomodoro mode" onchange={markChanged} /> <ToggleSwitch bind:checked={$config.pomodoro_enabled} label="Pomodoro mode" onchange={markChanged} />
</div> </div>
{#if $config.pomodoro_enabled} {#if $config.pomodoro_enabled}
<div class="my-4 h-px bg-[#161616]"></div> <div class="my-4 h-px bg-border"></div>
<div class="flex items-center"> <div class="flex items-center">
<div class="flex-1"> <div class="flex-1">
<div class="text-[13px] text-white">Short breaks before long</div> <div class="text-[13px] text-white">Short breaks before long</div>
<div class="text-[11px] text-[#8a8a8a]">{$config.pomodoro_short_breaks} short + 1 long</div> <div class="text-[11px] text-text-sec">{$config.pomodoro_short_breaks} short + 1 long</div>
</div> </div>
<Stepper bind:value={$config.pomodoro_short_breaks} label="Short breaks" min={1} max={10} onchange={markChanged} /> <Stepper bind:value={$config.pomodoro_short_breaks} label="Short breaks" min={1} max={10} onchange={markChanged} />
</div> </div>
<div class="my-4 h-px bg-[#161616]"></div> <div class="my-4 h-px bg-border"></div>
<div class="flex items-center"> <div class="flex items-center">
<div class="flex-1"> <div class="flex-1">
<div class="text-[13px] text-white">Long break duration</div> <div class="text-[13px] text-white">Long break duration</div>
<div class="text-[11px] text-[#8a8a8a]">{$config.pomodoro_long_break_duration} min</div> <div class="text-[11px] text-text-sec">{$config.pomodoro_long_break_duration} min</div>
</div> </div>
<Stepper bind:value={$config.pomodoro_long_break_duration} label="Long break duration" min={5} max={60} onchange={markChanged} /> <Stepper bind:value={$config.pomodoro_long_break_duration} label="Long break duration" min={5} max={60} onchange={markChanged} />
</div> </div>
<div class="my-4 h-px bg-[#161616]"></div> <div class="my-4 h-px bg-border"></div>
<div class="flex flex-col gap-1.5"> <div class="flex flex-col gap-1.5">
<label class="text-[13px] text-white" for="pomo-title">Long break title</label> <label class="text-[13px] text-white" for="pomo-title">Long break title</label>
<input id="pomo-title" type="text" maxlength={100} <input id="pomo-title" type="text" maxlength={100}
class="rounded-xl border border-[#161616] bg-black px-3.5 py-2.5 text-[13px] text-white outline-none transition-colors placeholder:text-[#2a2a2a] focus:border-[#333]" class="rounded-xl border border-border bg-black px-3.5 py-2.5 text-[13px] text-white outline-none transition-colors placeholder:text-[#555] focus:border-[#333]"
bind:value={$config.pomodoro_long_break_title} oninput={markChanged} bind:value={$config.pomodoro_long_break_title} oninput={markChanged}
/> />
</div> </div>
<div class="my-4 h-px bg-[#161616]"></div> <div class="my-4 h-px bg-border"></div>
<div class="flex flex-col gap-1.5"> <div class="flex flex-col gap-1.5">
<label class="text-[13px] text-white" for="pomo-msg">Long break message</label> <label class="text-[13px] text-white" for="pomo-msg">Long break message</label>
<input id="pomo-msg" type="text" maxlength={500} <input id="pomo-msg" type="text" maxlength={500}
class="rounded-xl border border-[#161616] bg-black px-3.5 py-2.5 text-[13px] text-white outline-none transition-colors placeholder:text-[#2a2a2a] focus:border-[#333]" class="rounded-xl border border-border bg-black px-3.5 py-2.5 text-[13px] text-white outline-none transition-colors placeholder:text-[#555] focus:border-[#333]"
bind:value={$config.pomodoro_long_break_message} oninput={markChanged} bind:value={$config.pomodoro_long_break_message} oninput={markChanged}
/> />
</div> </div>
<div class="my-4 h-px bg-[#161616]"></div> <div class="my-4 h-px bg-border"></div>
<div class="flex items-center"> <div class="flex items-center">
<div class="flex-1"> <div class="flex-1">
<div class="text-[13px] text-white">Reset on skip</div> <div class="text-[13px] text-white">Reset on skip</div>
<div class="text-[11px] text-[#8a8a8a]">Reset cycle when skipping a break</div> <div class="text-[11px] text-text-sec">Reset cycle when skipping a break</div>
</div> </div>
<ToggleSwitch bind:checked={$config.pomodoro_reset_on_skip} label="Reset on skip" onchange={markChanged} /> <ToggleSwitch bind:checked={$config.pomodoro_reset_on_skip} label="Reset on skip" onchange={markChanged} />
</div> </div>
@@ -276,15 +276,15 @@
</section> </section>
<!-- 3. Microbreaks --> <!-- 3. Microbreaks -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.06 }}> <section aria-labelledby="settings-microbreaks" class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.06 }}>
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase"> <h2 id="settings-microbreaks" title="20-20-20 rule: every 20 minutes, look 20 feet away for 20 seconds" class="mb-4 text-[11px] font-medium tracking-[0.15em] text-text-sec uppercase">
Microbreaks Microbreaks
</h3> </h2>
<div class="flex items-center"> <div class="flex items-center">
<div class="flex-1"> <div class="flex-1">
<div class="text-[13px] text-white">20-20-20 eye breaks</div> <div class="text-[13px] text-white">20-20-20 eye breaks</div>
<div class="text-[11px] text-[#8a8a8a]">Quick eye rest reminders</div> <div class="text-[11px] text-text-sec">Quick eye rest reminders</div>
</div> </div>
<ToggleSwitch <ToggleSwitch
bind:checked={$config.microbreak_enabled} bind:checked={$config.microbreak_enabled}
@@ -294,47 +294,47 @@
</div> </div>
{#if $config.microbreak_enabled} {#if $config.microbreak_enabled}
<div class="my-4 h-px bg-[#161616]"></div> <div class="my-4 h-px bg-border"></div>
<div class="flex items-center"> <div class="flex items-center">
<div class="flex-1"> <div class="flex-1">
<div class="text-[13px] text-white">Frequency</div> <div class="text-[13px] text-white">Frequency</div>
<div class="text-[11px] text-[#8a8a8a]">Every {$config.microbreak_frequency} min</div> <div class="text-[11px] text-text-sec">Every {$config.microbreak_frequency} min</div>
</div> </div>
<Stepper bind:value={$config.microbreak_frequency} label="Microbreak frequency" min={5} max={60} onchange={markChanged} /> <Stepper bind:value={$config.microbreak_frequency} label="Microbreak frequency" min={5} max={60} onchange={markChanged} />
</div> </div>
<div class="my-4 h-px bg-[#161616]"></div> <div class="my-4 h-px bg-border"></div>
<div class="flex items-center"> <div class="flex items-center">
<div class="flex-1"> <div class="flex-1">
<div class="text-[13px] text-white">Duration</div> <div class="text-[13px] text-white">Duration</div>
<div class="text-[11px] text-[#8a8a8a]">{$config.microbreak_duration} seconds</div> <div class="text-[11px] text-text-sec">{$config.microbreak_duration} seconds</div>
</div> </div>
<Stepper bind:value={$config.microbreak_duration} label="Microbreak duration" min={10} max={60} step={5} onchange={markChanged} /> <Stepper bind:value={$config.microbreak_duration} label="Microbreak duration" min={10} max={60} step={5} onchange={markChanged} />
</div> </div>
<div class="my-4 h-px bg-[#161616]"></div> <div class="my-4 h-px bg-border"></div>
<div class="flex items-center"> <div class="flex items-center">
<div class="flex-1"> <div class="flex-1">
<div class="text-[13px] text-white">Sound</div> <div class="text-[13px] text-white">Sound</div>
<div class="text-[11px] text-[#8a8a8a]">Play sound on eye break</div> <div class="text-[11px] text-text-sec">Play sound on eye break</div>
</div> </div>
<ToggleSwitch bind:checked={$config.microbreak_sound_enabled} label="Microbreak sound" onchange={markChanged} /> <ToggleSwitch bind:checked={$config.microbreak_sound_enabled} label="Microbreak sound" onchange={markChanged} />
</div> </div>
<div class="my-4 h-px bg-[#161616]"></div> <div class="my-4 h-px bg-border"></div>
<div class="flex items-center"> <div class="flex items-center">
<div class="flex-1"> <div class="flex-1">
<div class="text-[13px] text-white">Show activity</div> <div class="text-[13px] text-white">Show activity</div>
<div class="text-[11px] text-[#8a8a8a]">Activity suggestion during eye break</div> <div class="text-[11px] text-text-sec">Activity suggestion during eye break</div>
</div> </div>
<ToggleSwitch bind:checked={$config.microbreak_show_activity} label="Microbreak activity" onchange={markChanged} /> <ToggleSwitch bind:checked={$config.microbreak_show_activity} label="Microbreak activity" onchange={markChanged} />
</div> </div>
<div class="my-4 h-px bg-[#161616]"></div> <div class="my-4 h-px bg-border"></div>
<div class="flex items-center"> <div class="flex items-center">
<div class="flex-1"> <div class="flex-1">
<div class="text-[13px] text-white">Pause during breaks</div> <div class="text-[13px] text-white">Pause during breaks</div>
<div class="text-[11px] text-[#8a8a8a]">No eye breaks during main breaks</div> <div class="text-[11px] text-text-sec">No eye breaks during main breaks</div>
</div> </div>
<ToggleSwitch bind:checked={$config.microbreak_pause_during_break} label="Pause during breaks" onchange={markChanged} /> <ToggleSwitch bind:checked={$config.microbreak_pause_during_break} label="Pause during breaks" onchange={markChanged} />
</div> </div>
@@ -342,10 +342,10 @@
</section> </section>
<!-- 4. Break Screen (stripped down) --> <!-- 4. Break Screen (stripped down) -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.09 }}> <section aria-labelledby="settings-breakscreen" class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.09 }}>
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase"> <h2 id="settings-breakscreen" class="mb-4 text-[11px] font-medium tracking-[0.15em] text-text-sec uppercase">
Break Screen Break Screen
</h3> </h2>
<div class="flex flex-col gap-1.5"> <div class="flex flex-col gap-1.5">
<label class="text-[13px] text-white" for="break-title"> <label class="text-[13px] text-white" for="break-title">
@@ -354,16 +354,16 @@
<input <input
id="break-title" id="break-title"
type="text" type="text"
class="rounded-xl border border-[#161616] bg-black px-3.5 py-2.5 text-[13px] class="rounded-xl border border-border bg-black px-3.5 py-2.5 text-[13px]
text-white outline-none transition-colors text-white outline-none transition-colors
placeholder:text-[#2a2a2a] focus:border-[#333]" placeholder:text-[#555] focus:border-[#333]"
placeholder="Enter break title..." placeholder="Enter break title..."
bind:value={$config.break_title} bind:value={$config.break_title}
oninput={markChanged} oninput={markChanged}
/> />
</div> </div>
<div class="my-4 h-px bg-[#161616]"></div> <div class="my-4 h-px bg-border"></div>
<div class="flex flex-col gap-1.5"> <div class="flex flex-col gap-1.5">
<label class="text-[13px] text-white" for="break-message"> <label class="text-[13px] text-white" for="break-message">
@@ -372,21 +372,21 @@
<input <input
id="break-message" id="break-message"
type="text" type="text"
class="rounded-xl border border-[#161616] bg-black px-3.5 py-2.5 text-[13px] class="rounded-xl border border-border bg-black px-3.5 py-2.5 text-[13px]
text-white outline-none transition-colors text-white outline-none transition-colors
placeholder:text-[#2a2a2a] focus:border-[#333]" placeholder:text-[#555] focus:border-[#333]"
placeholder="Enter break message..." placeholder="Enter break message..."
bind:value={$config.break_message} bind:value={$config.break_message}
oninput={markChanged} oninput={markChanged}
/> />
</div> </div>
<div class="my-4 h-px bg-[#161616]"></div> <div class="my-4 h-px bg-border"></div>
<div class="flex items-center"> <div class="flex items-center">
<div class="flex-1"> <div class="flex-1">
<div class="text-[13px] text-white">Fullscreen break</div> <div class="text-[13px] text-white">Fullscreen break</div>
<div class="text-[11px] text-[#8a8a8a]"> <div class="text-[11px] text-text-sec">
{$config.fullscreen_mode ? "Fills entire screen" : "Centered modal"} {$config.fullscreen_mode ? "Fills entire screen" : "Centered modal"}
</div> </div>
</div> </div>
@@ -397,12 +397,12 @@
/> />
</div> </div>
<div class="my-4 h-px bg-[#161616]"></div> <div class="my-4 h-px bg-border"></div>
<div class="flex items-center"> <div class="flex items-center">
<div class="flex-1"> <div class="flex-1">
<div class="text-[13px] text-white">Activity suggestions</div> <div class="text-[13px] text-white">Activity suggestions</div>
<div class="text-[11px] text-[#8a8a8a]"> <div class="text-[11px] text-text-sec">
Exercise ideas during breaks Exercise ideas during breaks
</div> </div>
</div> </div>
@@ -414,11 +414,11 @@
</div> </div>
{#if $config.fullscreen_mode} {#if $config.fullscreen_mode}
<div class="my-4 h-px bg-[#161616]"></div> <div class="my-4 h-px bg-border"></div>
<div class="flex items-center"> <div class="flex items-center">
<div class="flex-1"> <div class="flex-1">
<div class="text-[13px] text-white">Block all monitors</div> <div class="text-[13px] text-white">Block all monitors</div>
<div class="text-[11px] text-[#8a8a8a]">Show overlay on all screens during breaks</div> <div class="text-[11px] text-text-sec">Show overlay on all screens during breaks</div>
</div> </div>
<ToggleSwitch <ToggleSwitch
bind:checked={$config.multi_monitor_break} bind:checked={$config.multi_monitor_break}
@@ -431,24 +431,24 @@
<!-- 5. Break Activities (own card, conditional) --> <!-- 5. Break Activities (own card, conditional) -->
{#if $config.show_break_activities} {#if $config.show_break_activities}
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.12 }}> <section aria-labelledby="settings-activities" class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.12 }}>
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase"> <h2 id="settings-activities" class="mb-4 text-[11px] font-medium tracking-[0.15em] text-text-sec uppercase">
Break Activities Break Activities
</h3> </h2>
<ActivityManager /> <ActivityManager />
</section> </section>
{/if} {/if}
<!-- 6. Breathing Guide (own card) --> <!-- 6. Breathing Guide (own card) -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.15 }}> <section aria-labelledby="settings-breathing" class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.15 }}>
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase"> <h2 id="settings-breathing" title="Visual breathing exercise during breaks to reduce stress" class="mb-4 text-[11px] font-medium tracking-[0.15em] text-text-sec uppercase">
Breathing Guide Breathing Guide
</h3> </h2>
<div class="flex items-center"> <div class="flex items-center">
<div class="flex-1"> <div class="flex-1">
<div class="text-[13px] text-white">Guided breathing</div> <div class="text-[13px] text-white">Guided breathing</div>
<div class="text-[11px] text-[#8a8a8a]">Visual breathing guide during breaks</div> <div class="text-[11px] text-text-sec">Visual breathing guide during breaks</div>
</div> </div>
<ToggleSwitch <ToggleSwitch
bind:checked={$config.breathing_guide_enabled} bind:checked={$config.breathing_guide_enabled}
@@ -458,18 +458,20 @@
</div> </div>
{#if $config.breathing_guide_enabled} {#if $config.breathing_guide_enabled}
<div class="my-4 h-px bg-[#161616]"></div> <div class="my-4 h-px bg-border"></div>
<div> <div>
<div class="mb-3 text-[13px] text-white">Breathing pattern</div> <div class="mb-3 text-[13px] text-white" id="breathing-pattern-label">Breathing pattern</div>
<div class="flex flex-col gap-1.5"> <div class="flex flex-col gap-1.5" role="radiogroup" aria-label="Breathing pattern">
{#each breathingPatternMeta as bp} {#each breathingPatternMeta as bp}
<button <button
use:pressable use:pressable
role="radio"
aria-checked={$config.breathing_pattern === bp.id}
class="flex items-center gap-3 rounded-xl px-3.5 py-2.5 text-left class="flex items-center gap-3 rounded-xl px-3.5 py-2.5 text-left
transition-all duration-200 transition-all duration-200
{$config.breathing_pattern === bp.id {$config.breathing_pattern === bp.id
? 'bg-[#1a1a1a] border border-[#333]' ? 'bg-[#1a1a1a] border border-[#333]'
: 'bg-[#0a0a0a] border border-[#161616] hover:border-[#333]'}" : 'bg-[#0a0a0a] border border-border hover:border-[#333]'}"
onclick={() => { onclick={() => {
$config.breathing_pattern = bp.id; $config.breathing_pattern = bp.id;
markChanged(); markChanged();
@@ -480,10 +482,10 @@
style="border-color: {$config.breathing_pattern === bp.id ? $config.accent_color : '#333'}; style="border-color: {$config.breathing_pattern === bp.id ? $config.accent_color : '#333'};
background: {$config.breathing_pattern === bp.id ? $config.accent_color : 'transparent'};" background: {$config.breathing_pattern === bp.id ? $config.accent_color : 'transparent'};"
></div> ></div>
<span class="text-[12px] font-medium {$config.breathing_pattern === bp.id ? 'text-white' : 'text-[#8a8a8a]'}"> <span class="text-[12px] font-medium {$config.breathing_pattern === bp.id ? 'text-white' : 'text-text-sec'}">
{bp.label} {bp.label}
</span> </span>
<span class="ml-auto text-[11px] text-[#8a8a8a] opacity-60 tabular-nums"> <span class="ml-auto text-[11px] text-text-sec opacity-60 tabular-nums">
{bp.desc} {bp.desc}
</span> </span>
</button> </button>
@@ -494,15 +496,15 @@
</section> </section>
<!-- 7. Behavior --> <!-- 7. Behavior -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.18 }}> <section aria-labelledby="settings-behavior" class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.18 }}>
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase"> <h2 id="settings-behavior" class="mb-4 text-[11px] font-medium tracking-[0.15em] text-text-sec uppercase">
Behavior Behavior
</h3> </h2>
<div class="flex items-center"> <div class="flex items-center">
<div class="flex-1"> <div class="flex-1">
<div class="text-[13px] text-white">Strict mode</div> <div class="text-[13px] text-white">Strict mode</div>
<div class="text-[11px] text-[#8a8a8a]"> <div class="text-[11px] text-text-sec">
Disable skip and snooze Disable skip and snooze
</div> </div>
</div> </div>
@@ -514,12 +516,12 @@
</div> </div>
{#if !$config.strict_mode} {#if !$config.strict_mode}
<div class="my-4 h-px bg-[#161616]"></div> <div class="my-4 h-px bg-border"></div>
<div class="flex items-center"> <div class="flex items-center">
<div class="flex-1"> <div class="flex-1">
<div class="text-[13px] text-white">Allow end early</div> <div class="text-[13px] text-white">Allow end early</div>
<div class="text-[11px] text-[#8a8a8a]">After 50% of break</div> <div class="text-[11px] text-text-sec">After 50% of break</div>
</div> </div>
<ToggleSwitch <ToggleSwitch
bind:checked={$config.allow_end_early} bind:checked={$config.allow_end_early}
@@ -528,12 +530,12 @@
/> />
</div> </div>
<div class="my-4 h-px bg-[#161616]"></div> <div class="my-4 h-px bg-border"></div>
<div class="flex items-center"> <div class="flex items-center">
<div class="flex-1"> <div class="flex-1">
<div class="text-[13px] text-white">Snooze duration</div> <div class="text-[13px] text-white">Snooze duration</div>
<div class="text-[11px] text-[#8a8a8a]"> <div class="text-[11px] text-text-sec">
{$config.snooze_duration} min {$config.snooze_duration} min
</div> </div>
</div> </div>
@@ -546,12 +548,12 @@
/> />
</div> </div>
<div class="my-4 h-px bg-[#161616]"></div> <div class="my-4 h-px bg-border"></div>
<div class="flex items-center"> <div class="flex items-center">
<div class="flex-1"> <div class="flex-1">
<div class="text-[13px] text-white">Snooze limit</div> <div class="text-[13px] text-white">Snooze limit</div>
<div class="text-[11px] text-[#8a8a8a]"> <div class="text-[11px] text-text-sec">
{$config.snooze_limit === 0 {$config.snooze_limit === 0
? "Unlimited" ? "Unlimited"
: `${$config.snooze_limit} per break`} : `${$config.snooze_limit} per break`}
@@ -568,12 +570,12 @@
</div> </div>
{/if} {/if}
<div class="my-4 h-px bg-[#161616]"></div> <div class="my-4 h-px bg-border"></div>
<div class="flex items-center"> <div class="flex items-center">
<div class="flex-1"> <div class="flex-1">
<div class="text-[13px] text-white">Immediate breaks</div> <div class="text-[13px] text-white">Immediate breaks</div>
<div class="text-[11px] text-[#8a8a8a]"> <div class="text-[11px] text-text-sec">
Skip pre-break warning Skip pre-break warning
</div> </div>
</div> </div>
@@ -586,15 +588,15 @@
</section> </section>
<!-- 8. Alerts (MERGED: Notifications + Pre-Break Nudge) --> <!-- 8. Alerts (MERGED: Notifications + Pre-Break Nudge) -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.21 }}> <section aria-labelledby="settings-alerts" class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.21 }}>
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase"> <h2 id="settings-alerts" class="mb-4 text-[11px] font-medium tracking-[0.15em] text-text-sec uppercase">
Alerts Alerts
</h3> </h2>
<div class="flex items-center"> <div class="flex items-center">
<div class="flex-1"> <div class="flex-1">
<div class="text-[13px] text-white">Pre-break alert</div> <div class="text-[13px] text-white">Pre-break alert</div>
<div class="text-[11px] text-[#8a8a8a]">Warn before breaks</div> <div class="text-[11px] text-text-sec">Warn before breaks</div>
</div> </div>
<ToggleSwitch <ToggleSwitch
bind:checked={$config.notification_enabled} bind:checked={$config.notification_enabled}
@@ -604,13 +606,13 @@
</div> </div>
{#if $config.notification_enabled} {#if $config.notification_enabled}
<div class="my-4 h-px bg-[#161616]"></div> <div class="my-4 h-px bg-border"></div>
<div class="flex items-center"> <div class="flex items-center">
<div class="flex-1"> <div class="flex-1">
<div class="text-[13px] text-white">Alert timing</div> <div class="text-[13px] text-white">Alert timing</div>
<div class="text-[11px] text-[#8a8a8a]"> <div class="text-[11px] text-text-sec">
{$config.notification_before_break}s before {$config.notification_before_break}<abbr title="seconds">s</abbr> before
</div> </div>
</div> </div>
<Stepper <Stepper
@@ -624,31 +626,31 @@
</div> </div>
{/if} {/if}
<div class="my-4 h-px bg-[#161616]"></div> <div class="my-4 h-px bg-border"></div>
<div class="flex items-center"> <div class="flex items-center">
<div class="flex-1"> <div class="flex-1">
<div class="text-[13px] text-white">Screen dimming</div> <div class="text-[13px] text-white">Screen dimming</div>
<div class="text-[11px] text-[#8a8a8a]">Gradually dim screen before breaks</div> <div class="text-[11px] text-text-sec">Gradually dim screen before breaks</div>
</div> </div>
<ToggleSwitch bind:checked={$config.screen_dim_enabled} label="Screen dimming" onchange={markChanged} /> <ToggleSwitch bind:checked={$config.screen_dim_enabled} label="Screen dimming" onchange={markChanged} />
</div> </div>
{#if $config.screen_dim_enabled} {#if $config.screen_dim_enabled}
<div class="my-4 h-px bg-[#161616]"></div> <div class="my-4 h-px bg-border"></div>
<div class="flex items-center"> <div class="flex items-center">
<div class="flex-1"> <div class="flex-1">
<div class="text-[13px] text-white">Start dimming</div> <div class="text-[13px] text-white">Start dimming</div>
<div class="text-[11px] text-[#8a8a8a]">{$config.screen_dim_seconds}s before break</div> <div class="text-[11px] text-text-sec">{$config.screen_dim_seconds}s before break</div>
</div> </div>
<Stepper bind:value={$config.screen_dim_seconds} label="Dim start" min={3} max={60} onchange={markChanged} /> <Stepper bind:value={$config.screen_dim_seconds} label="Dim start" min={3} max={60} onchange={markChanged} />
</div> </div>
<div class="my-4 h-px bg-[#161616]"></div> <div class="my-4 h-px bg-border"></div>
<div class="flex items-center"> <div class="flex items-center">
<div class="flex-1"> <div class="flex-1">
<div class="text-[13px] text-white">Max dimming</div> <div class="text-[13px] text-white">Max dimming</div>
<div class="text-[11px] text-[#8a8a8a]">{Math.round($config.screen_dim_max_opacity * 100)}%</div> <div class="text-[11px] text-text-sec">{Math.round($config.screen_dim_max_opacity * 100)}%</div>
</div> </div>
<Stepper <Stepper
bind:value={$config.screen_dim_max_opacity} bind:value={$config.screen_dim_max_opacity}
@@ -664,15 +666,15 @@
</section> </section>
<!-- 9. Sound --> <!-- 9. Sound -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.24 }}> <section aria-labelledby="settings-sound" class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.24 }}>
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase"> <h2 id="settings-sound" class="mb-4 text-[11px] font-medium tracking-[0.15em] text-text-sec uppercase">
Sound Sound
</h3> </h2>
<div class="flex items-center"> <div class="flex items-center">
<div class="flex-1"> <div class="flex-1">
<div class="text-[13px] text-white">Sound effects</div> <div class="text-[13px] text-white">Sound effects</div>
<div class="text-[11px] text-[#8a8a8a]">Play sounds on break events</div> <div class="text-[11px] text-text-sec">Play sounds on break events</div>
</div> </div>
<ToggleSwitch <ToggleSwitch
bind:checked={$config.sound_enabled} bind:checked={$config.sound_enabled}
@@ -682,12 +684,12 @@
</div> </div>
{#if $config.sound_enabled} {#if $config.sound_enabled}
<div class="my-4 h-px bg-[#161616]"></div> <div class="my-4 h-px bg-border"></div>
<div class="flex items-center"> <div class="flex items-center">
<div class="flex-1"> <div class="flex-1">
<div class="text-[13px] text-white">Volume</div> <div class="text-[13px] text-white">Volume</div>
<div class="text-[11px] text-[#8a8a8a]">{$config.sound_volume}%</div> <div class="text-[11px] text-text-sec">{$config.sound_volume}%</div>
</div> </div>
<Stepper <Stepper
bind:value={$config.sound_volume} bind:value={$config.sound_volume}
@@ -700,7 +702,7 @@
/> />
</div> </div>
<div class="my-4 h-px bg-[#161616]"></div> <div class="my-4 h-px bg-border"></div>
<div> <div>
<div class="mb-3 text-[13px] text-white">Sound preset</div> <div class="mb-3 text-[13px] text-white">Sound preset</div>
@@ -708,11 +710,12 @@
{#each soundPresets as preset} {#each soundPresets as preset}
<button <button
use:pressable use:pressable
aria-pressed={$config.sound_preset === preset}
class="rounded-xl py-2.5 text-[11px] tracking-wider uppercase class="rounded-xl py-2.5 text-[11px] tracking-wider uppercase
transition-all duration-200 transition-all duration-200
{$config.sound_preset === preset {$config.sound_preset === preset
? 'bg-[#1a1a1a] text-white border border-[#333]' ? 'bg-[#1a1a1a] text-white border border-[#333]'
: 'bg-[#0a0a0a] text-[#8a8a8a] border border-[#161616] hover:border-[#333] hover:text-white'}" : 'bg-[#0a0a0a] text-text-sec border border-border hover:border-[#333] hover:text-white'}"
onclick={() => { onclick={() => {
$config.sound_preset = preset; $config.sound_preset = preset;
markChanged(); markChanged();
@@ -728,15 +731,15 @@
</section> </section>
<!-- 10. Idle & Smart Breaks (MERGED) --> <!-- 10. Idle & Smart Breaks (MERGED) -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.27 }}> <section aria-labelledby="settings-idle" class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.27 }}>
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase"> <h2 id="settings-idle" class="mb-4 text-[11px] font-medium tracking-[0.15em] text-text-sec uppercase">
Idle & Smart Breaks Idle & Smart Breaks
</h3> </h2>
<div class="flex items-center"> <div class="flex items-center">
<div class="flex-1"> <div class="flex-1">
<div class="text-[13px] text-white">Auto-pause when idle</div> <div class="text-[13px] text-white">Auto-pause when idle</div>
<div class="text-[11px] text-[#8a8a8a]">Pause timer when away</div> <div class="text-[11px] text-text-sec">Pause timer when away</div>
</div> </div>
<ToggleSwitch <ToggleSwitch
bind:checked={$config.idle_detection_enabled} bind:checked={$config.idle_detection_enabled}
@@ -746,12 +749,12 @@
</div> </div>
{#if $config.idle_detection_enabled} {#if $config.idle_detection_enabled}
<div class="my-4 h-px bg-[#161616]"></div> <div class="my-4 h-px bg-border"></div>
<div class="flex items-center"> <div class="flex items-center">
<div class="flex-1"> <div class="flex-1">
<div class="text-[13px] text-white">Idle timeout</div> <div class="text-[13px] text-white">Idle timeout</div>
<div class="text-[11px] text-[#8a8a8a]"> <div class="text-[11px] text-text-sec">
{$config.idle_timeout}s of inactivity {$config.idle_timeout}s of inactivity
</div> </div>
</div> </div>
@@ -767,12 +770,12 @@
</div> </div>
{/if} {/if}
<div class="my-4 h-px bg-[#161616]"></div> <div class="my-4 h-px bg-border"></div>
<div class="flex items-center"> <div class="flex items-center">
<div class="flex-1"> <div class="flex-1">
<div class="text-[13px] text-white">Smart breaks</div> <div class="text-[13px] text-white">Smart breaks</div>
<div class="text-[11px] text-[#8a8a8a]">Auto-reset timer when you step away</div> <div class="text-[11px] text-text-sec">Auto-reset timer when you step away</div>
</div> </div>
<ToggleSwitch <ToggleSwitch
bind:checked={$config.smart_breaks_enabled} bind:checked={$config.smart_breaks_enabled}
@@ -782,12 +785,12 @@
</div> </div>
{#if $config.smart_breaks_enabled} {#if $config.smart_breaks_enabled}
<div class="my-4 h-px bg-[#161616]"></div> <div class="my-4 h-px bg-border"></div>
<div class="flex items-center"> <div class="flex items-center">
<div class="flex-1"> <div class="flex-1">
<div class="text-[13px] text-white">Minimum away time</div> <div class="text-[13px] text-white">Minimum away time</div>
<div class="text-[11px] text-[#8a8a8a]"> <div class="text-[11px] text-text-sec">
{$config.smart_break_threshold >= 60 {$config.smart_break_threshold >= 60
? `${Math.floor($config.smart_break_threshold / 60)} min` ? `${Math.floor($config.smart_break_threshold / 60)} min`
: `${$config.smart_break_threshold}s`} to count as break : `${$config.smart_break_threshold}s`} to count as break
@@ -804,12 +807,12 @@
/> />
</div> </div>
<div class="my-4 h-px bg-[#161616]"></div> <div class="my-4 h-px bg-border"></div>
<div class="flex items-center"> <div class="flex items-center">
<div class="flex-1"> <div class="flex-1">
<div class="text-[13px] text-white">Count in statistics</div> <div class="text-[13px] text-white">Count in statistics</div>
<div class="text-[11px] text-[#8a8a8a]">Track natural breaks in stats</div> <div class="text-[11px] text-text-sec">Track natural breaks in stats</div>
</div> </div>
<ToggleSwitch <ToggleSwitch
bind:checked={$config.smart_break_count_stats} bind:checked={$config.smart_break_count_stats}
@@ -821,36 +824,36 @@
</section> </section>
<!-- 11. Presentation Mode --> <!-- 11. Presentation Mode -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.30 }}> <section aria-labelledby="settings-presentation" class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.30 }}>
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase"> <h2 id="settings-presentation" class="mb-4 text-[11px] font-medium tracking-[0.15em] text-text-sec uppercase">
Presentation Mode Presentation Mode
</h3> </h2>
<div class="flex items-center"> <div class="flex items-center">
<div class="flex-1"> <div class="flex-1">
<div class="text-[13px] text-white">Auto-detect fullscreen</div> <div class="text-[13px] text-white">Auto-detect fullscreen</div>
<div class="text-[11px] text-[#8a8a8a]">Defer breaks during fullscreen apps</div> <div class="text-[11px] text-text-sec">Defer breaks during fullscreen apps</div>
</div> </div>
<ToggleSwitch bind:checked={$config.presentation_mode_enabled} label="Presentation mode" onchange={markChanged} /> <ToggleSwitch bind:checked={$config.presentation_mode_enabled} label="Presentation mode" onchange={markChanged} />
</div> </div>
{#if $config.presentation_mode_enabled} {#if $config.presentation_mode_enabled}
{#if $config.microbreak_enabled} {#if $config.microbreak_enabled}
<div class="my-4 h-px bg-[#161616]"></div> <div class="my-4 h-px bg-border"></div>
<div class="flex items-center"> <div class="flex items-center">
<div class="flex-1"> <div class="flex-1">
<div class="text-[13px] text-white">Defer microbreaks</div> <div class="text-[13px] text-white">Defer microbreaks</div>
<div class="text-[11px] text-[#8a8a8a]">Also pause eye breaks</div> <div class="text-[11px] text-text-sec">Also pause eye breaks</div>
</div> </div>
<ToggleSwitch bind:checked={$config.presentation_mode_defer_microbreaks} label="Defer microbreaks" onchange={markChanged} /> <ToggleSwitch bind:checked={$config.presentation_mode_defer_microbreaks} label="Defer microbreaks" onchange={markChanged} />
</div> </div>
{/if} {/if}
<div class="my-4 h-px bg-[#161616]"></div> <div class="my-4 h-px bg-border"></div>
<div class="flex items-center"> <div class="flex items-center">
<div class="flex-1"> <div class="flex-1">
<div class="text-[13px] text-white">Notification</div> <div class="text-[13px] text-white">Notification</div>
<div class="text-[11px] text-[#8a8a8a]">Show toast when break is deferred</div> <div class="text-[11px] text-text-sec">Show toast when break is deferred</div>
</div> </div>
<ToggleSwitch bind:checked={$config.presentation_mode_notification} label="Deferral notification" onchange={markChanged} /> <ToggleSwitch bind:checked={$config.presentation_mode_notification} label="Deferral notification" onchange={markChanged} />
</div> </div>
@@ -858,59 +861,59 @@
</section> </section>
<!-- 12. Goals & Streaks --> <!-- 12. Goals & Streaks -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.33 }}> <section aria-labelledby="settings-goals" class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.33 }}>
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase"> <h2 id="settings-goals" class="mb-4 text-[11px] font-medium tracking-[0.15em] text-text-sec uppercase">
Goals & Streaks Goals & Streaks
</h3> </h2>
<div class="flex items-center"> <div class="flex items-center">
<div class="flex-1"> <div class="flex-1">
<div class="text-[13px] text-white">Daily goal</div> <div class="text-[13px] text-white">Daily goal</div>
<div class="text-[11px] text-[#8a8a8a]">Track daily break target</div> <div class="text-[11px] text-text-sec">Track daily break target</div>
</div> </div>
<ToggleSwitch bind:checked={$config.daily_goal_enabled} label="Daily goal" onchange={markChanged} /> <ToggleSwitch bind:checked={$config.daily_goal_enabled} label="Daily goal" onchange={markChanged} />
</div> </div>
{#if $config.daily_goal_enabled} {#if $config.daily_goal_enabled}
<div class="my-4 h-px bg-[#161616]"></div> <div class="my-4 h-px bg-border"></div>
<div class="flex items-center"> <div class="flex items-center">
<div class="flex-1"> <div class="flex-1">
<div class="text-[13px] text-white">Target breaks</div> <div class="text-[13px] text-white">Target breaks</div>
<div class="text-[11px] text-[#8a8a8a]">{$config.daily_goal_breaks} per day</div> <div class="text-[11px] text-text-sec">{$config.daily_goal_breaks} per day</div>
</div> </div>
<Stepper bind:value={$config.daily_goal_breaks} label="Daily goal breaks" min={1} max={30} onchange={markChanged} /> <Stepper bind:value={$config.daily_goal_breaks} label="Daily goal breaks" min={1} max={30} onchange={markChanged} />
</div> </div>
{/if} {/if}
<div class="my-4 h-px bg-[#161616]"></div> <div class="my-4 h-px bg-border"></div>
<div class="flex items-center"> <div class="flex items-center">
<div class="flex-1"> <div class="flex-1">
<div class="text-[13px] text-white">Celebrations</div> <div class="text-[13px] text-white">Celebrations</div>
<div class="text-[11px] text-[#8a8a8a]">Confetti on milestones and goals</div> <div class="text-[11px] text-text-sec">Confetti on milestones and goals</div>
</div> </div>
<ToggleSwitch bind:checked={$config.milestone_celebrations} label="Celebrations" onchange={markChanged} /> <ToggleSwitch bind:checked={$config.milestone_celebrations} label="Celebrations" onchange={markChanged} />
</div> </div>
<div class="my-4 h-px bg-[#161616]"></div> <div class="my-4 h-px bg-border"></div>
<div class="flex items-center"> <div class="flex items-center">
<div class="flex-1"> <div class="flex-1">
<div class="text-[13px] text-white">Streak notifications</div> <div class="text-[13px] text-white">Streak notifications</div>
<div class="text-[11px] text-[#8a8a8a]">Toast on streak milestones</div> <div class="text-[11px] text-text-sec">Toast on streak milestones</div>
</div> </div>
<ToggleSwitch bind:checked={$config.streak_notifications} label="Streak notifications" onchange={markChanged} /> <ToggleSwitch bind:checked={$config.streak_notifications} label="Streak notifications" onchange={markChanged} />
</div> </div>
</section> </section>
<!-- 13. Appearance --> <!-- 13. Appearance -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.36 }}> <section aria-labelledby="settings-appearance" class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.36 }}>
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase"> <h2 id="settings-appearance" class="mb-4 text-[11px] font-medium tracking-[0.15em] text-text-sec uppercase">
Appearance Appearance
</h3> </h2>
<div class="flex items-center"> <div class="flex items-center">
<div class="flex-1"> <div class="flex-1">
<div class="text-[13px] text-white">UI zoom</div> <div class="text-[13px] text-white">UI zoom</div>
<div class="text-[11px] text-[#8a8a8a]"> <div class="text-[11px] text-text-sec">
{$config.ui_zoom}% {$config.ui_zoom}%
</div> </div>
</div> </div>
@@ -925,7 +928,7 @@
/> />
</div> </div>
<div class="my-4 h-px bg-[#161616]"></div> <div class="my-4 h-px bg-border"></div>
<ColorPicker <ColorPicker
label="Accent color" label="Accent color"
@@ -933,7 +936,7 @@
onchange={markChanged} onchange={markChanged}
/> />
<div class="my-4 h-px bg-[#161616]"></div> <div class="my-4 h-px bg-border"></div>
<ColorPicker <ColorPicker
label="Break screen color" label="Break screen color"
@@ -947,19 +950,19 @@
onchange={markChanged} onchange={markChanged}
/> />
<div class="my-4 h-px bg-[#161616]"></div> <div class="my-4 h-px bg-border"></div>
<FontSelector <FontSelector
bind:value={$config.countdown_font} bind:value={$config.countdown_font}
onchange={markChanged} onchange={markChanged}
/> />
<div class="my-4 h-px bg-[#161616]"></div> <div class="my-4 h-px bg-border"></div>
<div class="flex items-center"> <div class="flex items-center">
<div class="flex-1"> <div class="flex-1">
<div class="text-[13px] text-white">Animated background</div> <div class="text-[13px] text-white">Animated background</div>
<div class="text-[11px] text-[#8a8a8a]"> <div class="text-[11px] text-text-sec">
Gradient blobs with film grain Gradient blobs with film grain
</div> </div>
</div> </div>
@@ -972,11 +975,15 @@
</section> </section>
<!-- 14. Working Hours --> <!-- 14. Working Hours -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.39 }}> <section aria-labelledby="settings-workinghours" class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.39 }}>
<h2 id="settings-workinghours" class="mb-4 text-[11px] font-medium tracking-[0.15em] text-text-sec uppercase">
Working Hours
</h2>
<div class="flex items-center"> <div class="flex items-center">
<div class="flex-1"> <div class="flex-1">
<div class="text-[13px] text-white">Working hours</div> <div class="text-[13px] text-white">Working hours</div>
<div class="text-[11px] text-[#8a8a8a]"> <div class="text-[11px] text-text-sec">
Only show breaks during your configured work schedule Only show breaks during your configured work schedule
</div> </div>
</div> </div>
@@ -988,7 +995,7 @@
</div> </div>
{#if $config.working_hours_enabled} {#if $config.working_hours_enabled}
<div class="my-4 h-px bg-[#161616]"></div> <div class="my-4 h-px bg-border"></div>
{#each $config.working_hours_schedule as daySchedule, dayIndex} {#each $config.working_hours_schedule as daySchedule, dayIndex}
{@const dayName = daysOfWeek[dayIndex]} {@const dayName = daysOfWeek[dayIndex]}
@@ -1013,7 +1020,7 @@
countdownFont={$config.countdown_font} countdownFont={$config.countdown_font}
onchange={(v) => updateTimeRange(dayIndex, rangeIndex, "start", v)} onchange={(v) => updateTimeRange(dayIndex, rangeIndex, "start", v)}
/> />
<span class="text-[#8a8a8a] text-[13px]">to</span> <span class="text-text-sec text-[13px]">to</span>
<TimeSpinner <TimeSpinner
value={range.end} value={range.end}
countdownFont={$config.countdown_font} countdownFont={$config.countdown_font}
@@ -1024,7 +1031,7 @@
{#if rangeIndex === daySchedule.ranges.length - 1} {#if rangeIndex === daySchedule.ranges.length - 1}
<button <button
use:pressable use:pressable
class="ml-2 w-8 h-8 flex items-center justify-center rounded-full text-[#8a8a8a] hover:text-white hover:bg-[#1a1a1a] transition-colors" class="ml-2 w-8 h-8 flex items-center justify-center rounded-full text-text-sec hover:text-white hover:bg-[#1a1a1a] transition-colors"
onclick={() => addTimeRange(dayIndex)} onclick={() => addTimeRange(dayIndex)}
aria-label="Add time range" aria-label="Add time range"
> >
@@ -1037,7 +1044,7 @@
<!-- Clone button --> <!-- Clone button -->
<button <button
use:pressable use:pressable
class="w-8 h-8 flex items-center justify-center rounded-full text-[#8a8a8a] hover:text-white hover:bg-[#1a1a1a] transition-colors" class="w-8 h-8 flex items-center justify-center rounded-full text-text-sec hover:text-white hover:bg-[#1a1a1a] transition-colors"
onclick={() => cloneTimeRange(dayIndex, rangeIndex)} onclick={() => cloneTimeRange(dayIndex, rangeIndex)}
aria-label="Clone time range" aria-label="Clone time range"
> >
@@ -1051,7 +1058,7 @@
{#if rangeIndex > 0} {#if rangeIndex > 0}
<button <button
use:pressable use:pressable
class="w-8 h-8 flex items-center justify-center rounded-full text-[#8a8a8a] hover:text-[#f85149] hover:bg-[#f8514915] transition-colors" class="w-8 h-8 flex items-center justify-center rounded-full text-text-sec hover:text-[#ff6b6b] hover:bg-[#ff6b6b15] transition-colors"
onclick={() => removeTimeRange(dayIndex, rangeIndex)} onclick={() => removeTimeRange(dayIndex, rangeIndex)}
aria-label="Remove time range" aria-label="Remove time range"
> >
@@ -1067,22 +1074,22 @@
</div> </div>
{#if dayIndex < 6} {#if dayIndex < 6}
<div class="my-4 h-px bg-[#161616]"></div> <div class="my-4 h-px bg-border"></div>
{/if} {/if}
{/each} {/each}
{/if} {/if}
</section> </section>
<!-- 15. Mini Mode --> <!-- 15. Mini Mode -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.42 }}> <section aria-labelledby="settings-minimode" class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.42 }}>
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase"> <h2 id="settings-minimode" class="mb-4 text-[11px] font-medium tracking-[0.15em] text-text-sec uppercase">
Mini Mode Mini Mode
</h3> </h2>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<div class="text-[13px] text-white">Click-through</div> <div class="text-[13px] text-white">Click-through</div>
<div class="text-[11px] text-[#8a8a8a]"> <div class="text-[11px] text-text-sec">
Mini timer ignores clicks until you hover over it Mini timer ignores clicks until you hover over it
</div> </div>
</div> </div>
@@ -1097,7 +1104,7 @@
<div class="mt-4 flex items-center justify-between"> <div class="mt-4 flex items-center justify-between">
<div> <div>
<div class="text-[13px] text-white">Hover delay</div> <div class="text-[13px] text-white">Hover delay</div>
<div class="text-[11px] text-[#8a8a8a]"> <div class="text-[11px] text-text-sec">
Seconds to hover before it becomes draggable Seconds to hover before it becomes draggable
</div> </div>
</div> </div>
@@ -1115,15 +1122,15 @@
</section> </section>
<!-- 16. General --> <!-- 16. General -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.45 }}> <section aria-labelledby="settings-general" class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.45 }}>
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase"> <h2 id="settings-general" class="mb-4 text-[11px] font-medium tracking-[0.15em] text-text-sec uppercase">
General General
</h3> </h2>
<div class="flex items-center"> <div class="flex items-center">
<div class="flex-1"> <div class="flex-1">
<div class="text-[13px] text-white">Start on Windows login</div> <div class="text-[13px] text-white">Start on Windows login</div>
<div class="text-[11px] text-[#8a8a8a]">Launch automatically at startup</div> <div class="text-[11px] text-text-sec">Launch automatically at startup</div>
</div> </div>
<ToggleSwitch <ToggleSwitch
checked={autoStartEnabled} checked={autoStartEnabled}
@@ -1134,42 +1141,44 @@
</section> </section>
<!-- 17. Keyboard Shortcuts --> <!-- 17. Keyboard Shortcuts -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.48 }}> <section aria-labelledby="settings-shortcuts" class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.48 }}>
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase"> <h2 id="settings-shortcuts" class="mb-4 text-[11px] font-medium tracking-[0.15em] text-text-sec uppercase">
Keyboard Shortcuts Keyboard Shortcuts
</h3> </h2>
<div class="space-y-3"> <div class="space-y-3">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-[13px] text-white">Pause / Resume</span> <span class="text-[13px] text-white">Pause / Resume</span>
<kbd class="rounded-lg bg-[#161616] px-2.5 py-1 text-[11px] text-[#8a8a8a]">Ctrl+Shift+P</kbd> <kbd class="rounded-lg bg-border px-2.5 py-1 text-[11px] text-text-sec">Ctrl+Shift+P</kbd>
</div> </div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-[13px] text-white">Start break now</span> <span class="text-[13px] text-white">Start break now</span>
<kbd class="rounded-lg bg-[#161616] px-2.5 py-1 text-[11px] text-[#8a8a8a]">Ctrl+Shift+B</kbd> <kbd class="rounded-lg bg-border px-2.5 py-1 text-[11px] text-text-sec">Ctrl+Shift+B</kbd>
</div> </div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-[13px] text-white">Show / Hide window</span> <span class="text-[13px] text-white">Show / Hide window</span>
<kbd class="rounded-lg bg-[#161616] px-2.5 py-1 text-[11px] text-[#8a8a8a]">Ctrl+Shift+S</kbd> <kbd class="rounded-lg bg-border px-2.5 py-1 text-[11px] text-text-sec">Ctrl+Shift+S</kbd>
</div> </div>
</div> </div>
</section> </section>
<!-- 18. Reset --> <!-- 18. Reset -->
<div class="pt-2 pb-6" use:inView={{ delay: 0.51 }}> <section aria-labelledby="settings-reset" class="pt-2 pb-6" use:inView={{ delay: 0.51 }}>
<h2 id="settings-reset" class="sr-only">Reset</h2>
<button <button
use:pressable use:pressable
aria-live="polite"
class="w-full rounded-full border py-3 text-[12px] class="w-full rounded-full border py-3 text-[12px]
tracking-wider uppercase tracking-wider uppercase
transition-all duration-200 transition-all duration-200
{resetConfirming {resetConfirming
? 'border-[#f85149] text-[#f85149] hover:bg-[#f85149] hover:text-white' ? 'border-[#ff6b6b] text-[#ff6b6b] hover:bg-[#ff6b6b] hover:text-white'
: 'border-[#1a1a1a] text-[#8a8a8a] hover:border-[#333] hover:text-white'}" : 'border-[#1a1a1a] text-text-sec hover:border-[#333] hover:text-white'}"
onclick={handleReset} onclick={handleReset}
> >
{resetConfirming ? "Tap again to confirm reset" : "Reset to defaults"} {resetConfirming ? "Tap again to confirm reset" : "Reset to defaults"}
</button> </button>
</div> </section>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -156,7 +156,7 @@
// Day label — show every Nth for 30-day // Day label — show every Nth for 30-day
if (data.length <= 7 || i % 5 === 0) { if (data.length <= 7 || i % 5 === 0) {
ctx.fillStyle = "#8a8a8a"; ctx.fillStyle = "#a8a8a8";
ctx.font = "10px -apple-system, sans-serif"; ctx.font = "10px -apple-system, sans-serif";
ctx.textAlign = "center"; ctx.textAlign = "center";
const label = day.date.slice(5); const label = day.date.slice(5);
@@ -266,8 +266,8 @@
<button <button
aria-label="Back to dashboard" aria-label="Back to dashboard"
use:pressable use:pressable
class="mr-3 flex h-8 w-8 items-center justify-center rounded-full class="mr-3 flex h-10 w-10 min-h-[44px] min-w-[44px] items-center justify-center rounded-full
text-[#8a8a8a] transition-colors hover:text-white" text-text-sec transition-colors hover:text-white"
onclick={goBack} onclick={goBack}
> >
<svg <svg
@@ -294,14 +294,18 @@
</div> </div>
<!-- Tab navigation --> <!-- Tab navigation -->
<div class="flex gap-1 px-5 mb-3" use:fadeIn={{ duration: 0.3, y: 6 }}> <div class="flex gap-1 px-5 mb-3" role="tablist" aria-label="Statistics time range" use:fadeIn={{ duration: 0.3, y: 6 }}>
{#each [["today", "Today"], ["weekly", "Weekly"], ["monthly", "Monthly"]] as [tab, label]} {#each [["today", "Today"], ["weekly", "Weekly"], ["monthly", "Monthly"]] as [tab, label]}
<button <button
use:pressable use:pressable
class="rounded-lg px-4 py-1.5 text-[11px] tracking-wider uppercase transition-all duration-200 role="tab"
id="tab-{tab}"
aria-selected={activeTab === tab}
aria-controls="tabpanel-{tab}"
class="min-h-[44px] rounded-lg px-4 py-1.5 text-[11px] tracking-wider uppercase transition-all duration-200
{activeTab === tab {activeTab === tab
? 'bg-[#1a1a1a] text-white' ? 'bg-[#1a1a1a] text-white'
: 'text-[#8a8a8a] hover:text-white'}" : 'text-text-sec hover:text-white'}"
onclick={() => activeTab = tab as any} onclick={() => activeTab = tab as any}
> >
{label} {label}
@@ -314,18 +318,19 @@
<div class="space-y-3"> <div class="space-y-3">
{#if activeTab === "today"} {#if activeTab === "today"}
<div role="tabpanel" id="tabpanel-today" aria-labelledby="tab-today">
<!-- Today's summary --> <!-- Today's summary -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0 }}> <section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0 }}>
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase"> <h2 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-text-sec uppercase">
Today Today
</h3> </h2>
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
<div class="text-center"> <div class="text-center">
<div class="text-[28px] font-semibold text-white tabular-nums"> <div class="text-[28px] font-semibold text-white tabular-nums">
{stats?.todayCompleted ?? 0} {stats?.todayCompleted ?? 0}
</div> </div>
<div class="text-[11px] text-[#8a8a8a]">Breaks taken</div> <div class="text-[11px] text-text-sec">Breaks taken</div>
</div> </div>
<div class="text-center"> <div class="text-center">
<div class="text-[28px] font-semibold tabular-nums" <div class="text-[28px] font-semibold tabular-nums"
@@ -333,19 +338,19 @@
> >
{compliancePercent}% {compliancePercent}%
</div> </div>
<div class="text-[11px] text-[#8a8a8a]">Compliance</div> <div class="text-[11px] text-text-sec">Compliance</div>
</div> </div>
<div class="text-center"> <div class="text-center">
<div class="text-[28px] font-semibold text-white tabular-nums"> <div class="text-[28px] font-semibold text-white tabular-nums">
{breakTimeFormatted()} {breakTimeFormatted()}
</div> </div>
<div class="text-[11px] text-[#8a8a8a]">Break time</div> <div class="text-[11px] text-text-sec">Break time</div>
</div> </div>
<div class="text-center"> <div class="text-center">
<div class="text-[28px] font-semibold text-white tabular-nums"> <div class="text-[28px] font-semibold text-white tabular-nums">
{stats?.todaySkipped ?? 0} {stats?.todaySkipped ?? 0}
</div> </div>
<div class="text-[11px] text-[#8a8a8a]">Skipped</div> <div class="text-[11px] text-text-sec">Skipped</div>
</div> </div>
</div> </div>
</section> </section>
@@ -353,9 +358,9 @@
<!-- F10: Daily goal --> <!-- F10: Daily goal -->
{#if $config.daily_goal_enabled} {#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 }}> <section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.04 }}>
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase"> <h2 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-text-sec uppercase">
Daily Goal Daily Goal
</h3> </h2>
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<div class="relative w-16 h-16"> <div class="relative w-16 h-16">
<svg width="64" height="64" viewBox="0 0 64 64" style="transform: rotate(-90deg);"> <svg width="64" height="64" viewBox="0 0 64 64" style="transform: rotate(-90deg);">
@@ -375,7 +380,7 @@
<div class="text-[14px] text-white font-medium"> <div class="text-[14px] text-white font-medium">
{stats?.dailyGoalProgress ?? 0} / {$config.daily_goal_breaks} breaks {stats?.dailyGoalProgress ?? 0} / {$config.daily_goal_breaks} breaks
</div> </div>
<div class="text-[11px] text-[#8a8a8a]"> <div class="text-[11px] text-text-sec">
{stats?.dailyGoalMet ? "Goal reached!" : `${$config.daily_goal_breaks - (stats?.dailyGoalProgress ?? 0)} more to go`} {stats?.dailyGoalMet ? "Goal reached!" : `${$config.daily_goal_breaks - (stats?.dailyGoalProgress ?? 0)} more to go`}
</div> </div>
</div> </div>
@@ -385,26 +390,26 @@
<!-- Streak --> <!-- Streak -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.06 }}> <section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.06 }}>
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase"> <h2 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-text-sec uppercase">
Streak Streak
</h3> </h2>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<div class="text-[13px] text-white">Current streak</div> <div class="text-[13px] text-white">Current streak</div>
<div class="text-[11px] text-[#8a8a8a]">Consecutive days with breaks</div> <div class="text-[11px] text-text-sec">Consecutive days with breaks</div>
</div> </div>
<div class="text-[24px] font-semibold tabular-nums" style="color: {$config.accent_color}"> <div class="text-[24px] font-semibold tabular-nums" style="color: {$config.accent_color}">
{stats?.currentStreak ?? 0} {stats?.currentStreak ?? 0}
</div> </div>
</div> </div>
<div class="my-4 h-px bg-[#161616]"></div> <div class="my-4 h-px bg-border"></div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<div class="text-[13px] text-white">Best streak</div> <div class="text-[13px] text-white">Best streak</div>
<div class="text-[11px] text-[#8a8a8a]">All-time record</div> <div class="text-[11px] text-text-sec">All-time record</div>
</div> </div>
<div class="text-[24px] font-semibold text-white tabular-nums"> <div class="text-[24px] font-semibold text-white tabular-nums">
{stats?.bestStreak ?? 0} {stats?.bestStreak ?? 0}
@@ -413,13 +418,13 @@
<!-- F10: Next milestone --> <!-- F10: Next milestone -->
{#if nextMilestone()} {#if nextMilestone()}
<div class="my-4 h-px bg-[#161616]"></div> <div class="my-4 h-px bg-border"></div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<div class="text-[13px] text-white">Next milestone</div> <div class="text-[13px] text-white">Next milestone</div>
<div class="text-[11px] text-[#8a8a8a]">{nextMilestone()} day streak</div> <div class="text-[11px] text-text-sec">{nextMilestone()} day streak</div>
</div> </div>
<div class="text-[13px] text-[#8a8a8a] tabular-nums"> <div class="text-[13px] text-text-sec tabular-nums">
{nextMilestone()! - (stats?.currentStreak ?? 0)} days away {nextMilestone()! - (stats?.currentStreak ?? 0)} days away
</div> </div>
</div> </div>
@@ -428,9 +433,9 @@
<!-- Weekly chart --> <!-- Weekly chart -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.12 }}> <section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.12 }}>
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase"> <h2 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-text-sec uppercase">
Last 7 Days Last 7 Days
</h3> </h2>
<!-- svelte-ignore a11y_no_interactive_element_to_noninteractive_role --> <!-- svelte-ignore a11y_no_interactive_element_to_noninteractive_role -->
<canvas <canvas
@@ -458,7 +463,7 @@
</table> </table>
{/if} {/if}
<div class="mt-3 flex items-center justify-center gap-4 text-[10px] text-[#8a8a8a]"> <div class="mt-3 flex items-center justify-center gap-4 text-[10px] text-text-sec">
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
<div class="h-2 w-2 rounded-sm" style="background: {$config.accent_color}"></div> <div class="h-2 w-2 rounded-sm" style="background: {$config.accent_color}"></div>
Completed Completed
@@ -470,38 +475,40 @@
</div> </div>
</section> </section>
</div>
{:else if activeTab === "weekly"} {:else if activeTab === "weekly"}
<div role="tabpanel" id="tabpanel-weekly" aria-labelledby="tab-weekly">
<!-- Weekly summaries --> <!-- Weekly summaries -->
{#each weeklySummaries as week, i} {#each weeklySummaries as week, i}
{@const prevWeek = weeklySummaries[i + 1]} {@const prevWeek = weeklySummaries[i + 1]}
{@const trend = prevWeek ? week.complianceRate - prevWeek.complianceRate : 0} {@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 }}> <section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: i * 0.06 }}>
<h3 class="mb-3 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase"> <h2 class="mb-3 text-[11px] font-medium tracking-[0.15em] text-text-sec uppercase">
Week of {week.weekStart} Week of {week.weekStart}
</h3> </h2>
<div class="grid grid-cols-3 gap-3 mb-3"> <div class="grid grid-cols-3 gap-3 mb-3">
<div class="text-center"> <div class="text-center">
<div class="text-[22px] font-semibold text-white tabular-nums">{week.totalCompleted}</div> <div class="text-[22px] font-semibold text-white tabular-nums">{week.totalCompleted}</div>
<div class="text-[10px] text-[#8a8a8a]">Completed</div> <div class="text-[10px] text-text-sec">Completed</div>
</div> </div>
<div class="text-center"> <div class="text-center">
<div class="text-[22px] font-semibold text-white tabular-nums">{week.totalSkipped}</div> <div class="text-[22px] font-semibold text-white tabular-nums">{week.totalSkipped}</div>
<div class="text-[10px] text-[#8a8a8a]">Skipped</div> <div class="text-[10px] text-text-sec">Skipped</div>
</div> </div>
<div class="text-center"> <div class="text-center">
<div class="text-[22px] font-semibold tabular-nums" style="color: {$config.accent_color}"> <div class="text-[22px] font-semibold tabular-nums" style="color: {$config.accent_color}">
{Math.round(week.complianceRate * 100)}% {Math.round(week.complianceRate * 100)}%
</div> </div>
<div class="text-[10px] text-[#8a8a8a]">Compliance</div> <div class="text-[10px] text-text-sec">Compliance</div>
</div> </div>
</div> </div>
<div class="flex items-center justify-between text-[11px]"> <div class="flex items-center justify-between text-[11px]">
<span class="text-[#8a8a8a]">Avg {week.avgDailyCompleted.toFixed(1)} breaks/day</span> <span class="text-text-sec">Avg {week.avgDailyCompleted.toFixed(1)} breaks/day</span>
{#if prevWeek} {#if prevWeek}
<span class="flex items-center gap-1" <span class="flex items-center gap-1"
style="color: {trend > 0 ? '#3fb950' : trend < 0 ? '#f85149' : '#8a8a8a'};" style="color: {trend > 0 ? '#3fb950' : trend < 0 ? '#ff6b6b' : '#a8a8a8'};"
> >
{#if trend > 0} {#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> <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M18 15l-6-6-6 6"/></svg>
@@ -517,12 +524,14 @@
</section> </section>
{/each} {/each}
</div>
{:else} {:else}
<div role="tabpanel" id="tabpanel-monthly" aria-labelledby="tab-monthly">
<!-- Monthly: 30-day chart --> <!-- 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 }}> <section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0 }}>
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase"> <h2 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-text-sec uppercase">
Last 30 Days Last 30 Days
</h3> </h2>
<!-- svelte-ignore a11y_no_interactive_element_to_noninteractive_role --> <!-- svelte-ignore a11y_no_interactive_element_to_noninteractive_role -->
<canvas <canvas
@@ -532,7 +541,21 @@
aria-label="30-day break history chart" aria-label="30-day break history chart"
></canvas> ></canvas>
<div class="mt-3 flex items-center justify-center gap-4 text-[10px] text-[#8a8a8a]"> {#if monthHistory.length > 0}
<table class="sr-only">
<caption>Break history for the last {monthHistory.length} days</caption>
<thead>
<tr><th>Date</th><th>Completed</th><th>Skipped</th></tr>
</thead>
<tbody>
{#each monthHistory as day}
<tr><td>{day.date}</td><td>{day.breaksCompleted}</td><td>{day.breaksSkipped}</td></tr>
{/each}
</tbody>
</table>
{/if}
<div class="mt-3 flex items-center justify-center gap-4 text-[10px] text-text-sec">
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
<div class="h-2 w-2 rounded-sm" style="background: {$config.accent_color}"></div> <div class="h-2 w-2 rounded-sm" style="background: {$config.accent_color}"></div>
Completed Completed
@@ -546,9 +569,9 @@
<!-- Heatmap --> <!-- Heatmap -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.06 }}> <section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.06 }}>
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase"> <h2 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-text-sec uppercase">
Activity Heatmap Activity Heatmap
</h3> </h2>
<div class="flex justify-center"> <div class="flex justify-center">
<!-- svelte-ignore a11y_no_interactive_element_to_noninteractive_role --> <!-- svelte-ignore a11y_no_interactive_element_to_noninteractive_role -->
@@ -559,7 +582,21 @@
></canvas> ></canvas>
</div> </div>
<div class="mt-3 flex items-center justify-center gap-2 text-[10px] text-[#8a8a8a]"> {#if monthHistory.length > 0}
<table class="sr-only">
<caption>Activity heatmap for the last {monthHistory.length} days</caption>
<thead>
<tr><th>Date</th><th>Breaks completed</th></tr>
</thead>
<tbody>
{#each monthHistory as day}
<tr><td>{day.date}</td><td>{day.breaksCompleted}</td></tr>
{/each}
</tbody>
</table>
{/if}
<div class="mt-3 flex items-center justify-center gap-2 text-[10px] text-text-sec">
<span>Less</span> <span>Less</span>
<div class="flex gap-1"> <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: #161616;"></div>
@@ -574,35 +611,36 @@
<!-- Monthly totals --> <!-- Monthly totals -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.12 }}> <section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.12 }}>
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase"> <h2 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-text-sec uppercase">
Monthly Summary Monthly Summary
</h3> </h2>
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
<div class="text-center"> <div class="text-center">
<div class="text-[22px] font-semibold text-white tabular-nums">{monthTotalCompleted}</div> <div class="text-[22px] font-semibold text-white tabular-nums">{monthTotalCompleted}</div>
<div class="text-[10px] text-[#8a8a8a]">Total breaks</div> <div class="text-[10px] text-text-sec">Total breaks</div>
</div> </div>
<div class="text-center"> <div class="text-center">
<div class="text-[22px] font-semibold tabular-nums" style="color: {$config.accent_color}"> <div class="text-[22px] font-semibold tabular-nums" style="color: {$config.accent_color}">
{monthAvgCompliance()}% {monthAvgCompliance()}%
</div> </div>
<div class="text-[10px] text-[#8a8a8a]">Avg compliance</div> <div class="text-[10px] text-text-sec">Avg compliance</div>
</div> </div>
<div class="text-center"> <div class="text-center">
<div class="text-[22px] font-semibold text-white tabular-nums"> <div class="text-[22px] font-semibold text-white tabular-nums">
{Math.floor(monthTotalTime / 60)} min {Math.floor(monthTotalTime / 60)} min
</div> </div>
<div class="text-[10px] text-[#8a8a8a]">Total break time</div> <div class="text-[10px] text-text-sec">Total break time</div>
</div> </div>
<div class="text-center"> <div class="text-center">
<div class="text-[22px] font-semibold text-white tabular-nums"> <div class="text-[22px] font-semibold text-white tabular-nums">
{(monthTotalCompleted / 30).toFixed(1)} {(monthTotalCompleted / 30).toFixed(1)}
</div> </div>
<div class="text-[10px] text-[#8a8a8a]">Avg daily breaks</div> <div class="text-[10px] text-text-sec">Avg daily breaks</div>
</div> </div>
</div> </div>
</section> </section>
</div>
{/if} {/if}
</div> </div>

View File

@@ -55,20 +55,34 @@
if (holdTimer) { clearTimeout(holdTimer); holdTimer = null; } if (holdTimer) { clearTimeout(holdTimer); holdTimer = null; }
if (holdInterval) { clearTimeout(holdInterval as unknown as ReturnType<typeof setTimeout>); holdInterval = null; } if (holdInterval) { clearTimeout(holdInterval as unknown as ReturnType<typeof setTimeout>); holdInterval = null; }
} }
function handleKeydown(fn: () => void, e: KeyboardEvent) {
if (["Enter", " "].includes(e.key)) {
e.preventDefault();
startHold(fn);
}
}
function handleKeyup(e: KeyboardEvent) {
if (["Enter", " "].includes(e.key)) {
stopHold();
}
}
</script> </script>
<div class="flex items-center gap-1.5" role="group" aria-label={label}> <div class="flex items-center gap-1.5" role="group" aria-label={label}>
<button <button
type="button" type="button"
aria-label="Decrease" aria-label="Decrease"
class="flex h-7 w-7 items-center justify-center rounded-lg class="flex h-9 w-9 min-h-[44px] min-w-[44px] items-center justify-center rounded-lg
bg-[#141414] text-[#8a8a8a] transition-colors border border-[#3a3a3a] bg-[#1a1a1a] text-text-sec transition-colors
hover:bg-[#1c1c1c] hover:text-white hover:bg-[#1c1c1c] hover:text-white
disabled:opacity-20" disabled:opacity-20"
onmousedown={() => startHold(decrement)} onmousedown={() => startHold(decrement)}
onmouseup={stopHold} onmouseup={stopHold}
onmouseleave={stopHold} onmouseleave={stopHold}
onclick={(e) => { if (e.detail === 0) decrement(); }} onkeydown={(e) => handleKeydown(decrement, e)}
onkeyup={handleKeyup}
disabled={value <= min} disabled={value <= min}
> >
&minus; &minus;
@@ -79,14 +93,15 @@
<button <button
type="button" type="button"
aria-label="Increase" aria-label="Increase"
class="flex h-7 w-7 items-center justify-center rounded-lg class="flex h-9 w-9 min-h-[44px] min-w-[44px] items-center justify-center rounded-lg
bg-[#141414] text-[#8a8a8a] transition-colors border border-[#3a3a3a] bg-[#1a1a1a] text-text-sec transition-colors
hover:bg-[#1c1c1c] hover:text-white hover:bg-[#1c1c1c] hover:text-white
disabled:opacity-20" disabled:opacity-20"
onmousedown={() => startHold(increment)} onmousedown={() => startHold(increment)}
onmouseup={stopHold} onmouseup={stopHold}
onmouseleave={stopHold} onmouseleave={stopHold}
onclick={(e) => { if (e.detail === 0) increment(); }} onkeydown={(e) => handleKeydown(increment, e)}
onkeyup={handleKeyup}
disabled={value >= max} disabled={value >= max}
> >
+ +

View File

@@ -5,7 +5,7 @@
</script> </script>
<!-- Invisible drag region traffic lights on the right --> <!-- Invisible drag region traffic lights on the right -->
<div <header
data-tauri-drag-region data-tauri-drag-region
class="group absolute top-0 left-0 right-0 z-50 flex h-10 items-center justify-end pr-3.5 select-none" class="group absolute top-0 left-0 right-0 z-50 flex h-10 items-center justify-end pr-3.5 select-none"
> >
@@ -20,15 +20,14 @@
</span> </span>
<!-- Traffic light buttons (order: maximize, minimize, close → close is rightmost) --> <!-- Traffic light buttons (order: maximize, minimize, close → close is rightmost) -->
<div class="flex items-center gap-[8px] opacity-10 transition-opacity duration-300 group-hover:opacity-100 group-focus-within:opacity-100"> <div class="flex items-center gap-0 opacity-10 transition-opacity duration-300 group-hover:opacity-100 group-focus-within:opacity-100">
<!-- Maximize (green) --> <!-- Maximize (green) -->
<button <button
aria-label="Maximize" aria-label="Maximize"
class="traffic-btn group/btn relative flex h-[15px] w-[15px] items-center justify-center class="group/btn relative flex h-[44px] w-[44px] items-center justify-center"
rounded-full bg-[#27C93F] transition-all duration-150
hover:brightness-110"
onclick={() => appWindow.toggleMaximize()} onclick={() => appWindow.toggleMaximize()}
> >
<span class="flex h-[20px] w-[20px] items-center justify-center rounded-full bg-[#27C93F] transition-all duration-150 group-hover/btn:brightness-110">
<svg <svg
class="absolute opacity-0 transition-opacity duration-150 group-hover/btn:opacity-100" class="absolute opacity-0 transition-opacity duration-150 group-hover/btn:opacity-100"
width="8" width="8"
@@ -40,16 +39,16 @@
<polyline points="7,3 7,1 5,1" stroke="#006500" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" fill="none" /> <polyline points="7,3 7,1 5,1" stroke="#006500" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" fill="none" />
<line x1="1" y1="7" x2="7" y2="1" stroke="#006500" stroke-width="1.2" stroke-linecap="round" /> <line x1="1" y1="7" x2="7" y2="1" stroke="#006500" stroke-width="1.2" stroke-linecap="round" />
</svg> </svg>
</span>
</button> </button>
<!-- Minimize (yellow) --> <!-- Minimize (yellow) -->
<button <button
aria-label="Minimize" aria-label="Minimize"
class="traffic-btn group/btn relative flex h-[15px] w-[15px] items-center justify-center class="group/btn relative flex h-[44px] w-[44px] items-center justify-center"
rounded-full bg-[#FFBD2E] transition-all duration-150
hover:brightness-110"
onclick={() => appWindow.minimize()} onclick={() => appWindow.minimize()}
> >
<span class="flex h-[20px] w-[20px] items-center justify-center rounded-full bg-[#FFBD2E] transition-all duration-150 group-hover/btn:brightness-110">
<svg <svg
class="absolute opacity-0 transition-opacity duration-150 group-hover/btn:opacity-100" class="absolute opacity-0 transition-opacity duration-150 group-hover/btn:opacity-100"
width="8" width="8"
@@ -59,16 +58,16 @@
> >
<line x1="1" y1="1" x2="7" y2="1" stroke="#995700" stroke-width="1.3" stroke-linecap="round" /> <line x1="1" y1="1" x2="7" y2="1" stroke="#995700" stroke-width="1.3" stroke-linecap="round" />
</svg> </svg>
</span>
</button> </button>
<!-- Close (red) — rightmost --> <!-- Close (red) — rightmost -->
<button <button
aria-label="Close" aria-label="Close"
class="traffic-btn group/btn relative flex h-[15px] w-[15px] items-center justify-center class="group/btn relative flex h-[44px] w-[44px] items-center justify-center"
rounded-full bg-[#FF5F57] transition-all duration-150
hover:brightness-110"
onclick={() => appWindow.close()} onclick={() => appWindow.close()}
> >
<span class="flex h-[20px] w-[20px] items-center justify-center rounded-full bg-[#FF5F57] transition-all duration-150 group-hover/btn:brightness-110">
<svg <svg
class="absolute opacity-0 transition-opacity duration-150 group-hover/btn:opacity-100" class="absolute opacity-0 transition-opacity duration-150 group-hover/btn:opacity-100"
width="8" width="8"
@@ -79,6 +78,7 @@
<line x1="1.5" y1="1.5" x2="6.5" y2="6.5" stroke="#4a0002" stroke-width="1.3" stroke-linecap="round" /> <line x1="1.5" y1="1.5" x2="6.5" y2="6.5" stroke="#4a0002" stroke-width="1.3" stroke-linecap="round" />
<line x1="6.5" y1="1.5" x2="1.5" y2="6.5" stroke="#4a0002" stroke-width="1.3" stroke-linecap="round" /> <line x1="6.5" y1="1.5" x2="1.5" y2="6.5" stroke="#4a0002" stroke-width="1.3" stroke-linecap="round" />
</svg> </svg>
</span>
</button> </button>
</div> </div>
</div> </header>

View File

@@ -15,19 +15,24 @@
} }
</script> </script>
<!-- 44px hit area wrapper (WCAG 2.5.8) with compact visual toggle inside -->
<button <button
type="button" type="button"
role="switch" role="switch"
aria-label={label} aria-label={label}
aria-checked={checked} aria-checked={checked}
class="relative inline-flex h-[24px] w-[48px] shrink-0 cursor-pointer rounded-full class="relative inline-flex min-h-[44px] min-w-[52px] shrink-0 cursor-pointer items-center justify-center
transition-colors duration-200 ease-in-out" bg-transparent border-none p-0"
style="background: {checked ? $config.accent_color : '#1a1a1a'};"
onclick={toggle} onclick={toggle}
> >
<span <span
class="pointer-events-none inline-block h-[19px] w-[19px] rounded-full class="inline-flex h-[28px] w-[52px] items-center rounded-full transition-colors duration-200 ease-in-out"
style="background: {checked ? $config.accent_color : '#1a1a1a'};"
>
<span
class="pointer-events-none inline-block h-[22px] w-[22px] rounded-full
shadow-sm transition-transform duration-200 ease-in-out shadow-sm transition-transform duration-200 ease-in-out
{checked ? 'translate-x-[26px] bg-white' : 'translate-x-[3px] bg-[#444]'} mt-[2.5px]" {checked ? 'translate-x-[27px] bg-white' : 'translate-x-[3px] bg-[#666]'}"
></span> ></span>
</span>
</button> </button>

View File

@@ -140,15 +140,32 @@ export function pressable(node: HTMLElement) {
); );
} }
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onDown();
}
}
function onKeyUp(e: KeyboardEvent) {
if (e.key === "Enter" || e.key === " ") {
onUp();
}
}
node.addEventListener("mousedown", onDown); node.addEventListener("mousedown", onDown);
node.addEventListener("mouseup", onUp); node.addEventListener("mouseup", onUp);
node.addEventListener("mouseleave", onUp); node.addEventListener("mouseleave", onUp);
node.addEventListener("keydown", onKeyDown);
node.addEventListener("keyup", onKeyUp);
return { return {
destroy() { destroy() {
node.removeEventListener("mousedown", onDown); node.removeEventListener("mousedown", onDown);
node.removeEventListener("mouseup", onUp); node.removeEventListener("mouseup", onUp);
node.removeEventListener("mouseleave", onUp); node.removeEventListener("mouseleave", onUp);
node.removeEventListener("keydown", onKeyDown);
node.removeEventListener("keyup", onKeyUp);
active?.cancel(); active?.cancel();
}, },
}; };
@@ -200,6 +217,8 @@ export function glowHover(
node.addEventListener("mouseenter", onEnter); node.addEventListener("mouseenter", onEnter);
node.addEventListener("mouseleave", onLeave); node.addEventListener("mouseleave", onLeave);
node.addEventListener("focusin", onEnter);
node.addEventListener("focusout", onLeave);
return { return {
update(newOptions?: { color?: string }) { update(newOptions?: { color?: string }) {
@@ -209,6 +228,8 @@ export function glowHover(
destroy() { destroy() {
node.removeEventListener("mouseenter", onEnter); node.removeEventListener("mouseenter", onEnter);
node.removeEventListener("mouseleave", onLeave); node.removeEventListener("mouseleave", onLeave);
node.removeEventListener("focusin", onEnter);
node.removeEventListener("focusout", onLeave);
enterAnim?.cancel(); enterAnim?.cancel();
leaveAnim?.cancel(); leaveAnim?.cancel();
}, },