Portable Windows break timer to prevent RSI and eye strain. Tauri v2 + Svelte 5 + Tailwind CSS v4. No installer, no telemetry, no data leaves the machine. CC0 public domain.
418 lines
13 KiB
Svelte
418 lines
13 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 { scaleIn, fadeIn, pressable } from "../utils/animate";
|
|
import { pickRandomActivity, getCategoryIcon, getCategoryLabel, type BreakActivity } from "../utils/activities";
|
|
|
|
interface Props {
|
|
standalone?: boolean;
|
|
}
|
|
|
|
let { standalone = false }: Props = $props();
|
|
|
|
const appWindow = standalone ? getCurrentWebviewWindow() : null;
|
|
|
|
let currentActivity = $state<BreakActivity>(pickRandomActivity());
|
|
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);
|
|
}, 30_000);
|
|
}
|
|
return () => {
|
|
if (activityCycleTimer) {
|
|
clearInterval(activityCycleTimer);
|
|
activityCycleTimer = null;
|
|
}
|
|
};
|
|
});
|
|
|
|
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);
|
|
|
|
// 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);
|
|
</script>
|
|
|
|
{#if standalone}
|
|
<!-- ── Standalone break window: horizontal card, transparent background ── -->
|
|
<div class="standalone-card" use:scaleIn={{ duration: 0.5 }}>
|
|
<!-- Ripples emanate from the ring, visible outside the card -->
|
|
<div class="standalone-ring-area">
|
|
<div class="ripple-container">
|
|
<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>
|
|
<div class="break-breathe">
|
|
<TimerRing
|
|
progress={breakRingProgress}
|
|
size={140}
|
|
strokeWidth={5}
|
|
accentColor={$config.break_color}
|
|
>
|
|
<div class="break-breathe-counter">
|
|
<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>
|
|
</div>
|
|
</TimerRing>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Right side: text + buttons -->
|
|
<div class="standalone-content">
|
|
<h2 class="text-[17px] font-medium text-white mb-1.5">
|
|
{$timer.breakTitle}
|
|
</h2>
|
|
<p class="text-[12px] leading-relaxed text-[#666] 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-[#555] uppercase mb-1">
|
|
{getCategoryLabel(currentActivity.category)}
|
|
</div>
|
|
<p class="text-[12px] leading-relaxed text-[#999]">
|
|
{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-[#666] uppercase
|
|
transition-colors duration-200
|
|
hover:border-[#444] hover:text-[#aaa]"
|
|
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-[#333]">
|
|
{$timer.snoozesUsed}/{$config.snooze_limit} snoozes used
|
|
</p>
|
|
{/if}
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Bottom progress bar with clip-path -->
|
|
<div class="standalone-progress-container">
|
|
<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"
|
|
class:flex={isModal}
|
|
class:items-center={isModal}
|
|
class:justify-center={isModal}
|
|
style={isModal ? `background: #000;` : ""}
|
|
>
|
|
<div
|
|
class="relative flex flex-col"
|
|
class:h-full={!isModal}
|
|
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">
|
|
<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>
|
|
|
|
<div class="break-breathe relative">
|
|
<TimerRing
|
|
progress={breakRingProgress}
|
|
size={isModal ? 160 : 200}
|
|
strokeWidth={isModal ? 5 : 6}
|
|
accentColor={$config.break_color}
|
|
>
|
|
<div class="break-breathe-counter">
|
|
<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>
|
|
</div>
|
|
</TimerRing>
|
|
</div>
|
|
</div>
|
|
|
|
<h2 class="mb-2 text-lg font-medium text-white" use:fadeIn={{ delay: 0.25, y: 10 }}>
|
|
{$timer.breakTitle}
|
|
</h2>
|
|
|
|
<p
|
|
class="max-w-[300px] text-center text-[13px] leading-relaxed text-[#555]"
|
|
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-[#444] uppercase">
|
|
{getCategoryLabel(currentActivity.category)}
|
|
</div>
|
|
<p class="text-[13px] leading-relaxed text-[#999]">
|
|
{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-[#222] px-6 py-2.5 text-[12px]
|
|
tracking-wider text-[#555] uppercase
|
|
transition-colors duration-200
|
|
hover:border-[#333] hover:text-[#999]"
|
|
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-[#2a2a2a]">
|
|
{$timer.snoozesUsed}/{$config.snooze_limit} snoozes used
|
|
</p>
|
|
{/if}
|
|
{/if}
|
|
|
|
<!-- Bottom progress bar for modal - uses clip-path to respect border radius -->
|
|
<div class="break-modal-progress-container">
|
|
<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>
|
|
</div>
|
|
</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);
|
|
}
|
|
|
|
/* ── Breathing pulse on the ring ── */
|
|
.break-breathe {
|
|
animation: breathe 4s ease-in-out infinite;
|
|
}
|
|
@keyframes breathe {
|
|
0%, 100% { transform: scale(1); }
|
|
50% { transform: scale(1.04); }
|
|
}
|
|
|
|
.break-breathe-counter {
|
|
animation: breathe-counter 4s ease-in-out infinite;
|
|
}
|
|
@keyframes breathe-counter {
|
|
0%, 100% { transform: scale(1); }
|
|
50% { transform: scale(0.962); }
|
|
}
|
|
|
|
/* ── Ripple circles ── */
|
|
.break-ripple {
|
|
position: absolute;
|
|
width: 140px;
|
|
height: 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.3;
|
|
}
|
|
100% {
|
|
transform: scale(2.2);
|
|
opacity: 0;
|
|
}
|
|
}
|
|
</style>
|