- Dashboard: text-text-sec tokens, nav landmark, toast hover persistence, goal progressbar ARIA, pomodoro sr-only text - Settings: h3→h2 heading hierarchy, section aria-labelledby with ids, Working Hours heading added - StatsView: h3→h2, tablist/tab/tabpanel ARIA pattern, sr-only data tables for 30-day chart and heatmap, contrast tokens - BreakScreen: strict-mode focus safety span, breathing phase-only announcements, contrast tokens - Celebration: JS-controlled hover/focus persistence, dismiss buttons, Escape key, removed pointer-events:none - Titlebar: removed redundant role="banner" on <header>
552 lines
19 KiB
Svelte
552 lines
19 KiB
Svelte
<script lang="ts">
|
||
import { invoke } from "@tauri-apps/api/core";
|
||
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
||
import { timer, currentView, formatTime, type TimerSnapshot } from "../stores/timer";
|
||
import { config } from "../stores/config";
|
||
import TimerRing from "./TimerRing.svelte";
|
||
import { onMount } from "svelte";
|
||
import { scaleIn, fadeIn, pressable } from "../utils/animate";
|
||
import { pickRandomActivity, getCategoryIcon, getCategoryLabel, type BreakActivity } from "../utils/activities";
|
||
import BreathingGuide from "./BreathingGuide.svelte";
|
||
|
||
interface Props {
|
||
standalone?: boolean;
|
||
}
|
||
|
||
let { standalone = false }: Props = $props();
|
||
|
||
const appWindow = $derived(standalone ? getCurrentWebviewWindow() : null);
|
||
|
||
let currentActivity = $state<BreakActivity>(pickRandomActivity(undefined, $config));
|
||
let activityCycleTimer: ReturnType<typeof setInterval> | null = null;
|
||
|
||
// Cycle activity every 30 seconds during break
|
||
$effect(() => {
|
||
if ($config.show_break_activities && $timer.state === "breakActive") {
|
||
activityCycleTimer = setInterval(() => {
|
||
currentActivity = pickRandomActivity(currentActivity, $config);
|
||
}, 30_000);
|
||
}
|
||
return () => {
|
||
if (activityCycleTimer) {
|
||
clearInterval(activityCycleTimer);
|
||
activityCycleTimer = null;
|
||
}
|
||
};
|
||
});
|
||
|
||
// F3: Long break indicator
|
||
const isLongBreak = $derived($timer.isLongBreak);
|
||
|
||
async function cancelBreak() {
|
||
const snap = await invoke<TimerSnapshot>("cancel_break");
|
||
timer.set(snap);
|
||
if (standalone) {
|
||
appWindow?.hide();
|
||
} else {
|
||
currentView.set(snap.currentView);
|
||
}
|
||
}
|
||
|
||
async function snoozeBreak() {
|
||
const snap = await invoke<TimerSnapshot>("snooze");
|
||
timer.set(snap);
|
||
// If snooze ended the break, hide standalone window
|
||
if (standalone && snap.state !== "breakActive") {
|
||
appWindow?.hide();
|
||
}
|
||
}
|
||
|
||
const breakRingProgress = $derived(
|
||
$timer.breakTotalDuration > 0
|
||
? $timer.breakTimeRemaining / $timer.breakTotalDuration
|
||
: 0,
|
||
);
|
||
|
||
const cancelBtnText = $derived(
|
||
$timer.breakPastHalf && $config.allow_end_early ? "End break" : "Skip",
|
||
);
|
||
|
||
const showButtons = $derived(!$config.strict_mode);
|
||
|
||
// Breathing guide bindable state
|
||
let breathPhase = $state("Inhale");
|
||
let breathCountdown = $state(4);
|
||
let breathScale = $state(0.6);
|
||
|
||
// Only announce phase name changes (not countdown ticks) to screen readers
|
||
let breathAnnouncement = $state("");
|
||
let lastBreathPhase = $state("");
|
||
$effect(() => {
|
||
// Extract just the phase name (e.g., "Inhale" from "Inhale 4")
|
||
const phaseName = breathPhase?.split(' ')[0] ?? "";
|
||
if (phaseName && phaseName !== lastBreathPhase) {
|
||
lastBreathPhase = phaseName;
|
||
breathAnnouncement = phaseName;
|
||
}
|
||
});
|
||
|
||
// Map raw 0.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));
|
||
|
||
// Interpolate color between break_color (inhale) and accent_color (exhale)
|
||
// breathScale: 0.6 = exhale (accent), 1.0 = inhale (break)
|
||
function hexToRgb(hex: string): [number, number, number] {
|
||
const h = hex.replace("#", "");
|
||
return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];
|
||
}
|
||
function lerpColor(c1: string, c2: string, t: number): string {
|
||
const [r1, g1, b1] = hexToRgb(c1);
|
||
const [r2, g2, b2] = hexToRgb(c2);
|
||
const r = Math.round(r1 + (r2 - r1) * t);
|
||
const g = Math.round(g1 + (g2 - g1) * t);
|
||
const b = Math.round(b1 + (b2 - b1) * t);
|
||
return `rgb(${r},${g},${b})`;
|
||
}
|
||
// t=0 at exhale (scale=0.6), t=1 at inhale (scale=1.0)
|
||
const breathT = $derived((breathScale - 0.6) / 0.4);
|
||
const breathColor = $derived(lerpColor($config.accent_color, $config.break_color, breathT));
|
||
|
||
// Bottom progress bar uses a gradient from break color to accent
|
||
const barGradient = $derived(
|
||
`linear-gradient(to right, ${$config.break_color}, ${$config.accent_color})`,
|
||
);
|
||
|
||
const isModal = $derived(!$config.fullscreen_mode && !standalone);
|
||
|
||
// Focus trap: keep Tab cycling within break screen
|
||
let breakContainer = $state<HTMLElement>(undefined!);
|
||
$effect(() => {
|
||
if (!breakContainer) return;
|
||
function trapFocus(e: KeyboardEvent) {
|
||
if (e.key !== "Tab") return;
|
||
const focusable = breakContainer.querySelectorAll<HTMLElement>(
|
||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||
);
|
||
if (focusable.length === 0) return;
|
||
const first = focusable[0];
|
||
const last = focusable[focusable.length - 1];
|
||
if (e.shiftKey) {
|
||
if (document.activeElement === first) { e.preventDefault(); last.focus(); }
|
||
} else {
|
||
if (document.activeElement === last) { e.preventDefault(); first.focus(); }
|
||
}
|
||
}
|
||
breakContainer.addEventListener("keydown", trapFocus);
|
||
return () => breakContainer.removeEventListener("keydown", trapFocus);
|
||
});
|
||
</script>
|
||
|
||
{#if standalone}
|
||
<!-- ── Standalone break window: horizontal card, transparent background ── -->
|
||
<div class="standalone-card" use:scaleIn={{ duration: 0.5 }} bind:this={breakContainer}>
|
||
<!-- Ripples emanate from the ring, visible outside the card -->
|
||
<div class="standalone-ring-area">
|
||
<div class="ripple-container" aria-hidden="true">
|
||
<div class="break-ripple ripple-1" style="--ripple-color: {$config.break_color}"></div>
|
||
<div class="break-ripple ripple-2" style="--ripple-color: {$config.break_color}"></div>
|
||
<div class="break-ripple ripple-3" style="--ripple-color: {$config.break_color}"></div>
|
||
</div>
|
||
<!-- Hidden: runs breathing animation logic, we only use its phase data -->
|
||
{#if $config.breathing_guide_enabled}
|
||
<div class="hidden" aria-hidden="true">
|
||
<BreathingGuide
|
||
pattern={$config.breathing_pattern}
|
||
size={0}
|
||
color={$config.break_color}
|
||
showLabel={false}
|
||
bind:phaseLabel={breathPhase}
|
||
bind:countdown={breathCountdown}
|
||
bind:breathScale={breathScale}
|
||
/>
|
||
</div>
|
||
{/if}
|
||
<div>
|
||
<TimerRing
|
||
progress={breakRingProgress}
|
||
size={140}
|
||
strokeWidth={5}
|
||
accentColor={$config.break_color}
|
||
label="Break timer"
|
||
valueText="{formatTime($timer.breakTimeRemaining)} remaining"
|
||
>
|
||
<div class="flex flex-col items-center">
|
||
<span
|
||
class="font-semibold leading-none tabular-nums text-white text-[26px]"
|
||
style={$config.countdown_font ? `font-family: '${$config.countdown_font}', monospace;` : ""}
|
||
>
|
||
{formatTime($timer.breakTimeRemaining)}
|
||
</span>
|
||
{#if $config.breathing_guide_enabled}
|
||
<span
|
||
class="block mt-1.5 text-[9px] tracking-wider uppercase text-center font-medium"
|
||
style="transform: scale({textScale}); color: {breathColor}; opacity: {0.5 + breathT * 0.5}; transition: transform 0.15s ease-out, opacity 0.15s ease-out, color 0.15s ease-out;"
|
||
aria-hidden="true"
|
||
>
|
||
{breathPhase}{breathCountdown > 0 ? ` ${breathCountdown}s` : ""}
|
||
</span>
|
||
<span class="sr-only" aria-live="polite" aria-atomic="true">{breathAnnouncement}</span>
|
||
{/if}
|
||
</div>
|
||
</TimerRing>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Right side: text + buttons -->
|
||
<div class="standalone-content">
|
||
<h2 class="text-[17px] font-medium text-white mb-1.5" tabindex="-1">
|
||
{$timer.breakTitle}
|
||
</h2>
|
||
<p class="text-[12px] leading-relaxed text-text-sec mb-4 max-w-[240px]">
|
||
{$timer.breakMessage}
|
||
</p>
|
||
|
||
{#if $config.show_break_activities}
|
||
<div class="rounded-xl border border-[#ffffff08] bg-[#ffffff06] px-4 py-2.5 mb-4 max-w-[260px]">
|
||
<div class="text-[9px] font-medium tracking-[0.15em] text-text-sec uppercase mb-1">
|
||
{getCategoryLabel(currentActivity.category)}
|
||
</div>
|
||
<p class="text-[12px] leading-relaxed text-text-sec" aria-live="polite">
|
||
{currentActivity.text}
|
||
</p>
|
||
</div>
|
||
{/if}
|
||
|
||
{#if showButtons}
|
||
<div class="flex items-center gap-2.5">
|
||
<button
|
||
use:pressable
|
||
class="rounded-full border border-[#333] px-5 py-2 text-[11px]
|
||
tracking-wider text-text-sec uppercase
|
||
transition-colors duration-200
|
||
hover:border-[#444] hover:text-[#ccc]"
|
||
onclick={cancelBreak}
|
||
>
|
||
{cancelBtnText}
|
||
</button>
|
||
{#if $timer.canSnooze}
|
||
<button
|
||
use:pressable
|
||
class="rounded-full px-5 py-2 text-[11px]
|
||
tracking-wider text-white uppercase
|
||
transition-colors duration-200"
|
||
style="background: rgba(255,255,255,0.08);"
|
||
onclick={snoozeBreak}
|
||
>
|
||
Snooze
|
||
</button>
|
||
{/if}
|
||
</div>
|
||
{#if $config.snooze_limit > 0}
|
||
<p class="mt-2 text-[9px] text-text-sec">
|
||
{$timer.snoozesUsed}/{$config.snooze_limit} snoozes used
|
||
</p>
|
||
{/if}
|
||
{:else}
|
||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||
<span tabindex="0" class="sr-only" aria-live="polite">
|
||
Break in progress, please wait
|
||
</span>
|
||
{/if}
|
||
</div>
|
||
|
||
<!-- Bottom progress bar with clip-path -->
|
||
<div class="standalone-progress-container" role="progressbar" aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round($timer.breakProgress * 100)} aria-label="Break progress">
|
||
<div class="standalone-progress-track">
|
||
<div
|
||
class="h-full transition-[width] duration-1000 ease-linear"
|
||
style="width: {$timer.breakProgress * 100}%; background: {barGradient};"
|
||
></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{:else}
|
||
<!-- ── In-app break screen: full-screen fill OR modal with backdrop ── -->
|
||
<div
|
||
class="relative h-full flex items-center justify-center"
|
||
style="background: #000;"
|
||
bind:this={breakContainer}
|
||
>
|
||
<div
|
||
class="relative flex flex-col items-center"
|
||
class:break-modal={isModal}
|
||
>
|
||
<!-- Break ring with breathing pulse + ripples -->
|
||
<div class="mb-6 relative" use:scaleIn={{ duration: 0.6, delay: 0.1 }}>
|
||
<div class="absolute inset-0 flex items-center justify-center pointer-events-none" aria-hidden="true">
|
||
<div class="break-ripple ripple-1" style="--ripple-color: {$config.break_color}; --ripple-size: {isModal ? 160 : 200}px"></div>
|
||
<div class="break-ripple ripple-2" style="--ripple-color: {$config.break_color}; --ripple-size: {isModal ? 160 : 200}px"></div>
|
||
<div class="break-ripple ripple-3" style="--ripple-color: {$config.break_color}; --ripple-size: {isModal ? 160 : 200}px"></div>
|
||
</div>
|
||
|
||
<!-- Hidden: runs breathing animation logic, we only use its phase data -->
|
||
{#if $config.breathing_guide_enabled}
|
||
<div class="hidden" aria-hidden="true">
|
||
<BreathingGuide
|
||
pattern={$config.breathing_pattern}
|
||
size={0}
|
||
color={$config.break_color}
|
||
showLabel={false}
|
||
bind:phaseLabel={breathPhase}
|
||
bind:countdown={breathCountdown}
|
||
bind:breathScale={breathScale}
|
||
/>
|
||
</div>
|
||
{/if}
|
||
|
||
<div class="relative">
|
||
<TimerRing
|
||
progress={breakRingProgress}
|
||
size={isModal ? 160 : 200}
|
||
strokeWidth={isModal ? 5 : 6}
|
||
accentColor={$config.break_color}
|
||
label="Break timer"
|
||
valueText="{formatTime($timer.breakTimeRemaining)} remaining"
|
||
>
|
||
<div class="flex flex-col items-center">
|
||
<span
|
||
class="font-semibold leading-none tabular-nums text-white"
|
||
class:text-[30px]={isModal}
|
||
class:text-[38px]={!isModal}
|
||
style={$config.countdown_font ? `font-family: '${$config.countdown_font}', monospace;` : ""}
|
||
>
|
||
{formatTime($timer.breakTimeRemaining)}
|
||
</span>
|
||
{#if $config.breathing_guide_enabled}
|
||
<span
|
||
class="block mt-2 tracking-wider uppercase text-center font-medium"
|
||
class:text-[10px]={!isModal}
|
||
class:text-[9px]={isModal}
|
||
style="transform: scale({textScale}); color: {breathColor}; opacity: {0.5 + breathT * 0.5}; transition: transform 0.15s ease-out, opacity 0.15s ease-out, color 0.15s ease-out;"
|
||
aria-hidden="true"
|
||
>
|
||
{breathPhase}{breathCountdown > 0 ? ` ${breathCountdown}s` : ""}
|
||
</span>
|
||
<span class="sr-only" aria-live="polite" aria-atomic="true">{breathAnnouncement}</span>
|
||
{/if}
|
||
</div>
|
||
</TimerRing>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- F3: Long break badge -->
|
||
{#if isLongBreak}
|
||
<div class="mb-2 rounded-full px-3 py-1 text-[10px] font-medium tracking-wider uppercase"
|
||
style="background: {$config.break_color}20; color: {$config.break_color}; border: 1px solid {$config.break_color}30;"
|
||
use:fadeIn={{ delay: 0.2, y: 8 }}
|
||
>
|
||
Long break
|
||
</div>
|
||
{/if}
|
||
|
||
<h2 class="mb-2 text-lg font-medium text-white" tabindex="-1" use:fadeIn={{ delay: 0.25, y: 10 }}>
|
||
{$timer.breakTitle}
|
||
</h2>
|
||
|
||
<p
|
||
class="max-w-[300px] text-center text-[13px] leading-relaxed text-text-sec"
|
||
class:mb-4={$config.show_break_activities}
|
||
class:mb-8={!$config.show_break_activities}
|
||
use:fadeIn={{ delay: 0.35, y: 10 }}
|
||
>
|
||
{$timer.breakMessage}
|
||
</p>
|
||
|
||
{#if $config.show_break_activities}
|
||
<div
|
||
class="mb-8 mx-auto max-w-[320px] rounded-2xl border border-[#1a1a1a] bg-[#0a0a0a] px-5 py-3.5 text-center"
|
||
use:fadeIn={{ delay: 0.4, y: 10 }}
|
||
>
|
||
<div class="mb-1.5 text-[10px] font-medium tracking-[0.15em] text-text-sec uppercase">
|
||
{getCategoryLabel(currentActivity.category)}
|
||
</div>
|
||
<p class="text-[13px] leading-relaxed text-text-sec" aria-live="polite">
|
||
{currentActivity.text}
|
||
</p>
|
||
</div>
|
||
{/if}
|
||
|
||
{#if showButtons}
|
||
<div class="flex items-center gap-3" use:fadeIn={{ delay: 0.45, y: 10 }}>
|
||
<button
|
||
use:pressable
|
||
class="rounded-full border border-border px-6 py-2.5 text-[12px]
|
||
tracking-wider text-text-sec uppercase
|
||
transition-colors duration-200
|
||
hover:border-[#333] hover:text-[#ccc]"
|
||
onclick={cancelBreak}
|
||
>
|
||
{cancelBtnText}
|
||
</button>
|
||
{#if $timer.canSnooze}
|
||
<button
|
||
use:pressable
|
||
class="rounded-full px-6 py-2.5 text-[12px]
|
||
tracking-wider text-white uppercase backdrop-blur-xl
|
||
transition-colors duration-200"
|
||
style="background: rgba(20,20,20,0.7);"
|
||
onclick={snoozeBreak}
|
||
>
|
||
Snooze
|
||
</button>
|
||
{/if}
|
||
</div>
|
||
{#if $config.snooze_limit > 0}
|
||
<p class="mt-3 text-[10px] text-text-sec">
|
||
{$timer.snoozesUsed}/{$config.snooze_limit} snoozes used
|
||
</p>
|
||
{/if}
|
||
{:else}
|
||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||
<span tabindex="0" class="sr-only" aria-live="polite">
|
||
Break in progress, please wait
|
||
</span>
|
||
{/if}
|
||
|
||
<!-- Bottom progress bar for modal -->
|
||
{#if isModal}
|
||
<div class="break-modal-progress-container" role="progressbar" aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round($timer.breakProgress * 100)} aria-label="Break progress">
|
||
<div class="break-modal-progress-track">
|
||
<div
|
||
class="h-full transition-[width] duration-1000 ease-linear"
|
||
style="width: {$timer.breakProgress * 100}%; background: {barGradient};"
|
||
></div>
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
|
||
<!-- Fullscreen progress bar - anchored to bottom of screen -->
|
||
{#if !isModal}
|
||
<div class="absolute bottom-8 left-12 right-12 h-[3px] rounded-full overflow-hidden" role="progressbar" aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round($timer.breakProgress * 100)} aria-label="Break progress">
|
||
<div class="w-full h-full" style="background: rgba(255,255,255,0.03);">
|
||
<div
|
||
class="h-full transition-[width] duration-1000 ease-linear"
|
||
style="width: {$timer.breakProgress * 100}%; background: {barGradient};"
|
||
></div>
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
{/if}
|
||
|
||
<style>
|
||
/* ── Standalone horizontal card ── */
|
||
.standalone-card {
|
||
position: relative;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 28px;
|
||
background: rgba(12, 12, 12, 0.97);
|
||
backdrop-filter: blur(32px);
|
||
-webkit-backdrop-filter: blur(32px);
|
||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||
border-radius: 24px;
|
||
padding: 32px 36px 32px 32px;
|
||
box-shadow:
|
||
0 0 0 1px rgba(0, 0, 0, 0.3),
|
||
0 20px 60px rgba(0, 0, 0, 0.5),
|
||
0 0 80px rgba(0, 0, 0, 0.3);
|
||
overflow: visible;
|
||
}
|
||
|
||
.standalone-ring-area {
|
||
position: relative;
|
||
flex-shrink: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 140px;
|
||
height: 140px;
|
||
}
|
||
|
||
.standalone-content {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
/* Standalone progress bar - positioned inside the padding area */
|
||
.standalone-progress-container {
|
||
position: absolute;
|
||
bottom: 28px;
|
||
left: 28px;
|
||
right: 28px;
|
||
height: 3px;
|
||
border-radius: 2px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.standalone-progress-track {
|
||
width: 100%;
|
||
height: 100%;
|
||
background: rgba(255, 255, 255, 0.03);
|
||
}
|
||
|
||
/* Ripple container — sits behind the ring, overflows the card */
|
||
.ripple-container {
|
||
position: absolute;
|
||
inset: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
pointer-events: none;
|
||
}
|
||
|
||
/* ── Modal card when fullscreen is off (in-app) ── */
|
||
.break-modal {
|
||
position: relative;
|
||
background: #0a0a0a;
|
||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||
border-radius: 28px;
|
||
padding: 40px 32px 32px;
|
||
max-width: 400px;
|
||
width: 90%;
|
||
box-shadow: 0 24px 100px rgba(0, 0, 0, 0.7);
|
||
overflow: hidden;
|
||
isolation: isolate;
|
||
}
|
||
|
||
/* Progress bar - positioned inside padding to avoid rounded corner overflow */
|
||
.break-modal-progress-container {
|
||
position: absolute;
|
||
bottom: 32px;
|
||
left: 32px;
|
||
right: 32px;
|
||
height: 3px;
|
||
border-radius: 2px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.break-modal-progress-track {
|
||
width: 100%;
|
||
height: 100%;
|
||
background: rgba(255, 255, 255, 0.05);
|
||
}
|
||
|
||
/* ── Ripple circles ── */
|
||
.break-ripple {
|
||
position: absolute;
|
||
width: var(--ripple-size, 140px);
|
||
height: var(--ripple-size, 140px);
|
||
border-radius: 50%;
|
||
border: 1.5px solid var(--ripple-color);
|
||
opacity: 0;
|
||
animation: ripple-expand 4s ease-out infinite;
|
||
}
|
||
.ripple-1 { animation-delay: 0s; }
|
||
.ripple-2 { animation-delay: 1.33s; }
|
||
.ripple-3 { animation-delay: 2.66s; }
|
||
|
||
@keyframes ripple-expand {
|
||
0% {
|
||
transform: scale(1);
|
||
opacity: 0.25;
|
||
}
|
||
100% {
|
||
transform: scale(2.5);
|
||
opacity: 0;
|
||
}
|
||
}
|
||
</style>
|