29 Commits

Author SHA1 Message Date
lashman 167faf62b1 Update README.md 2026-03-09 16:27:37 +02:00
lashman 608fda61d2 Update README.md 2026-03-09 16:27:08 +02:00
lashman 7bb0fe572f Update README.md 2026-03-09 16:26:36 +02:00
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
lashman 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
Your Name a339dd1bb3 Add pomodoro, microbreaks, breathing guide, screen dimming, presentation mode, goals, multi-monitor, and activity manager
Major feature release (v0.1.3) adding 15 new features to the break timer:

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

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

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

Changes across 17 source files:
- Global focus-visible outlines, sr-only utility, forced-colors support
- prefers-reduced-motion kills all CSS animations AND JS Web Animations
- All text upgraded to 4.5:1+ contrast ratio (WCAG AA)
- Keyboard navigation for ColorPicker, Stepper, TimeSpinner
- Screen reader: aria-live status regions, progressbar roles, labeled
  controls, sr-only chart data table, focus management on view changes
- Focus trap on break screen, aria-hidden on decorative elements
- Descriptive labels on all 25+ toggle/stepper instances in Settings
- README updated with accessibility section and WCAG badge
2026-02-07 12:13:03 +02:00
lashman d5ad1514d1 Update README.md 2026-02-07 09:35:24 +00:00
lashman 925a7d5516 Update README.md 2026-02-07 09:34:36 +00:00
Your Name 87ab035c68 Update README - fix download links, activity count, and accuracy
- Fix download links to point to Gitea releases page (not GitHub-style relative URLs)
- Correct activity count from 70 to 72
- Enlarge break screen screenshot
- Add missing IPC entries (natural-break-detected event, cursor/window commands)
- Add msvc_compat.rs to backend modules table
- Update winapi description to include WebView2 check dialog
- Remove unused dirs crate from dependencies
2026-02-07 11:24:16 +02:00
Your Name 6b584efb40 Bump version to 0.1.1 2026-02-07 11:16:29 +02:00
Your Name b01dbd6c0b Tighten TimeSpinner spacing and fix build warnings
Reduce gap between numbers and h/m unit labels in the workday
schedule spinners. Remove unused parse_hour function and fix
Svelte state_referenced_locally warning in BreakScreen.
2026-02-07 11:16:29 +02:00
Your Name d93d231a45 Enable custom-protocol for embedded frontend assets
Without this feature, Tauri falls back to devUrl even in release
builds. The tauri CLI adds it automatically but direct cargo
builds need it in Cargo.toml.
2026-02-07 11:00:45 +02:00
Your Name 4f4599c4c9 Fix WebView2 detection - use loader API instead of registry
Registry check gave false negatives on systems where WebView2
is installed through Edge rather than EdgeUpdate. Now calls
GetAvailableCoreWebView2BrowserVersionString (statically linked)
which detects all installation methods.
2026-02-07 10:51:49 +02:00
Your Name e9021e51e5 Statically link WebView2Loader - single exe, no DLL needed
build.rs swaps the dynamic import library with the static archive
from webview2-com-sys, so the WebView2 loader code is baked into
the exe. msvc_compat.rs provides the MSVC CRT symbols (security
cookie, thread-safe init, C++ operators) that the MSVC-compiled
static library expects.
2026-02-07 02:32:54 +02:00
Your Name 37d0d638d5 Detect missing WebView2 at startup, show download link
Checks Windows registry for WebView2 Runtime before Tauri
initializes. If missing, shows a native MessageBox explaining
the requirement and opens the Microsoft download page.
2026-02-07 02:08:51 +02:00
Your Name 6bba2835bb Fix fullscreen break screen - centering, progress bar, ripple sizing
- Center break content on fullscreen (was top-left aligned)
- Move fullscreen progress bar to screen bottom (was overlapping buttons)
- Scale ripple circles to match ring size per mode (140/160/200px)
- Increase ripple expansion for more dramatic spread
2026-02-07 01:56:10 +02:00
Your Name 3aeb83f69b Add screenshots, move philosophy section above features 2026-02-07 01:36:43 +02:00
Your Name a441eeea32 Revamp README - emojis, better formatting, cleaner layout 2026-02-07 01:18:48 +02:00
Your Name 0cbd8abad4 Initial commit - Core Cooldown v0.1.0
Portable Windows break timer to prevent RSI and eye strain.
Tauri v2 + Svelte 5 + Tailwind CSS v4. No installer, no telemetry,
no data leaves the machine. CC0 public domain.
2026-02-07 01:12:49 +02:00
23 changed files with 1441 additions and 66 deletions
+27
View File
@@ -0,0 +1,27 @@
# Dependencies
node_modules/
# Build output
dist/
# Rust build artifacts
src-tauri/target/
# Generated Tauri schemas (auto-regenerated on build)
src-tauri/gen/schemas/
# Runtime portable data (created next to exe)
config.json
stats.json
data/
# OS files
Thumbs.db
.DS_Store
# Editor
.vscode/
.idea/
*.swp
*.swo
*~
+78
View File
@@ -0,0 +1,78 @@
# Changelog
## v0.1.3
### New Features
- **Pomodoro Mode** — Alternates short breaks with a longer recovery break after configurable focus sessions. Cycle indicator on dashboard, custom long break titles/messages, reset-on-skip option.
- **Microbreaks (20-20-20 Rule)** — Independent short eye breaks between main breaks. Subtle non-blocking overlay with activity suggestion and optional sound. Configurable frequency (5-60 min) and duration (10-60 sec).
- **Breathing Guide** — Visual breathing exercise during breaks with 5 patterns (Box, Relaxing, Energizing, Calm, Deep). Animated pulsing halo behind countdown ring with color interpolation between accent and break colors. Phase labels (Inhale/Hold/Exhale) with countdown.
- **Screen Dimming** — Gradual pre-break screen dimming. Configurable timing (3-60 sec before break) and maximum intensity (10-70%).
- **Presentation Mode** — Detects fullscreen applications and defers breaks until exit. Optional microbreak deferral and toast notification when breaks are deferred.
- **Goals & Streaks** — Daily break target with progress indicator inside dashboard ring. Confetti celebrations on milestones and goal completion. Streak tracking with toast notifications.
- **Multi-Monitor Breaks** — Fullscreen break overlay spans all connected monitors.
- **Working Hours Schedule** — Per-day schedule with multiple time ranges. Timer automatically pauses outside configured hours.
- **Activity Manager** — Browse, search, favorite, and disable built-in activities. Add custom activities with category assignment. Favorites appear 3x more often. Momentum drag scroll with elastic overscroll.
- **Celebration Animations** — Confetti particle effects on streak milestones and daily goal completion.
- **Smart Breaks** — Recognizes natural away-from-desk breaks (idle periods exceeding configurable threshold) and optionally counts them toward daily goal.
- **Break Window (Standalone)** — When fullscreen mode is disabled, breaks open in a separate centered transparent modal window instead of taking over the main window.
- **Break Overlay** — Multi-monitor break enforcement overlay that covers all screens during breaks.
- **Microbreak Overlay** — Subtle non-blocking overlay for 20-20-20 eye breaks with activity suggestion.
- **Dim Overlay** — Smooth pre-break screen dimming with gradual opacity transition.
### UI Polish
- **Settings Reordered** — 18 logically grouped cards: Timer, Pomodoro, Microbreaks, Break Screen, Break Activities (conditional), Breathing Guide, Behavior, Alerts (merged notifications + screen dimming), Sound, Idle & Smart Breaks (merged), Presentation Mode, Goals & Streaks, Appearance, Working Hours, Mini Mode, General, Keyboard Shortcuts, Reset.
- **Breathing Pattern Selector Redesigned** — Replaced cramped 5-column grid with vertical radio-button list showing timing descriptions (e.g., "4s in - 4s hold - 4s out - 4s hold").
- **Dashboard Indicators Inside Ring** — Pomodoro cycle dots, microbreak countdown, and daily goal progress bar moved inside the timer ring for cleaner layout.
- **Daily Goal Label** — Progress bar now includes a target icon and "Goal" label.
- **Break Screen Breathing Halo** — Breathing guide renders as a pulsing circle behind the countdown ring with dynamic color gradient, in both fullscreen and standalone modes.
### Improvements
- Expanded break activity library from 40 to 71 curated activities
- Added 4 new sound presets: Harp, Bowl, Rain, Whistle (now 8 total)
- Stats view now includes weekly summary, natural break tracking, and daily goal progress
- Timer store now handles microbreak, screen dim, celebration, and presentation mode events
- Config store expanded from ~30 to 71 keys with validation for all new features
- Timer state machine now tracks pomodoro cycles, microbreak scheduling, presentation mode deferral, and smart break detection
- Stats backend tracks natural breaks, daily goals, and weekly summaries
### Documentation
- README comprehensively updated with all new features, accurate component/activity/config counts
- CLAUDE.md updated with current architecture (17 commands, 12 events, 20 components, 71 config keys)
---
## v0.1.2
- WCAG 2.1 Level AA accessibility across all components
- Focus indicators, reduced motion support, forced colors, screen reader support
- Keyboard navigation, focus trapping, aria-live regions
## v0.1.1
- Tighten TimeSpinner spacing and fix build warnings
- Enable custom-protocol for embedded frontend assets
- Fix WebView2 detection using loader API instead of registry
- Statically link WebView2Loader for single portable exe
- Fix fullscreen break screen centering and sizing
## v0.1.0
- Initial release
- Dashboard with timer ring and status pill
- Break screen with activity suggestions
- Settings panel with sound, idle detection, and activity configuration
- Statistics view with 7-day bar chart
- System tray with dynamic icon, tooltip, and context menu
- Config and stats persistence (portable JSON files)
- Global keyboard shortcuts (Ctrl+Shift+P/B/S)
- Mini mode floating timer
- Toast notifications
- Always-on-top break enforcement
- Animated view transitions
- Idle detection via Windows API
- Custom titlebar with frosted glass effects
- Background gradient blobs with film grain
+3 -1
View File
@@ -792,6 +792,8 @@ See [`LICENSE`](LICENSE) for the full legal text.
<p align="center">
<sub>
Built with care. Shared without conditions.<br />
<em>Rest well.</em>
<em>Rest well.</em><br />
<br />
<em>This project is developed with the help of a locally-run LLM via opencode for scaffolding, planning, and routine code tasks. Architecture and design decisions are my own.</em>
</sub>
</p>
+218
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 |
@@ -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.
+2 -2
View File
@@ -8,7 +8,7 @@ fn main() {
}
// On GNU targets, replace the WebView2Loader import library with the static
// library so the loader is baked into the exe -- no DLL to ship.
// library so the loader is baked into the exe no DLL to ship.
if std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("gnu") {
swap_webview2_to_static();
}
@@ -44,7 +44,7 @@ fn fix_resource_lib() {
// 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
// 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([
+15 -1
View File
@@ -3,6 +3,7 @@ use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
/// A custom break activity defined by the user.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CustomActivity {
pub id: String,
@@ -12,6 +13,7 @@ pub struct CustomActivity {
pub enabled: bool,
}
/// A single time range (e.g., 09:00 to 17:00)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TimeRange {
pub start: String, // Format: "HH:MM"
@@ -27,6 +29,7 @@ impl Default for TimeRange {
}
}
/// Schedule for a single day
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DaySchedule {
pub enabled: bool,
@@ -43,6 +46,7 @@ impl Default for DaySchedule {
}
impl DaySchedule {
/// Create a default schedule for weekend days (disabled by default)
fn weekend_default() -> Self {
Self {
enabled: false,
@@ -300,13 +304,14 @@ impl Default for Config {
}
impl Config {
// Portable: next to the exe
/// Get the path to the config file (portable: next to the exe)
fn config_path() -> Result<PathBuf> {
let exe_path = std::env::current_exe().context("Failed to determine exe path")?;
let exe_dir = exe_path.parent().context("Failed to determine exe directory")?;
Ok(exe_dir.join("config.json"))
}
/// Load configuration from file, or return default if it doesn't exist
pub fn load_or_default() -> Self {
match Self::load() {
Ok(config) => config,
@@ -322,6 +327,7 @@ impl Config {
}
}
/// Load configuration from file
pub fn load() -> Result<Self> {
let config_path = Self::config_path()?;
@@ -339,6 +345,7 @@ impl Config {
Ok(config.validate())
}
/// Save configuration to file
pub fn save(&self) -> Result<()> {
let config_path = Self::config_path()?;
@@ -350,6 +357,7 @@ impl Config {
Ok(())
}
/// Validate and sanitize configuration values
pub fn validate(mut self) -> Self {
// Break duration: 1-60 minutes
self.break_duration = self.break_duration.clamp(1, 60);
@@ -503,6 +511,7 @@ impl Config {
self
}
/// Check if a time string is in valid HH:MM format
fn is_valid_time_format(time: &str) -> bool {
let parts: Vec<&str> = time.split(':').collect();
if parts.len() != 2 {
@@ -527,24 +536,29 @@ impl Config {
hours * 60 + minutes
}
/// Check if a string is a valid hex color (#RRGGBB)
fn is_valid_hex_color(color: &str) -> bool {
color.len() == 7
&& color.starts_with('#')
&& color[1..].chars().all(|c| c.is_ascii_hexdigit())
}
/// Get break duration in seconds
pub fn break_duration_seconds(&self) -> u64 {
self.break_duration as u64 * 60
}
/// Get break frequency in seconds
pub fn break_frequency_seconds(&self) -> u64 {
self.break_frequency as u64 * 60
}
/// Get snooze duration in seconds
pub fn snooze_duration_seconds(&self) -> u64 {
self.snooze_duration as u64 * 60
}
/// Reset to default values
pub fn reset_to_default(&mut self) {
*self = Self::default();
}
+16 -7
View File
@@ -310,6 +310,7 @@ fn save_window_position(
// ── Dynamic Tray Icon Rendering ────────────────────────────────────────────
/// Parse a "#RRGGBB" hex color string into (r, g, b). Falls back to fallback on invalid input.
fn parse_hex_color(hex: &str, fallback: (u8, u8, u8)) -> (u8, u8, u8) {
if hex.len() == 7 && hex.starts_with('#') {
let r = u8::from_str_radix(&hex[1..3], 16).unwrap_or(fallback.0);
@@ -321,6 +322,8 @@ fn parse_hex_color(hex: &str, fallback: (u8, u8, u8)) -> (u8, u8, u8) {
}
}
/// Render a 32x32 RGBA icon with a progress arc using the given accent/break colors.
/// F10: Optionally renders a green checkmark when daily goal is met.
fn render_tray_icon(
progress: f64,
is_break: bool,
@@ -398,31 +401,33 @@ fn update_tray(
break_color: (u8, u8, u8),
goal_met: bool,
) {
// Update tooltip
let tooltip = match snapshot.state {
timer::TimerState::Running => {
if snapshot.deferred_break_pending {
"Core Cooldown -- Break deferred (fullscreen)".to_string()
"Core Cooldown Break deferred (fullscreen)".to_string()
} else {
let m = snapshot.time_remaining / 60;
let s = snapshot.time_remaining % 60;
format!("Core Cooldown -- {:02}:{:02} until break", m, s)
format!("Core Cooldown {:02}:{:02} until break", m, s)
}
}
timer::TimerState::Paused => {
if snapshot.idle_paused {
"Core Cooldown -- Paused (idle)".to_string()
"Core Cooldown Paused (idle)".to_string()
} else {
"Core Cooldown -- Paused".to_string()
"Core Cooldown Paused".to_string()
}
}
timer::TimerState::BreakActive => {
let m = snapshot.break_time_remaining / 60;
let s = snapshot.break_time_remaining % 60;
format!("Core Cooldown -- Break {:02}:{:02}", m, s)
format!("Core Cooldown Break {:02}:{:02}", m, s)
}
};
let _ = tray.set_tooltip(Some(&tooltip));
// Update icon
let (progress, is_break, is_paused) = match snapshot.state {
timer::TimerState::Running => (snapshot.progress, false, false),
timer::TimerState::Paused => (snapshot.progress, false, true),
@@ -469,7 +474,7 @@ pub fn run() {
"break",
tauri::WebviewUrl::App("index.html?break=1".into()),
)
.title("Core Cooldown - Break")
.title("Core Cooldown \u{2014} Break")
.inner_size(900.0, 540.0)
.decorations(false)
.transparent(true)
@@ -549,6 +554,7 @@ pub fn run() {
let break_c = parse_hex_color(&break_hex, (124, 106, 239));
update_tray(&tray, &snapshot, accent, break_c, goal_met);
// Emit tick event with full snapshot
let _ = handle.emit("timer-tick", &snapshot);
// F5: Screen dim window management
@@ -610,6 +616,7 @@ pub fn run() {
break_deferred_notified = false;
}
TickResult::BreakEnded => {
// Restore normal window state and close break window
handle_break_end(&handle);
// F9: Close multi-monitor overlays
close_multi_monitor_overlays(&handle);
@@ -714,7 +721,7 @@ pub fn run() {
.notification()
.builder()
.title("Break deferred")
.body("Fullscreen app detected -- break will start when you exit.")
.body("Fullscreen app detected break will start when you exit.")
.show();
}
let _ = handle.emit("break-deferred", &());
@@ -876,6 +883,7 @@ fn close_break_window(app: &AppHandle) {
}
}
/// Handle break start: either fullscreen on main window, or open a separate modal break window.
fn handle_break_start(app: &AppHandle, fullscreen_mode: bool) {
if fullscreen_mode {
// Fullscreen: show break inside the main window
@@ -891,6 +899,7 @@ fn handle_break_start(app: &AppHandle, fullscreen_mode: bool) {
}
}
/// Handle break end: restore main window state and close break window if open.
fn handle_break_end(app: &AppHandle) {
if let Some(window) = app.get_webview_window("main") {
let _ = window.set_always_on_top(false);
+8 -8
View File
@@ -9,7 +9,7 @@ use std::sync::atomic::{AtomicI32, Ordering};
// ── MSVC Buffer Security Check (/GS) ────────────────────────────────────────
//
// MSVC's /GS flag instruments functions with stack canaries. These two symbols
// implement the canary check. The cookie value is arbitrary -- real MSVC CRT
// implement the canary check. The cookie value is arbitrary real MSVC CRT
// randomises it at startup, but for a statically-linked helper library this
// fixed sentinel is sufficient.
@@ -44,15 +44,15 @@ pub unsafe extern "C" fn _Init_thread_header(guard: *mut i32) {
loop {
let val = guard.read_volatile();
if val == 0 {
// Already initialised -- tell caller to skip
// Already initialised tell caller to skip
return;
}
if val == -1 {
// Not yet initialised -- try to claim it
// Not yet initialised try to claim it
guard.write_volatile(1);
return;
}
// val == 1: another thread is initialising -- yield and retry
// val == 1: another thread is initialising yield and retry
std::thread::yield_now();
}
}
@@ -71,22 +71,22 @@ pub unsafe extern "C" fn _Init_thread_footer(guard: *mut i32) {
// MinGW's libstdc++ exports the same operators but with GCC/Itanium mangling,
// so the linker can't match them. We provide the MSVC-mangled versions here.
/// `std::nothrow` -- MSVC-mangled `?nothrow@std@@3Unothrow_t@1@B`
/// `std::nothrow` MSVC-mangled `?nothrow@std@@3Unothrow_t@1@B`
/// An empty struct constant used as a tag for nothrow `new`.
#[export_name = "?nothrow@std@@3Unothrow_t@1@B"]
pub static MSVC_STD_NOTHROW: u8 = 0;
/// `operator new(size_t, const std::nothrow_t&)` -- nothrow allocation
/// `operator new(size_t, const std::nothrow_t&)` nothrow allocation
/// MSVC-mangled: `??2@YAPEAX_KAEBUnothrow_t@std@@@Z`
#[export_name = "??2@YAPEAX_KAEBUnothrow_t@std@@@Z"]
pub unsafe extern "C" fn msvc_operator_new_nothrow(size: usize, _nothrow: *const u8) -> *mut u8 {
let size = if size == 0 { 1 } else { size };
let layout = std::alloc::Layout::from_size_align_unchecked(size, 8);
let ptr = std::alloc::alloc(layout);
ptr // null on failure -- nothrow semantics
ptr // null on failure nothrow semantics
}
/// `operator delete(void*, size_t)` -- sized deallocation
/// `operator delete(void*, size_t)` sized deallocation
/// MSVC-mangled: `??3@YAXPEAX_K@Z`
#[export_name = "??3@YAXPEAX_K@Z"]
pub unsafe extern "C" fn msvc_operator_delete_sized(ptr: *mut u8, size: usize) {
+11 -1
View File
@@ -3,6 +3,7 @@ use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
/// A single day's break statistics.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct DayRecord {
@@ -15,6 +16,7 @@ pub struct DayRecord {
pub natural_break_time_secs: u64,
}
/// Persistent stats stored as JSON.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct StatsData {
pub days: HashMap<String, DayRecord>,
@@ -22,10 +24,12 @@ pub struct StatsData {
pub best_streak: u32,
}
/// Runtime stats manager.
pub struct Stats {
pub data: StatsData,
}
/// Snapshot sent to the frontend.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct StatsSnapshot {
@@ -43,11 +47,13 @@ pub struct StatsSnapshot {
pub daily_goal_met: bool,
}
/// F10: Result of recording a completed break
pub struct BreakCompletedResult {
pub milestone_reached: Option<u32>,
pub daily_goal_just_met: bool,
}
/// F7: Weekly summary for reports
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct WeekSummary {
@@ -62,7 +68,7 @@ pub struct WeekSummary {
const MILESTONES: &[u32] = &[3, 5, 7, 14, 21, 30, 50, 100, 365];
impl Stats {
// Portable: next to the exe
/// Portable: stats file lives next to the exe
fn stats_path() -> Option<PathBuf> {
let exe_path = std::env::current_exe().ok()?;
let exe_dir = exe_path.parent()?;
@@ -108,6 +114,7 @@ impl Stats {
})
}
/// Record a completed break. Returns milestone/goal info for gamification.
pub fn record_break_completed(&mut self, duration_secs: u64, daily_goal: u32) -> BreakCompletedResult {
let day = self.today_mut();
let was_below_goal = day.breaks_completed < daily_goal;
@@ -175,6 +182,7 @@ impl Stats {
}
}
/// F10: Check if current streak exactly matches a milestone
fn check_milestone(&self) -> Option<u32> {
let streak = self.data.current_streak;
if MILESTONES.contains(&streak) {
@@ -217,6 +225,7 @@ impl Stats {
}
}
/// Get recent N days of history, sorted chronologically.
pub fn recent_days(&self, n: u32) -> Vec<DayRecord> {
let today = chrono::Local::now().date_naive();
let mut records = Vec::new();
@@ -234,6 +243,7 @@ impl Stats {
records
}
/// F7: Get weekly summaries for the past N weeks
pub fn weekly_summary(&self, weeks: u32) -> Vec<WeekSummary> {
let today = chrono::Local::now().date_naive();
let mut summaries = Vec::new();
+15 -2
View File
@@ -26,6 +26,7 @@ pub enum AppView {
Stats,
}
/// Snapshot of the full timer state, sent to the frontend on every tick
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TimerSnapshot {
@@ -73,6 +74,7 @@ pub struct TimerSnapshot {
pub is_long_break: bool,
}
/// Events emitted by the timer to the frontend
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BreakStartedPayload {
@@ -159,6 +161,8 @@ impl TimerManager {
}
}
/// Check idle state and auto-pause/resume accordingly.
/// Returns IdleCheckResult indicating what happened.
pub fn check_idle(&mut self) -> IdleCheckResult {
if !self.config.idle_detection_enabled {
// If idle detection disabled but we were idle-paused, resume
@@ -214,6 +218,7 @@ impl TimerManager {
}
}
/// F2: Check if the foreground window is fullscreen (presentation mode)
pub fn check_presentation_mode(&mut self) -> bool {
if !self.config.presentation_mode_enabled {
self.presentation_mode_active = false;
@@ -225,6 +230,7 @@ impl TimerManager {
fs
}
/// Called every second. Returns what events should be emitted.
pub fn tick(&mut self) -> TickResult {
// Idle detection and natural break detection
let idle_result = self.check_idle();
@@ -316,6 +322,7 @@ impl TimerManager {
}
}
/// F1: Called every second for microbreak logic. Returns microbreak events.
pub fn tick_microbreak(&mut self) -> MicrobreakTickResult {
if !self.config.microbreak_enabled {
return MicrobreakTickResult::None;
@@ -409,6 +416,7 @@ impl TimerManager {
self.snoozes_used = 0;
}
/// F3: Advance the Pomodoro cycle position after a break completes
fn advance_pomodoro_cycle(&mut self) {
if !self.config.pomodoro_enabled {
return;
@@ -464,14 +472,14 @@ impl TimerManager {
let past_half = total > 0 && elapsed * 2 >= total;
if past_half && self.config.allow_end_early {
// "End break" -- counts as completed
// "End break" counts as completed
self.has_had_break = true;
self.seconds_since_last_break = 0;
self.advance_pomodoro_cycle();
self.reset_timer();
true
} else if !past_half {
// "Cancel break" -- doesn't count
// "Cancel break" doesn't count
// F3: Pomodoro reset-on-skip
if self.config.pomodoro_enabled && self.config.pomodoro_reset_on_skip {
self.pomodoro_cycle_position = 0;
@@ -664,12 +672,14 @@ pub enum TickResult {
BreakDeferred, // F2
}
/// F1: Microbreak tick result
pub enum MicrobreakTickResult {
None,
MicrobreakStarted,
MicrobreakEnded,
}
/// Result of checking idle state
pub enum IdleCheckResult {
None,
JustPaused,
@@ -677,6 +687,7 @@ pub enum IdleCheckResult {
NaturalBreakDetected(u64), // duration in seconds
}
/// Returns the number of seconds since last user input (mouse/keyboard).
#[cfg(windows)]
pub fn get_idle_seconds() -> u64 {
use std::mem;
@@ -701,6 +712,7 @@ pub fn get_idle_seconds() -> u64 {
0
}
/// F2: Check if the foreground window is a fullscreen application
#[cfg(windows)]
pub fn is_foreground_fullscreen() -> bool {
use std::mem;
@@ -744,6 +756,7 @@ pub fn is_foreground_fullscreen() -> bool {
false
}
/// F9: Get all monitor rects for multi-monitor break enforcement
#[cfg(windows)]
pub fn get_all_monitors() -> Vec<MonitorInfo> {
use winapi::shared::windef::{HMONITOR, HDC, LPRECT};
+2 -2
View File
@@ -62,7 +62,7 @@
return () => mq.removeEventListener("change", handler);
});
// Transition parameters -- zero when reduced motion active
// Transition parameters zero when reduced motion active
const DURATION = $derived(reducedMotion ? 0 : 700);
const easing = cubicOut;
@@ -83,7 +83,7 @@
settings: "Settings",
stats: "Statistics",
};
document.title = `Core Cooldown -- ${viewNames[effectiveView] ?? "Dashboard"}`;
document.title = `Core Cooldown ${viewNames[effectiveView] ?? "Dashboard"}`;
});
// When fullscreen_mode is OFF, the separate break window handles breaks,
+2 -2
View File
@@ -86,7 +86,7 @@
}
});
// Map raw 0.6-1.0 scale to 0.9-1.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));
// Interpolate color between break_color (inhale) and accent_color (exhale)
@@ -483,7 +483,7 @@
background: rgba(255, 255, 255, 0.03);
}
/* Ripple container -- sits behind the ring, overflows the card */
/* Ripple container sits behind the ring, overflows the card */
.ripple-container {
position: absolute;
inset: 0;
+2 -2
View File
@@ -259,7 +259,7 @@
</button>
{/each}
<!-- Custom toggle swatch -- ring shows when picker open OR value is custom -->
<!-- Custom toggle swatch ring shows when picker open OR value is custom -->
<button
type="button"
class="group flex items-center justify-center min-h-[44px] min-w-[44px] bg-transparent border-none p-0"
@@ -277,7 +277,7 @@
</button>
</div>
<!-- Inline custom picker -- slides open/closed -->
<!-- Inline custom picker slides open/closed -->
{#if showCustom}
<div
class="flex flex-col gap-3 rounded-xl bg-[#0f0f0f] p-3 border border-[#1a1a1a]"
+1 -1
View File
@@ -104,7 +104,7 @@
// Gap between ring and button, compensating for CSS transform phantom space.
// transform: scale() doesn't affect layout, so the 280px box stays full-size
// even when visually shrunk -- creating phantom space below the visual ring.
// even when visually shrunk creating phantom space below the visual ring.
const ringSize = 280;
const phantomBelow = $derived((ringSize * (1 - ringScale)) / 2);
const targetGap = $derived(Math.max(16, Math.round((windowH - 400) * 0.05 + 16)));
+1 -1
View File
@@ -45,7 +45,7 @@
<path d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
<span id="microbreak-msg" class="text-[15px] font-medium text-white">Look away -- 20 feet for {timeRemaining}s</span>
<span id="microbreak-msg" class="text-[15px] font-medium text-white">Look away 20 feet for {timeRemaining}s</span>
</div>
{#if activity && $config.microbreak_show_activity}
+3 -3
View File
@@ -88,7 +88,7 @@
return milestones.find((m) => m > current) ?? null;
});
// Chart rendering -- 7-day
// Chart rendering 7-day
let chartCanvas: HTMLCanvasElement | undefined = $state();
$effect(() => {
@@ -96,7 +96,7 @@
drawChart(chartCanvas, history);
});
// Chart rendering -- 30-day
// Chart rendering 30-day
let monthChartCanvas: HTMLCanvasElement | undefined = $state();
$effect(() => {
@@ -154,7 +154,7 @@
ctx.fill();
}
// Day label -- show every Nth for 30-day
// Day label show every Nth for 30-day
if (data.length <= 7 || i % 5 === 0) {
ctx.fillStyle = "#a8a8a8";
ctx.font = "10px -apple-system, sans-serif";
+2 -2
View File
@@ -7,7 +7,7 @@
let { value, onchange, countdownFont = "" }: Props = $props();
// Local display values -- driven by prop normally, overridden during drag/momentum
// Local display values driven by prop normally, overridden during drag/momentum
let displayHours = $state(0);
let displayMinutes = $state(0);
let isAnimating = $state(false); // true during drag OR momentum
@@ -373,7 +373,7 @@
border-color: rgba(255, 255, 255, 0.18);
}
/* Perspective container -- looking into the cylinder from outside */
/* Perspective container looking into the cylinder from outside */
.wheel-viewport {
width: 100%;
height: 100%;
+2 -2
View File
@@ -55,7 +55,7 @@
aria-label={label}
aria-valuetext={valueText}
>
<!-- Glow SVG -- drawn larger than the container so blur isn't clipped -->
<!-- Glow SVG drawn larger than the container so blur isn't clipped -->
<svg
aria-hidden="true"
width={viewSize}
@@ -145,7 +145,7 @@
{/if}
</svg>
<!-- Non-glow SVG -- exact size, draws the track + crisp ring -->
<!-- Non-glow SVG exact size, draws the track + crisp ring -->
<svg
aria-hidden="true"
width={size}
+3 -3
View File
@@ -4,12 +4,12 @@
const appWindow = getCurrentWebviewWindow();
</script>
<!-- Invisible drag region - traffic lights on the right -->
<!-- Invisible drag region traffic lights on the right -->
<header
data-tauri-drag-region
class="group absolute top-0 left-0 right-0 z-50 flex h-10 items-center justify-end pr-3.5 select-none"
>
<!-- Centered app name (decorative -- OS window title handles screen readers) -->
<!-- Centered app name (decorative OS window title handles screen readers) -->
<span
aria-hidden="true"
class="pointer-events-none absolute inset-0 flex items-center justify-center
@@ -61,7 +61,7 @@
</span>
</button>
<!-- Close (red) -- rightmost -->
<!-- Close (red) rightmost -->
<button
aria-label="Close"
class="group/btn relative flex h-[44px] w-[44px] items-center justify-center"
+14 -14
View File
@@ -8,9 +8,9 @@ export const breakActivities: BreakActivity[] = [
{ category: "eyes", text: "Look at something 20 feet away for 20 seconds" },
{ category: "eyes", text: "Close your eyes and gently press your palms over them" },
{ category: "eyes", text: "Slowly roll your eyes in circles, 5 times each direction" },
{ category: "eyes", text: "Focus on a distant object, then a near one -- repeat 5 times" },
{ category: "eyes", text: "Focus on a distant object, then a near one repeat 5 times" },
{ category: "eyes", text: "Blink rapidly 20 times to refresh your eyes" },
{ category: "eyes", text: "Look up, down, left, right -- hold each for 2 seconds" },
{ category: "eyes", text: "Look up, down, left, right hold each for 2 seconds" },
{ category: "eyes", text: "Cup your hands over closed eyes and breathe deeply" },
{ category: "eyes", text: "Trace a figure-8 with your eyes, slowly" },
{ category: "eyes", text: "Look out the nearest window and find the farthest point" },
@@ -19,9 +19,9 @@ export const breakActivities: BreakActivity[] = [
{ category: "eyes", text: "Warm your palms by rubbing them together, then rest them over closed eyes" },
{ category: "eyes", text: "Slowly shift your gaze along the horizon line outside" },
{ category: "eyes", text: "Focus on the tip of your nose for 5 seconds, then a far wall" },
{ category: "eyes", text: "Look at something green -- plants reduce eye strain naturally" },
{ category: "eyes", text: "Look at something green plants reduce eye strain naturally" },
{ category: "eyes", text: "Close your eyes and gently press your eyebrows outward from center" },
{ category: "eyes", text: "Squeeze your eyes shut for 3 seconds, then open wide -- repeat 5 times" },
{ category: "eyes", text: "Squeeze your eyes shut for 3 seconds, then open wide repeat 5 times" },
{ category: "eyes", text: "Trace the outline of a window frame with your eyes, slowly" },
// Stretches
@@ -35,7 +35,7 @@ export const breakActivities: BreakActivity[] = [
{ category: "stretch", text: "Drop your chin to your chest and slowly roll your neck" },
{ category: "stretch", text: "Extend each arm across your chest, hold for 15 seconds" },
{ category: "stretch", text: "Open and close your hands, spreading fingers wide" },
{ category: "stretch", text: "Place your right hand on your left knee and twist gently -- switch sides" },
{ category: "stretch", text: "Place your right hand on your left knee and twist gently switch sides" },
{ category: "stretch", text: "Reach one arm overhead and lean to the opposite side, hold 10 seconds each" },
{ category: "stretch", text: "Interlace your fingers and flip your palms to the ceiling, push up" },
{ category: "stretch", text: "Pull each finger gently backward for 3 seconds to stretch your forearms" },
@@ -46,20 +46,20 @@ export const breakActivities: BreakActivity[] = [
// Breathing
{ category: "breathing", text: "Inhale for 4 seconds, hold for 4, exhale for 6" },
{ category: "breathing", text: "Take 5 deep belly breaths -- feel your diaphragm expand" },
{ category: "breathing", text: "Take 5 deep belly breaths feel your diaphragm expand" },
{ category: "breathing", text: "Box breathing: inhale 4s, hold 4s, exhale 4s, hold 4s" },
{ category: "breathing", text: "Breathe in through your nose, out through your mouth -- 5 times" },
{ category: "breathing", text: "Breathe in through your nose, out through your mouth 5 times" },
{ category: "breathing", text: "Sigh deeply 3 times to release tension" },
{ category: "breathing", text: "Place a hand on your chest and breathe so only your belly moves" },
{ category: "breathing", text: "Alternate nostril breathing: 3 cycles each side" },
{ category: "breathing", text: "Exhale completely, then take the deepest breath you can" },
{ category: "breathing", text: "Count your breaths backward from 10 to 1" },
{ category: "breathing", text: "Breathe in calm, breathe out tension -- 5 rounds" },
{ category: "breathing", text: "Breathe in calm, breathe out tension 5 rounds" },
{ category: "breathing", text: "4-7-8 breathing: inhale 4s, hold 7s, exhale slowly for 8s" },
{ category: "breathing", text: "Take 3 breaths making your exhale twice as long as your inhale" },
{ category: "breathing", text: "Breathe in for 2 counts, out for 2 -- gradually increase to 6 each" },
{ category: "breathing", text: "Hum gently on each exhale for 5 breaths -- feel the vibration in your chest" },
{ category: "breathing", text: "Imagine breathing in cool blue air and exhaling warm red air -- 5 rounds" },
{ category: "breathing", text: "Breathe in for 2 counts, out for 2 gradually increase to 6 each" },
{ category: "breathing", text: "Hum gently on each exhale for 5 breaths feel the vibration in your chest" },
{ category: "breathing", text: "Imagine breathing in cool blue air and exhaling warm red air 5 rounds" },
{ category: "breathing", text: "Place both hands on your ribs and breathe so you feel them expand sideways" },
{ category: "breathing", text: "Breathe in through pursed lips slowly, then exhale in a long, steady stream" },
@@ -75,11 +75,11 @@ export const breakActivities: BreakActivity[] = [
{ category: "movement", text: "Shake out your hands and arms for 10 seconds" },
{ category: "movement", text: "Stand up, touch your toes, and slowly rise" },
{ category: "movement", text: "Walk to the farthest room in your home and back" },
{ category: "movement", text: "Do 5 wall push-ups -- hands on the wall, lean in and push back" },
{ category: "movement", text: "Do 5 wall push-ups hands on the wall, lean in and push back" },
{ category: "movement", text: "Stand up and slowly shift your weight side to side for 20 seconds" },
{ category: "movement", text: "Walk in a small circle, paying attention to each footstep" },
{ category: "movement", text: "Rise onto your tiptoes, hold for 5 seconds, lower slowly -- repeat 5 times" },
{ category: "movement", text: "Do a gentle standing forward fold -- let your arms hang loose" },
{ category: "movement", text: "Rise onto your tiptoes, hold for 5 seconds, lower slowly repeat 5 times" },
{ category: "movement", text: "Do a gentle standing forward fold let your arms hang loose" },
{ category: "movement", text: "Step away from your desk and do 10 hip circles in each direction" },
{ category: "movement", text: "Stand with your back against a wall and slide down into a gentle wall sit for 15 seconds" },
];
+31 -5
View File
@@ -1,6 +1,6 @@
import { animate } from "motion";
// Module-level reduced motion query -- shared across all actions
// Module-level reduced motion query shared across all actions
const reducedMotionQuery =
typeof window !== "undefined"
? window.matchMedia("(prefers-reduced-motion: reduce)")
@@ -10,6 +10,9 @@ function prefersReducedMotion(): boolean {
return reducedMotionQuery?.matches ?? false;
}
/**
* Svelte action: fade in + slide up on mount
*/
export function fadeIn(
node: HTMLElement,
options?: { duration?: number; delay?: number; y?: number },
@@ -35,6 +38,9 @@ export function fadeIn(
};
}
/**
* Svelte action: scale in + fade on mount
*/
export function scaleIn(
node: HTMLElement,
options?: { duration?: number; delay?: number },
@@ -60,6 +66,9 @@ export function scaleIn(
};
}
/**
* Svelte action: animate when scrolled into view (IntersectionObserver)
*/
export function inView(
node: HTMLElement,
options?: { delay?: number; y?: number; threshold?: number },
@@ -102,6 +111,9 @@ export function inView(
};
}
/**
* Svelte action: spring-scale press feedback on buttons
*/
export function pressable(node: HTMLElement) {
if (prefersReducedMotion()) {
return { destroy() {} };
@@ -159,7 +171,14 @@ export function pressable(node: HTMLElement) {
};
}
// Animated glow band on hover. Pass a hex color (e.g. "#ff4d00").
/**
* Svelte action: animated glow band on hover.
* Creates a crisp ring "band" plus a soft atmospheric glow.
* Pass a hex color (e.g. "#ff4d00").
*
* Uses same-hue zero-alpha as the "off" state so the Web Animations API
* interpolates through the correct color channel instead of through black.
*/
export function glowHover(
node: HTMLElement,
options?: { color?: string },
@@ -217,7 +236,14 @@ export function glowHover(
};
}
// Momentum grab-and-drag scrolling. Node must have exactly one child wrapper div.
/**
* Svelte action: momentum-based grab-and-drag scrolling
* with elastic overscroll and spring-back at boundaries.
*
* IMPORTANT: The node must have exactly one child element (a wrapper div).
* Overscroll transforms are applied to that child, NOT to the scroll
* container itself (which would break overflow clipping).
*/
export function dragScroll(node: HTMLElement) {
if (prefersReducedMotion()) {
// Allow normal scrolling without the momentum/elastic physics
@@ -267,7 +293,7 @@ export function dragScroll(node: HTMLElement) {
// Ensure DOM is clean after animation completes
springAnim.finished.then(() => {
if (content) content.style.transform = "";
}).catch(() => { /* cancelled -- onDown handles cleanup */ });
}).catch(() => { /* cancelled onDown handles cleanup */ });
}
function forceReset() {
@@ -344,7 +370,7 @@ export function dragScroll(node: HTMLElement) {
// Time-based exponential decay (iOS-style scroll physics).
// position(t) = start + v0 * tau * (1 - e^(-t/tau))
// velocity(t) = v0 * e^(-t/tau) → naturally asymptotes to zero
const tau = 325; // time constant in ms -- iOS UIScrollView feel
const tau = 325; // time constant in ms iOS UIScrollView feel
const coastStart = performance.now();
const scrollStart2 = node.scrollTop;
const totalDist = v0 * tau;
+18 -7
View File
@@ -1,3 +1,8 @@
/**
* Synthesized notification sounds using the Web Audio API.
* No external audio files needed — all sounds are generated programmatically.
*/
let audioCtx: AudioContext | null = null;
function getAudioContext(): AudioContext {
@@ -9,6 +14,11 @@ function getAudioContext(): AudioContext {
type SoundPreset = "bell" | "chime" | "soft" | "digital" | "harp" | "bowl" | "rain" | "whistle";
/**
* Play a notification sound with the given preset and volume.
* @param preset - One of: "bell", "chime", "soft", "digital"
* @param volume - 0 to 100
*/
export function playSound(preset: SoundPreset, volume: number): void {
const ctx = getAudioContext();
const gain = ctx.createGain();
@@ -43,7 +53,7 @@ export function playSound(preset: SoundPreset, volume: number): void {
}
}
/** Warm bell -- two sine tones with harmonics and slow decay */
/** Warm bell two sine tones with harmonics and slow decay */
function playBell(ctx: AudioContext, destination: GainNode, vol: number) {
const now = ctx.currentTime;
@@ -93,7 +103,7 @@ function playChime(ctx: AudioContext, destination: GainNode, vol: number) {
});
}
/** Gentle soft ping -- filtered triangle wave */
/** Gentle soft ping filtered triangle wave */
function playSoft(ctx: AudioContext, destination: GainNode, vol: number) {
const now = ctx.currentTime;
@@ -119,7 +129,7 @@ function playSoft(ctx: AudioContext, destination: GainNode, vol: number) {
osc.stop(now + 1.2);
}
/** Digital blip -- short square wave burst */
/** Digital blip short square wave burst */
function playDigital(ctx: AudioContext, destination: GainNode, vol: number) {
const now = ctx.currentTime;
@@ -139,7 +149,7 @@ function playDigital(ctx: AudioContext, destination: GainNode, vol: number) {
}
}
/** Harp -- cascading arpeggiated sine tones (C5-E5-G5-C6) */
/** Harp cascading arpeggiated sine tones (C5-E5-G5-C6) */
function playHarp(ctx: AudioContext, destination: GainNode, vol: number) {
const now = ctx.currentTime;
const notes = [523.25, 659.25, 783.99, 1046.5]; // C5, E5, G5, C6
@@ -160,7 +170,7 @@ function playHarp(ctx: AudioContext, destination: GainNode, vol: number) {
});
}
/** Singing bowl -- low sine with slow beating from detuned pair */
/** Singing bowl low sine with slow beating from detuned pair */
function playBowl(ctx: AudioContext, destination: GainNode, vol: number) {
const now = ctx.currentTime;
@@ -191,7 +201,7 @@ function playBowl(ctx: AudioContext, destination: GainNode, vol: number) {
osc3.stop(now + 1.5);
}
/** Rain -- filtered noise burst with gentle decay */
/** Rain filtered noise burst with gentle decay */
function playRain(ctx: AudioContext, destination: GainNode, vol: number) {
const now = ctx.currentTime;
const bufferSize = ctx.sampleRate * 1;
@@ -223,7 +233,7 @@ function playRain(ctx: AudioContext, destination: GainNode, vol: number) {
noise.stop(now + 1.0);
}
/** Whistle -- gentle two-note sine glide */
/** Whistle gentle two-note sine glide */
function playWhistle(ctx: AudioContext, destination: GainNode, vol: number) {
const now = ctx.currentTime;
@@ -246,6 +256,7 @@ function playWhistle(ctx: AudioContext, destination: GainNode, vol: number) {
osc.stop(now + 1.0);
}
/** Play a completion sound — slightly different from start (descending) */
export function playBreakEndSound(preset: SoundPreset, volume: number): void {
const ctx = getAudioContext();
const gain = ctx.createGain();