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>
|