27 Commits

Author SHA1 Message Date
lashman 6874b3f028 strip unicode dashes, trim restating doc comments, untrack forbidden files 2026-03-13 14:05:59 +02:00
Your Name 1a6774e195 v0.2.0: WCAG 2.2 AAA accessibility + toggle fix + version bump 2026-02-18 19:18:15 +02:00
Your Name 99aaa3c785 build.rs cfg() checks HOST not TARGET -- use CARGO_CFG_TARGET_ENV 2026-02-18 19:18:15 +02:00
Your Name e3a46aff0c 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 c0f2170a0e 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 40a0e6130a 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 887edd5f21 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 d820f16b64 a11y: update theme tokens and global styles for WCAG AAA 2026-02-18 19:18:15 +02:00
lashman 927871bb45 Update README.md 2026-02-07 13:27:24 +00:00
Your Name 6cafe47da1 Add emojis to all top-level README section headers 2026-02-07 15:24:03 +02:00
Your Name 778026e36a Add emojis to feature section headers in README 2026-02-07 15:22:11 +02:00
Your Name 6aac33b195 Add pomodoro, microbreaks, breathing guide, screen dimming, presentation mode, goals, multi-monitor, and activity manager 2026-02-07 15:11:44 +02:00
Your Name 516524d76c Bump version to 0.1.2 2026-02-07 12:16:03 +02:00
Your Name 6d2707770d Add WCAG 2.1 Level AA accessibility across all components 2026-02-07 12:13:03 +02:00
lashman 3427c629c0 Update README.md 2026-02-07 09:35:24 +00:00
lashman 63bc63d0cf Update README.md 2026-02-07 09:34:36 +00:00
Your Name 2a285c29b6 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 6bb00e8212 Bump version to 0.1.1 2026-02-07 11:16:29 +02:00
Your Name 33ee83a5c0 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 9d36e07228 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 e0afe9a2c1 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 d2dd852b52 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 55f7ba1644 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 07bb033d0a 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 663bd4ed79 Add screenshots, move philosophy section above features 2026-02-07 01:36:43 +02:00
Your Name f5e782ec29 Revamp README - emojis, better formatting, cleaner layout 2026-02-07 01:18:48 +02:00
Your Name 2fc44f0d65 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 66 additions and 1441 deletions
-27
View File
@@ -1,27 +0,0 @@
# 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
@@ -1,78 +0,0 @@
# 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
+1 -3
View File
@@ -792,8 +792,6 @@ See [`LICENSE`](LICENSE) for the full legal text.
<p align="center"> <p align="center">
<sub> <sub>
Built with care. Shared without conditions.<br /> Built with care. Shared without conditions.<br />
<em>Rest well.</em><br /> <em>Rest well.</em>
<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> </sub>
</p> </p>
-218
View File
@@ -1,218 +0,0 @@
# 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 |
@@ -1,967 +0,0 @@
# 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 // 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") { if std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("gnu") {
swap_webview2_to_static(); swap_webview2_to_static();
} }
@@ -44,7 +44,7 @@ fn fix_resource_lib() {
// archive signature). A .res file starts with 0x00000000. // archive signature). A .res file starts with 0x00000000.
if let Ok(header) = std::fs::read(&lib_file) { if let Ok(header) = std::fs::read(&lib_file) {
if header.len() >= 4 && header[0..4] == [0, 0, 0, 0] { 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 windres = "C:/Users/lashman/mingw-w64/mingw64/bin/windres.exe";
let status = Command::new(windres) let status = Command::new(windres)
.args([ .args([
+1 -15
View File
@@ -3,7 +3,6 @@ use serde::{Deserialize, Serialize};
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
/// A custom break activity defined by the user.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CustomActivity { pub struct CustomActivity {
pub id: String, pub id: String,
@@ -13,7 +12,6 @@ pub struct CustomActivity {
pub enabled: bool, pub enabled: bool,
} }
/// A single time range (e.g., 09:00 to 17:00)
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TimeRange { pub struct TimeRange {
pub start: String, // Format: "HH:MM" pub start: String, // Format: "HH:MM"
@@ -29,7 +27,6 @@ impl Default for TimeRange {
} }
} }
/// Schedule for a single day
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DaySchedule { pub struct DaySchedule {
pub enabled: bool, pub enabled: bool,
@@ -46,7 +43,6 @@ impl Default for DaySchedule {
} }
impl DaySchedule { impl DaySchedule {
/// Create a default schedule for weekend days (disabled by default)
fn weekend_default() -> Self { fn weekend_default() -> Self {
Self { Self {
enabled: false, enabled: false,
@@ -304,14 +300,13 @@ impl Default for Config {
} }
impl Config { impl Config {
/// Get the path to the config file (portable: next to the exe) // Portable: next to the exe
fn config_path() -> Result<PathBuf> { fn config_path() -> Result<PathBuf> {
let exe_path = std::env::current_exe().context("Failed to determine exe path")?; 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")?; let exe_dir = exe_path.parent().context("Failed to determine exe directory")?;
Ok(exe_dir.join("config.json")) Ok(exe_dir.join("config.json"))
} }
/// Load configuration from file, or return default if it doesn't exist
pub fn load_or_default() -> Self { pub fn load_or_default() -> Self {
match Self::load() { match Self::load() {
Ok(config) => config, Ok(config) => config,
@@ -327,7 +322,6 @@ impl Config {
} }
} }
/// Load configuration from file
pub fn load() -> Result<Self> { pub fn load() -> Result<Self> {
let config_path = Self::config_path()?; let config_path = Self::config_path()?;
@@ -345,7 +339,6 @@ impl Config {
Ok(config.validate()) Ok(config.validate())
} }
/// Save configuration to file
pub fn save(&self) -> Result<()> { pub fn save(&self) -> Result<()> {
let config_path = Self::config_path()?; let config_path = Self::config_path()?;
@@ -357,7 +350,6 @@ impl Config {
Ok(()) Ok(())
} }
/// Validate and sanitize configuration values
pub fn validate(mut self) -> Self { pub fn validate(mut self) -> Self {
// Break duration: 1-60 minutes // Break duration: 1-60 minutes
self.break_duration = self.break_duration.clamp(1, 60); self.break_duration = self.break_duration.clamp(1, 60);
@@ -511,7 +503,6 @@ impl Config {
self self
} }
/// Check if a time string is in valid HH:MM format
fn is_valid_time_format(time: &str) -> bool { fn is_valid_time_format(time: &str) -> bool {
let parts: Vec<&str> = time.split(':').collect(); let parts: Vec<&str> = time.split(':').collect();
if parts.len() != 2 { if parts.len() != 2 {
@@ -536,29 +527,24 @@ impl Config {
hours * 60 + minutes hours * 60 + minutes
} }
/// Check if a string is a valid hex color (#RRGGBB)
fn is_valid_hex_color(color: &str) -> bool { fn is_valid_hex_color(color: &str) -> bool {
color.len() == 7 color.len() == 7
&& color.starts_with('#') && color.starts_with('#')
&& color[1..].chars().all(|c| c.is_ascii_hexdigit()) && color[1..].chars().all(|c| c.is_ascii_hexdigit())
} }
/// Get break duration in seconds
pub fn break_duration_seconds(&self) -> u64 { pub fn break_duration_seconds(&self) -> u64 {
self.break_duration as u64 * 60 self.break_duration as u64 * 60
} }
/// Get break frequency in seconds
pub fn break_frequency_seconds(&self) -> u64 { pub fn break_frequency_seconds(&self) -> u64 {
self.break_frequency as u64 * 60 self.break_frequency as u64 * 60
} }
/// Get snooze duration in seconds
pub fn snooze_duration_seconds(&self) -> u64 { pub fn snooze_duration_seconds(&self) -> u64 {
self.snooze_duration as u64 * 60 self.snooze_duration as u64 * 60
} }
/// Reset to default values
pub fn reset_to_default(&mut self) { pub fn reset_to_default(&mut self) {
*self = Self::default(); *self = Self::default();
} }
+7 -16
View File
@@ -310,7 +310,6 @@ fn save_window_position(
// ── Dynamic Tray Icon Rendering ──────────────────────────────────────────── // ── 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) { fn parse_hex_color(hex: &str, fallback: (u8, u8, u8)) -> (u8, u8, u8) {
if hex.len() == 7 && hex.starts_with('#') { if hex.len() == 7 && hex.starts_with('#') {
let r = u8::from_str_radix(&hex[1..3], 16).unwrap_or(fallback.0); let r = u8::from_str_radix(&hex[1..3], 16).unwrap_or(fallback.0);
@@ -322,8 +321,6 @@ fn parse_hex_color(hex: &str, fallback: (u8, u8, u8)) -> (u8, u8, u8) {
} }
} }
/// Render a 32x32 RGBA icon with a progress arc using the given accent/break colors.
/// F10: Optionally renders a green checkmark when daily goal is met.
fn render_tray_icon( fn render_tray_icon(
progress: f64, progress: f64,
is_break: bool, is_break: bool,
@@ -401,33 +398,31 @@ fn update_tray(
break_color: (u8, u8, u8), break_color: (u8, u8, u8),
goal_met: bool, goal_met: bool,
) { ) {
// Update tooltip
let tooltip = match snapshot.state { let tooltip = match snapshot.state {
timer::TimerState::Running => { timer::TimerState::Running => {
if snapshot.deferred_break_pending { if snapshot.deferred_break_pending {
"Core Cooldown Break deferred (fullscreen)".to_string() "Core Cooldown -- Break deferred (fullscreen)".to_string()
} else { } else {
let m = snapshot.time_remaining / 60; let m = snapshot.time_remaining / 60;
let s = snapshot.time_remaining % 60; let s = snapshot.time_remaining % 60;
format!("Core Cooldown {:02}:{:02} until break", m, s) format!("Core Cooldown -- {:02}:{:02} until break", m, s)
} }
} }
timer::TimerState::Paused => { timer::TimerState::Paused => {
if snapshot.idle_paused { if snapshot.idle_paused {
"Core Cooldown Paused (idle)".to_string() "Core Cooldown -- Paused (idle)".to_string()
} else { } else {
"Core Cooldown Paused".to_string() "Core Cooldown -- Paused".to_string()
} }
} }
timer::TimerState::BreakActive => { timer::TimerState::BreakActive => {
let m = snapshot.break_time_remaining / 60; let m = snapshot.break_time_remaining / 60;
let s = 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)); let _ = tray.set_tooltip(Some(&tooltip));
// Update icon
let (progress, is_break, is_paused) = match snapshot.state { let (progress, is_break, is_paused) = match snapshot.state {
timer::TimerState::Running => (snapshot.progress, false, false), timer::TimerState::Running => (snapshot.progress, false, false),
timer::TimerState::Paused => (snapshot.progress, false, true), timer::TimerState::Paused => (snapshot.progress, false, true),
@@ -474,7 +469,7 @@ pub fn run() {
"break", "break",
tauri::WebviewUrl::App("index.html?break=1".into()), tauri::WebviewUrl::App("index.html?break=1".into()),
) )
.title("Core Cooldown \u{2014} Break") .title("Core Cooldown - Break")
.inner_size(900.0, 540.0) .inner_size(900.0, 540.0)
.decorations(false) .decorations(false)
.transparent(true) .transparent(true)
@@ -554,7 +549,6 @@ pub fn run() {
let break_c = parse_hex_color(&break_hex, (124, 106, 239)); let break_c = parse_hex_color(&break_hex, (124, 106, 239));
update_tray(&tray, &snapshot, accent, break_c, goal_met); update_tray(&tray, &snapshot, accent, break_c, goal_met);
// Emit tick event with full snapshot
let _ = handle.emit("timer-tick", &snapshot); let _ = handle.emit("timer-tick", &snapshot);
// F5: Screen dim window management // F5: Screen dim window management
@@ -616,7 +610,6 @@ pub fn run() {
break_deferred_notified = false; break_deferred_notified = false;
} }
TickResult::BreakEnded => { TickResult::BreakEnded => {
// Restore normal window state and close break window
handle_break_end(&handle); handle_break_end(&handle);
// F9: Close multi-monitor overlays // F9: Close multi-monitor overlays
close_multi_monitor_overlays(&handle); close_multi_monitor_overlays(&handle);
@@ -721,7 +714,7 @@ pub fn run() {
.notification() .notification()
.builder() .builder()
.title("Break deferred") .title("Break deferred")
.body("Fullscreen app detected break will start when you exit.") .body("Fullscreen app detected -- break will start when you exit.")
.show(); .show();
} }
let _ = handle.emit("break-deferred", &()); let _ = handle.emit("break-deferred", &());
@@ -883,7 +876,6 @@ fn close_break_window(app: &AppHandle) {
} }
} }
/// Handle break start: either fullscreen on main window, or open a separate modal break window.
fn handle_break_start(app: &AppHandle, fullscreen_mode: bool) { fn handle_break_start(app: &AppHandle, fullscreen_mode: bool) {
if fullscreen_mode { if fullscreen_mode {
// Fullscreen: show break inside the main window // Fullscreen: show break inside the main window
@@ -899,7 +891,6 @@ fn handle_break_start(app: &AppHandle, fullscreen_mode: bool) {
} }
} }
/// Handle break end: restore main window state and close break window if open.
fn handle_break_end(app: &AppHandle) { fn handle_break_end(app: &AppHandle) {
if let Some(window) = app.get_webview_window("main") { if let Some(window) = app.get_webview_window("main") {
let _ = window.set_always_on_top(false); 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 Buffer Security Check (/GS) ────────────────────────────────────────
// //
// MSVC's /GS flag instruments functions with stack canaries. These two symbols // 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 // randomises it at startup, but for a statically-linked helper library this
// fixed sentinel is sufficient. // fixed sentinel is sufficient.
@@ -44,15 +44,15 @@ pub unsafe extern "C" fn _Init_thread_header(guard: *mut i32) {
loop { loop {
let val = guard.read_volatile(); let val = guard.read_volatile();
if val == 0 { if val == 0 {
// Already initialised tell caller to skip // Already initialised -- tell caller to skip
return; return;
} }
if val == -1 { if val == -1 {
// Not yet initialised try to claim it // Not yet initialised -- try to claim it
guard.write_volatile(1); guard.write_volatile(1);
return; return;
} }
// val == 1: another thread is initialising yield and retry // val == 1: another thread is initialising -- yield and retry
std::thread::yield_now(); 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, // 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. // 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`. /// An empty struct constant used as a tag for nothrow `new`.
#[export_name = "?nothrow@std@@3Unothrow_t@1@B"] #[export_name = "?nothrow@std@@3Unothrow_t@1@B"]
pub static MSVC_STD_NOTHROW: u8 = 0; 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` /// MSVC-mangled: `??2@YAPEAX_KAEBUnothrow_t@std@@@Z`
#[export_name = "??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 { 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 size = if size == 0 { 1 } else { size };
let layout = std::alloc::Layout::from_size_align_unchecked(size, 8); let layout = std::alloc::Layout::from_size_align_unchecked(size, 8);
let ptr = std::alloc::alloc(layout); 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` /// MSVC-mangled: `??3@YAXPEAX_K@Z`
#[export_name = "??3@YAXPEAX_K@Z"] #[export_name = "??3@YAXPEAX_K@Z"]
pub unsafe extern "C" fn msvc_operator_delete_sized(ptr: *mut u8, size: usize) { pub unsafe extern "C" fn msvc_operator_delete_sized(ptr: *mut u8, size: usize) {
+1 -11
View File
@@ -3,7 +3,6 @@ use std::collections::HashMap;
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
/// A single day's break statistics.
#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct DayRecord { pub struct DayRecord {
@@ -16,7 +15,6 @@ pub struct DayRecord {
pub natural_break_time_secs: u64, pub natural_break_time_secs: u64,
} }
/// Persistent stats stored as JSON.
#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct StatsData { pub struct StatsData {
pub days: HashMap<String, DayRecord>, pub days: HashMap<String, DayRecord>,
@@ -24,12 +22,10 @@ pub struct StatsData {
pub best_streak: u32, pub best_streak: u32,
} }
/// Runtime stats manager.
pub struct Stats { pub struct Stats {
pub data: StatsData, pub data: StatsData,
} }
/// Snapshot sent to the frontend.
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct StatsSnapshot { pub struct StatsSnapshot {
@@ -47,13 +43,11 @@ pub struct StatsSnapshot {
pub daily_goal_met: bool, pub daily_goal_met: bool,
} }
/// F10: Result of recording a completed break
pub struct BreakCompletedResult { pub struct BreakCompletedResult {
pub milestone_reached: Option<u32>, pub milestone_reached: Option<u32>,
pub daily_goal_just_met: bool, pub daily_goal_just_met: bool,
} }
/// F7: Weekly summary for reports
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct WeekSummary { pub struct WeekSummary {
@@ -68,7 +62,7 @@ pub struct WeekSummary {
const MILESTONES: &[u32] = &[3, 5, 7, 14, 21, 30, 50, 100, 365]; const MILESTONES: &[u32] = &[3, 5, 7, 14, 21, 30, 50, 100, 365];
impl Stats { impl Stats {
/// Portable: stats file lives next to the exe // Portable: next to the exe
fn stats_path() -> Option<PathBuf> { fn stats_path() -> Option<PathBuf> {
let exe_path = std::env::current_exe().ok()?; let exe_path = std::env::current_exe().ok()?;
let exe_dir = exe_path.parent()?; let exe_dir = exe_path.parent()?;
@@ -114,7 +108,6 @@ impl Stats {
}) })
} }
/// Record a completed break. Returns milestone/goal info for gamification.
pub fn record_break_completed(&mut self, duration_secs: u64, daily_goal: u32) -> BreakCompletedResult { pub fn record_break_completed(&mut self, duration_secs: u64, daily_goal: u32) -> BreakCompletedResult {
let day = self.today_mut(); let day = self.today_mut();
let was_below_goal = day.breaks_completed < daily_goal; let was_below_goal = day.breaks_completed < daily_goal;
@@ -182,7 +175,6 @@ impl Stats {
} }
} }
/// F10: Check if current streak exactly matches a milestone
fn check_milestone(&self) -> Option<u32> { fn check_milestone(&self) -> Option<u32> {
let streak = self.data.current_streak; let streak = self.data.current_streak;
if MILESTONES.contains(&streak) { if MILESTONES.contains(&streak) {
@@ -225,7 +217,6 @@ impl Stats {
} }
} }
/// Get recent N days of history, sorted chronologically.
pub fn recent_days(&self, n: u32) -> Vec<DayRecord> { pub fn recent_days(&self, n: u32) -> Vec<DayRecord> {
let today = chrono::Local::now().date_naive(); let today = chrono::Local::now().date_naive();
let mut records = Vec::new(); let mut records = Vec::new();
@@ -243,7 +234,6 @@ impl Stats {
records records
} }
/// F7: Get weekly summaries for the past N weeks
pub fn weekly_summary(&self, weeks: u32) -> Vec<WeekSummary> { pub fn weekly_summary(&self, weeks: u32) -> Vec<WeekSummary> {
let today = chrono::Local::now().date_naive(); let today = chrono::Local::now().date_naive();
let mut summaries = Vec::new(); let mut summaries = Vec::new();
+2 -15
View File
@@ -26,7 +26,6 @@ pub enum AppView {
Stats, Stats,
} }
/// Snapshot of the full timer state, sent to the frontend on every tick
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct TimerSnapshot { pub struct TimerSnapshot {
@@ -74,7 +73,6 @@ pub struct TimerSnapshot {
pub is_long_break: bool, pub is_long_break: bool,
} }
/// Events emitted by the timer to the frontend
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct BreakStartedPayload { pub struct BreakStartedPayload {
@@ -161,8 +159,6 @@ impl TimerManager {
} }
} }
/// Check idle state and auto-pause/resume accordingly.
/// Returns IdleCheckResult indicating what happened.
pub fn check_idle(&mut self) -> IdleCheckResult { pub fn check_idle(&mut self) -> IdleCheckResult {
if !self.config.idle_detection_enabled { if !self.config.idle_detection_enabled {
// If idle detection disabled but we were idle-paused, resume // If idle detection disabled but we were idle-paused, resume
@@ -218,7 +214,6 @@ impl TimerManager {
} }
} }
/// F2: Check if the foreground window is fullscreen (presentation mode)
pub fn check_presentation_mode(&mut self) -> bool { pub fn check_presentation_mode(&mut self) -> bool {
if !self.config.presentation_mode_enabled { if !self.config.presentation_mode_enabled {
self.presentation_mode_active = false; self.presentation_mode_active = false;
@@ -230,7 +225,6 @@ impl TimerManager {
fs fs
} }
/// Called every second. Returns what events should be emitted.
pub fn tick(&mut self) -> TickResult { pub fn tick(&mut self) -> TickResult {
// Idle detection and natural break detection // Idle detection and natural break detection
let idle_result = self.check_idle(); let idle_result = self.check_idle();
@@ -322,7 +316,6 @@ impl TimerManager {
} }
} }
/// F1: Called every second for microbreak logic. Returns microbreak events.
pub fn tick_microbreak(&mut self) -> MicrobreakTickResult { pub fn tick_microbreak(&mut self) -> MicrobreakTickResult {
if !self.config.microbreak_enabled { if !self.config.microbreak_enabled {
return MicrobreakTickResult::None; return MicrobreakTickResult::None;
@@ -416,7 +409,6 @@ impl TimerManager {
self.snoozes_used = 0; self.snoozes_used = 0;
} }
/// F3: Advance the Pomodoro cycle position after a break completes
fn advance_pomodoro_cycle(&mut self) { fn advance_pomodoro_cycle(&mut self) {
if !self.config.pomodoro_enabled { if !self.config.pomodoro_enabled {
return; return;
@@ -472,14 +464,14 @@ impl TimerManager {
let past_half = total > 0 && elapsed * 2 >= total; let past_half = total > 0 && elapsed * 2 >= total;
if past_half && self.config.allow_end_early { if past_half && self.config.allow_end_early {
// "End break" counts as completed // "End break" -- counts as completed
self.has_had_break = true; self.has_had_break = true;
self.seconds_since_last_break = 0; self.seconds_since_last_break = 0;
self.advance_pomodoro_cycle(); self.advance_pomodoro_cycle();
self.reset_timer(); self.reset_timer();
true true
} else if !past_half { } else if !past_half {
// "Cancel break" doesn't count // "Cancel break" -- doesn't count
// F3: Pomodoro reset-on-skip // F3: Pomodoro reset-on-skip
if self.config.pomodoro_enabled && self.config.pomodoro_reset_on_skip { if self.config.pomodoro_enabled && self.config.pomodoro_reset_on_skip {
self.pomodoro_cycle_position = 0; self.pomodoro_cycle_position = 0;
@@ -672,14 +664,12 @@ pub enum TickResult {
BreakDeferred, // F2 BreakDeferred, // F2
} }
/// F1: Microbreak tick result
pub enum MicrobreakTickResult { pub enum MicrobreakTickResult {
None, None,
MicrobreakStarted, MicrobreakStarted,
MicrobreakEnded, MicrobreakEnded,
} }
/// Result of checking idle state
pub enum IdleCheckResult { pub enum IdleCheckResult {
None, None,
JustPaused, JustPaused,
@@ -687,7 +677,6 @@ pub enum IdleCheckResult {
NaturalBreakDetected(u64), // duration in seconds NaturalBreakDetected(u64), // duration in seconds
} }
/// Returns the number of seconds since last user input (mouse/keyboard).
#[cfg(windows)] #[cfg(windows)]
pub fn get_idle_seconds() -> u64 { pub fn get_idle_seconds() -> u64 {
use std::mem; use std::mem;
@@ -712,7 +701,6 @@ pub fn get_idle_seconds() -> u64 {
0 0
} }
/// F2: Check if the foreground window is a fullscreen application
#[cfg(windows)] #[cfg(windows)]
pub fn is_foreground_fullscreen() -> bool { pub fn is_foreground_fullscreen() -> bool {
use std::mem; use std::mem;
@@ -756,7 +744,6 @@ pub fn is_foreground_fullscreen() -> bool {
false false
} }
/// F9: Get all monitor rects for multi-monitor break enforcement
#[cfg(windows)] #[cfg(windows)]
pub fn get_all_monitors() -> Vec<MonitorInfo> { pub fn get_all_monitors() -> Vec<MonitorInfo> {
use winapi::shared::windef::{HMONITOR, HDC, LPRECT}; use winapi::shared::windef::{HMONITOR, HDC, LPRECT};
+2 -2
View File
@@ -62,7 +62,7 @@
return () => mq.removeEventListener("change", handler); 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 DURATION = $derived(reducedMotion ? 0 : 700);
const easing = cubicOut; const easing = cubicOut;
@@ -83,7 +83,7 @@
settings: "Settings", settings: "Settings",
stats: "Statistics", 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, // When fullscreen_mode is OFF, the separate break window handles breaks,
+2 -2
View File
@@ -86,7 +86,7 @@
} }
}); });
// Map raw 0.61.0 scale to 0.91.6 range for visible breathing text // Map raw 0.6-1.0 scale to 0.9-1.6 range for visible breathing text
const textScale = $derived(0.9 + (breathScale - 0.6) * (0.7 / 0.4)); const textScale = $derived(0.9 + (breathScale - 0.6) * (0.7 / 0.4));
// Interpolate color between break_color (inhale) and accent_color (exhale) // Interpolate color between break_color (inhale) and accent_color (exhale)
@@ -483,7 +483,7 @@
background: rgba(255, 255, 255, 0.03); 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 { .ripple-container {
position: absolute; position: absolute;
inset: 0; inset: 0;
+2 -2
View File
@@ -259,7 +259,7 @@
</button> </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="group flex items-center justify-center min-h-[44px] min-w-[44px] bg-transparent border-none p-0" class="group flex items-center justify-center min-h-[44px] min-w-[44px] bg-transparent border-none p-0"
@@ -277,7 +277,7 @@
</button> </button>
</div> </div>
<!-- Inline custom picker slides open/closed --> <!-- Inline custom picker -- slides open/closed -->
{#if showCustom} {#if showCustom}
<div <div
class="flex flex-col gap-3 rounded-xl bg-[#0f0f0f] p-3 border border-[#1a1a1a]" 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. // Gap between ring and button, compensating for CSS transform phantom space.
// transform: scale() doesn't affect layout, so the 280px box stays full-size // 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 ringSize = 280;
const phantomBelow = $derived((ringSize * (1 - ringScale)) / 2); const phantomBelow = $derived((ringSize * (1 - ringScale)) / 2);
const targetGap = $derived(Math.max(16, Math.round((windowH - 400) * 0.05 + 16))); 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"/> <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 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> </div>
{#if activity && $config.microbreak_show_activity} {#if activity && $config.microbreak_show_activity}
+3 -3
View File
@@ -88,7 +88,7 @@
return milestones.find((m) => m > current) ?? null; return milestones.find((m) => m > current) ?? null;
}); });
// Chart rendering 7-day // Chart rendering -- 7-day
let chartCanvas: HTMLCanvasElement | undefined = $state(); let chartCanvas: HTMLCanvasElement | undefined = $state();
$effect(() => { $effect(() => {
@@ -96,7 +96,7 @@
drawChart(chartCanvas, history); drawChart(chartCanvas, history);
}); });
// Chart rendering 30-day // Chart rendering -- 30-day
let monthChartCanvas: HTMLCanvasElement | undefined = $state(); let monthChartCanvas: HTMLCanvasElement | undefined = $state();
$effect(() => { $effect(() => {
@@ -154,7 +154,7 @@
ctx.fill(); 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) { if (data.length <= 7 || i % 5 === 0) {
ctx.fillStyle = "#a8a8a8"; ctx.fillStyle = "#a8a8a8";
ctx.font = "10px -apple-system, sans-serif"; ctx.font = "10px -apple-system, sans-serif";
+2 -2
View File
@@ -7,7 +7,7 @@
let { value, onchange, countdownFont = "" }: Props = $props(); 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 displayHours = $state(0);
let displayMinutes = $state(0); let displayMinutes = $state(0);
let isAnimating = $state(false); // true during drag OR momentum let isAnimating = $state(false); // true during drag OR momentum
@@ -373,7 +373,7 @@
border-color: rgba(255, 255, 255, 0.18); 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 { .wheel-viewport {
width: 100%; width: 100%;
height: 100%; height: 100%;
+2 -2
View File
@@ -55,7 +55,7 @@
aria-label={label} aria-label={label}
aria-valuetext={valueText} 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 <svg
aria-hidden="true" aria-hidden="true"
width={viewSize} width={viewSize}
@@ -145,7 +145,7 @@
{/if} {/if}
</svg> </svg>
<!-- Non-glow SVG exact size, draws the track + crisp ring --> <!-- Non-glow SVG -- exact size, draws the track + crisp ring -->
<svg <svg
aria-hidden="true" aria-hidden="true"
width={size} width={size}
+3 -3
View File
@@ -4,12 +4,12 @@
const appWindow = getCurrentWebviewWindow(); const appWindow = getCurrentWebviewWindow();
</script> </script>
<!-- Invisible drag region traffic lights on the right --> <!-- Invisible drag region - traffic lights on the right -->
<header <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"
> >
<!-- Centered app name (decorative OS window title handles screen readers) --> <!-- Centered app name (decorative -- OS window title handles screen readers) -->
<span <span
aria-hidden="true" aria-hidden="true"
class="pointer-events-none absolute inset-0 flex items-center justify-center class="pointer-events-none absolute inset-0 flex items-center justify-center
@@ -61,7 +61,7 @@
</span> </span>
</button> </button>
<!-- Close (red) rightmost --> <!-- Close (red) -- rightmost -->
<button <button
aria-label="Close" aria-label="Close"
class="group/btn relative flex h-[44px] w-[44px] items-center justify-center" 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: "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: "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: "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: "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: "Cup your hands over closed eyes and breathe deeply" },
{ category: "eyes", text: "Trace a figure-8 with your eyes, slowly" }, { category: "eyes", text: "Trace a figure-8 with your eyes, slowly" },
{ category: "eyes", text: "Look out the nearest window and find the farthest point" }, { 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: "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: "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: "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: "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" }, { category: "eyes", text: "Trace the outline of a window frame with your eyes, slowly" },
// Stretches // 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: "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: "Extend each arm across your chest, hold for 15 seconds" },
{ category: "stretch", text: "Open and close your hands, spreading fingers wide" }, { 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: "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: "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" }, { category: "stretch", text: "Pull each finger gently backward for 3 seconds to stretch your forearms" },
@@ -46,20 +46,20 @@ export const breakActivities: BreakActivity[] = [
// Breathing // Breathing
{ category: "breathing", text: "Inhale for 4 seconds, hold for 4, exhale for 6" }, { 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: "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: "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: "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: "Alternate nostril breathing: 3 cycles each side" },
{ category: "breathing", text: "Exhale completely, then take the deepest breath you can" }, { 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: "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: "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: "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: "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: "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: "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: "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" }, { 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: "Shake out your hands and arms for 10 seconds" },
{ category: "movement", text: "Stand up, touch your toes, and slowly rise" }, { 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: "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: "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: "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: "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: "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: "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" }, { category: "movement", text: "Stand with your back against a wall and slide down into a gentle wall sit for 15 seconds" },
]; ];
+5 -31
View File
@@ -1,6 +1,6 @@
import { animate } from "motion"; import { animate } from "motion";
// Module-level reduced motion query shared across all actions // Module-level reduced motion query -- shared across all actions
const reducedMotionQuery = const reducedMotionQuery =
typeof window !== "undefined" typeof window !== "undefined"
? window.matchMedia("(prefers-reduced-motion: reduce)") ? window.matchMedia("(prefers-reduced-motion: reduce)")
@@ -10,9 +10,6 @@ function prefersReducedMotion(): boolean {
return reducedMotionQuery?.matches ?? false; return reducedMotionQuery?.matches ?? false;
} }
/**
* Svelte action: fade in + slide up on mount
*/
export function fadeIn( export function fadeIn(
node: HTMLElement, node: HTMLElement,
options?: { duration?: number; delay?: number; y?: number }, options?: { duration?: number; delay?: number; y?: number },
@@ -38,9 +35,6 @@ export function fadeIn(
}; };
} }
/**
* Svelte action: scale in + fade on mount
*/
export function scaleIn( export function scaleIn(
node: HTMLElement, node: HTMLElement,
options?: { duration?: number; delay?: number }, options?: { duration?: number; delay?: number },
@@ -66,9 +60,6 @@ export function scaleIn(
}; };
} }
/**
* Svelte action: animate when scrolled into view (IntersectionObserver)
*/
export function inView( export function inView(
node: HTMLElement, node: HTMLElement,
options?: { delay?: number; y?: number; threshold?: number }, options?: { delay?: number; y?: number; threshold?: number },
@@ -111,9 +102,6 @@ export function inView(
}; };
} }
/**
* Svelte action: spring-scale press feedback on buttons
*/
export function pressable(node: HTMLElement) { export function pressable(node: HTMLElement) {
if (prefersReducedMotion()) { if (prefersReducedMotion()) {
return { destroy() {} }; return { destroy() {} };
@@ -171,14 +159,7 @@ 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( export function glowHover(
node: HTMLElement, node: HTMLElement,
options?: { color?: string }, options?: { color?: string },
@@ -236,14 +217,7 @@ 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) { export function dragScroll(node: HTMLElement) {
if (prefersReducedMotion()) { if (prefersReducedMotion()) {
// Allow normal scrolling without the momentum/elastic physics // Allow normal scrolling without the momentum/elastic physics
@@ -293,7 +267,7 @@ export function dragScroll(node: HTMLElement) {
// Ensure DOM is clean after animation completes // Ensure DOM is clean after animation completes
springAnim.finished.then(() => { springAnim.finished.then(() => {
if (content) content.style.transform = ""; if (content) content.style.transform = "";
}).catch(() => { /* cancelled onDown handles cleanup */ }); }).catch(() => { /* cancelled -- onDown handles cleanup */ });
} }
function forceReset() { function forceReset() {
@@ -370,7 +344,7 @@ export function dragScroll(node: HTMLElement) {
// Time-based exponential decay (iOS-style scroll physics). // Time-based exponential decay (iOS-style scroll physics).
// position(t) = start + v0 * tau * (1 - e^(-t/tau)) // position(t) = start + v0 * tau * (1 - e^(-t/tau))
// velocity(t) = v0 * e^(-t/tau) → naturally asymptotes to zero // 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 coastStart = performance.now();
const scrollStart2 = node.scrollTop; const scrollStart2 = node.scrollTop;
const totalDist = v0 * tau; const totalDist = v0 * tau;
+7 -18
View File
@@ -1,8 +1,3 @@
/**
* Synthesized notification sounds using the Web Audio API.
* No external audio files needed — all sounds are generated programmatically.
*/
let audioCtx: AudioContext | null = null; let audioCtx: AudioContext | null = null;
function getAudioContext(): AudioContext { function getAudioContext(): AudioContext {
@@ -14,11 +9,6 @@ function getAudioContext(): AudioContext {
type SoundPreset = "bell" | "chime" | "soft" | "digital" | "harp" | "bowl" | "rain" | "whistle"; 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 { export function playSound(preset: SoundPreset, volume: number): void {
const ctx = getAudioContext(); const ctx = getAudioContext();
const gain = ctx.createGain(); const gain = ctx.createGain();
@@ -53,7 +43,7 @@ export function playSound(preset: SoundPreset, volume: number): void {
} }
} }
/** Warm bell two sine tones with harmonics and slow decay */ /** Warm bell -- two sine tones with harmonics and slow decay */
function playBell(ctx: AudioContext, destination: GainNode, vol: number) { function playBell(ctx: AudioContext, destination: GainNode, vol: number) {
const now = ctx.currentTime; const now = ctx.currentTime;
@@ -103,7 +93,7 @@ function playChime(ctx: AudioContext, destination: GainNode, vol: number) {
}); });
} }
/** Gentle soft ping filtered triangle wave */ /** Gentle soft ping -- filtered triangle wave */
function playSoft(ctx: AudioContext, destination: GainNode, vol: number) { function playSoft(ctx: AudioContext, destination: GainNode, vol: number) {
const now = ctx.currentTime; const now = ctx.currentTime;
@@ -129,7 +119,7 @@ function playSoft(ctx: AudioContext, destination: GainNode, vol: number) {
osc.stop(now + 1.2); 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) { function playDigital(ctx: AudioContext, destination: GainNode, vol: number) {
const now = ctx.currentTime; const now = ctx.currentTime;
@@ -149,7 +139,7 @@ function playDigital(ctx: AudioContext, destination: GainNode, vol: number) {
} }
} }
/** Harp cascading arpeggiated sine tones (C5-E5-G5-C6) */ /** Harp -- cascading arpeggiated sine tones (C5-E5-G5-C6) */
function playHarp(ctx: AudioContext, destination: GainNode, vol: number) { function playHarp(ctx: AudioContext, destination: GainNode, vol: number) {
const now = ctx.currentTime; const now = ctx.currentTime;
const notes = [523.25, 659.25, 783.99, 1046.5]; // C5, E5, G5, C6 const notes = [523.25, 659.25, 783.99, 1046.5]; // C5, E5, G5, C6
@@ -170,7 +160,7 @@ function playHarp(ctx: AudioContext, destination: GainNode, vol: number) {
}); });
} }
/** Singing bowl low sine with slow beating from detuned pair */ /** Singing bowl -- low sine with slow beating from detuned pair */
function playBowl(ctx: AudioContext, destination: GainNode, vol: number) { function playBowl(ctx: AudioContext, destination: GainNode, vol: number) {
const now = ctx.currentTime; const now = ctx.currentTime;
@@ -201,7 +191,7 @@ function playBowl(ctx: AudioContext, destination: GainNode, vol: number) {
osc3.stop(now + 1.5); 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) { function playRain(ctx: AudioContext, destination: GainNode, vol: number) {
const now = ctx.currentTime; const now = ctx.currentTime;
const bufferSize = ctx.sampleRate * 1; const bufferSize = ctx.sampleRate * 1;
@@ -233,7 +223,7 @@ function playRain(ctx: AudioContext, destination: GainNode, vol: number) {
noise.stop(now + 1.0); 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) { function playWhistle(ctx: AudioContext, destination: GainNode, vol: number) {
const now = ctx.currentTime; const now = ctx.currentTime;
@@ -256,7 +246,6 @@ function playWhistle(ctx: AudioContext, destination: GainNode, vol: number) {
osc.stop(now + 1.0); osc.stop(now + 1.0);
} }
/** Play a completion sound — slightly different from start (descending) */
export function playBreakEndSound(preset: SoundPreset, volume: number): void { export function playBreakEndSound(preset: SoundPreset, volume: number): void {
const ctx = getAudioContext(); const ctx = getAudioContext();
const gain = ctx.createGain(); const gain = ctx.createGain();