Initial commit - Core Cooldown v0.1.0

Portable Windows break timer to prevent RSI and eye strain.
Tauri v2 + Svelte 5 + Tailwind CSS v4. No installer, no telemetry,
no data leaves the machine. CC0 public domain.
This commit is contained in:
Your Name
2026-02-07 01:12:32 +02:00
commit 0cbd8abad4
48 changed files with 15133 additions and 0 deletions

View File

@@ -0,0 +1,417 @@
<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>