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