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,289 @@
<script lang="ts">
import { invoke } from "@tauri-apps/api/core";
import {
timer,
currentView,
formatTime,
formatDurationAgo,
type TimerSnapshot,
} from "../stores/timer";
import { config } from "../stores/config";
import TimerRing from "./TimerRing.svelte";
import { scaleIn, fadeIn, pressable, glowHover } from "../utils/animate";
async function toggleTimer() {
const snap = await invoke<TimerSnapshot>("toggle_timer");
timer.set(snap);
}
async function startBreakNow() {
const snap = await invoke<TimerSnapshot>("start_break_now");
timer.set(snap);
currentView.set(snap.currentView);
}
function openSettings() {
invoke("set_view", { view: "settings" });
currentView.set("settings");
}
const statusText = $derived(
$timer.idlePaused
? "IDLE"
: $timer.prebreakWarning
? "BREAK SOON"
: $timer.state === "running"
? "FOCUS"
: "PAUSED",
);
const toggleBtnText = $derived(
$timer.state === "running" ? "PAUSE" : "START",
);
// Responsive ring scaling computed from window dimensions
let windowW = $state(window.innerWidth);
let windowH = $state(window.innerHeight);
// Ring scales 1.0 → 0.6 based on both dimensions
const ringScale = $derived(
Math.min(1, Math.max(0.6, Math.min(
(windowH - 300) / 400,
(windowW - 200) / 300,
))),
);
// Text scales less aggressively: 1.0 → 0.7 (counter-scale inside ring)
// counterScale = textScale / ringScale, where textScale = lerp(0.7, 1.0, (ringScale-0.6)/0.4)
const textCounterScale = $derived(
ringScale < 1
? Math.min(1.2, (0.7 + (ringScale - 0.6) * 0.75) / ringScale)
: 1,
);
// Gap between ring and button, compensating for CSS transform phantom space.
// transform: scale() doesn't affect layout, so the 280px box stays full-size
// even when visually shrunk — creating phantom space below the visual ring.
const ringSize = 280;
const phantomBelow = $derived((ringSize * (1 - ringScale)) / 2);
const targetGap = $derived(Math.max(16, Math.round((windowH - 400) * 0.05 + 16)));
const ringMargin = $derived(targetGap - phantomBelow);
// Natural break notification
let showNaturalBreakToast = $state(false);
let naturalBreakToastTimeout: ReturnType<typeof setTimeout> | null = null;
// Watch for natural break detection
$effect(() => {
if ($timer.naturalBreakOccurred) {
showNaturalBreakToast = true;
if (naturalBreakToastTimeout) clearTimeout(naturalBreakToastTimeout);
naturalBreakToastTimeout = setTimeout(() => {
showNaturalBreakToast = false;
}, 5000);
}
});
</script>
<svelte:window bind:innerWidth={windowW} bind:innerHeight={windowH} />
<div class="relative flex h-full flex-col items-center justify-center">
<!-- Outer: responsive scaling (JS-driven), Inner: mount animation -->
<div style="transform: scale({ringScale}); transform-origin: center center; margin-bottom: {ringMargin}px;">
<div use:scaleIn={{ duration: 0.7, delay: 0.1 }}>
<TimerRing
progress={$timer.progress}
size={280}
strokeWidth={8}
accentColor={$config.accent_color}
>
<!-- Counter-scale wrapper: text shrinks less than ring -->
<div style="transform: scale({textCounterScale}); transform-origin: center center;">
<!-- Eye icon -->
<svg
class="mx-auto mb-3 eye-blink"
width="26"
height="26"
viewBox="0 0 24 24"
fill="none"
stroke="#444"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<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" />
</svg>
<!-- Time display -->
<span
class="block text-center text-[54px] font-semibold leading-none tracking-tight tabular-nums text-white"
style={$config.countdown_font ? `font-family: '${$config.countdown_font}', monospace;` : ""}
>
{formatTime($timer.timeRemaining)}
</span>
<div class="h-3"></div>
<!-- Status label -->
<span
class="block text-center text-[11px] font-medium tracking-[0.25em]"
class:text-[#444]={!$timer.prebreakWarning &&
$timer.state === "running"}
class:text-[#333]={!$timer.prebreakWarning &&
$timer.state === "paused"}
class:text-warning={$timer.prebreakWarning}
>
{statusText}
</span>
</div>
</TimerRing>
</div>
</div>
<!-- Last break info -->
<div use:fadeIn={{ delay: 0.4, y: 10 }}>
{#if $timer.hasHadBreak}
<p style="margin-bottom: {ringMargin}px;" class="text-[12px] text-[#2a2a2a]">
Last break {formatDurationAgo($timer.secondsSinceLastBreak)}
</p>
{:else}
<div style="margin-bottom: {ringMargin}px; height: 18px;"></div>
{/if}
</div>
<!-- Natural break notification toast -->
{#if showNaturalBreakToast}
<div
class="absolute top-6 left-1/2 -translate-x-1/2 rounded-2xl border px-5 py-3 text-center backdrop-blur-xl transition-all duration-500"
style="border-color: rgba(63, 185, 80, 0.3); background: rgba(63, 185, 80, 0.1);"
use:scaleIn={{ duration: 0.3, delay: 0 }}
>
<div class="flex items-center gap-2">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#3fb950" stroke-width="2">
<path d="M20 6L9 17l-5-5"/>
</svg>
<span class="text-[13px] text-[#3fb950]">Natural break detected - timer reset</span>
</div>
</div>
{/if}
<!-- Pause / Start button -->
<button
use:fadeIn={{ delay: 0.3, y: 12 }}
use:pressable
use:glowHover={{ color: $config.accent_color }}
class="w-[200px] rounded-full py-3.5 text-[13px] font-medium
tracking-[0.2em] text-white uppercase backdrop-blur-xl
transition-colors duration-200"
style="background: rgba(20,20,20,0.7);"
onclick={toggleTimer}
>
{toggleBtnText}
</button>
<!-- Bottom left: start break now -->
<div class="absolute bottom-5 left-5" use:fadeIn={{ delay: 0.5, y: 8 }}>
<button
aria-label="Start break now"
use:pressable
class="flex h-11 w-11 items-center justify-center rounded-full
border border-[#222] text-[#383838]
transition-colors duration-200
hover:border-[#333] hover:text-[#666]"
onclick={startBreakNow}
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
>
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
</button>
</div>
<!-- Bottom center: stats -->
<div class="absolute bottom-5 left-1/2 -translate-x-1/2" use:fadeIn={{ delay: 0.52, y: 8 }}>
<button
aria-label="Statistics"
use:pressable
class="flex h-11 w-11 items-center justify-center rounded-full
border border-[#222] text-[#383838]
transition-colors duration-200
hover:border-[#333] hover:text-[#666]"
onclick={() => {
invoke("set_view", { view: "stats" });
currentView.set("stats");
}}
>
<svg
width="17"
height="17"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="3" y="12" width="4" height="9" rx="1" />
<rect x="10" y="7" width="4" height="14" rx="1" />
<rect x="17" y="3" width="4" height="18" rx="1" />
</svg>
</button>
</div>
<!-- Bottom right: settings -->
<div class="absolute bottom-5 right-5" use:fadeIn={{ delay: 0.55, y: 8 }}>
<button
aria-label="Settings"
use:pressable
class="flex h-11 w-11 items-center justify-center rounded-full
border border-[#222] text-[#383838]
transition-colors duration-200
hover:border-[#333] hover:text-[#666]"
onclick={openSettings}
>
<svg
width="17"
height="17"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"
/>
<circle cx="12" cy="12" r="3" />
</svg>
</button>
</div>
</div>
<style>
/* Eye blink animation - natural human blink (~4s interval, 0.5s duration) */
.eye-blink {
transform-origin: center center;
animation: blink 4s infinite ease-in-out;
}
@keyframes blink {
0%, 45%, 55%, 100% {
transform: scaleY(1);
}
50% {
transform: scaleY(0.1);
}
}
</style>