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

118
src/App.svelte Normal file
View File

@@ -0,0 +1,118 @@
<script lang="ts">
import { onMount } from "svelte";
import { fly, scale, fade } from "svelte/transition";
import { cubicOut } from "svelte/easing";
import { invoke } from "@tauri-apps/api/core";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { initTimerStore, currentView } from "./lib/stores/timer";
import { loadConfig, config } from "./lib/stores/config";
import Titlebar from "./lib/components/Titlebar.svelte";
import Dashboard from "./lib/components/Dashboard.svelte";
import BreakScreen from "./lib/components/BreakScreen.svelte";
import Settings from "./lib/components/Settings.svelte";
import StatsView from "./lib/components/StatsView.svelte";
import BackgroundBlobs from "./lib/components/BackgroundBlobs.svelte";
const appWindow = getCurrentWebviewWindow();
onMount(async () => {
await loadConfig();
await initTimerStore();
// Save window position on move/resize (debounced)
let posTimer: ReturnType<typeof setTimeout>;
const savePos = () => {
clearTimeout(posTimer);
posTimer = setTimeout(async () => {
try {
const pos = await appWindow.outerPosition();
const size = await appWindow.outerSize();
await invoke("save_window_position", {
label: "main", x: pos.x, y: pos.y,
width: size.width, height: size.height,
});
} catch {}
}, 500);
};
appWindow.onMoved(savePos);
appWindow.onResized(savePos);
});
// Apply UI zoom from config
const zoomScale = $derived($config.ui_zoom / 100);
// Track previous view for directional transitions
let previousView = $state<string>("dashboard");
$effect(() => {
const view = $currentView;
// Store previous for determining transition direction
return () => {
previousView = view;
};
});
// Transition parameters
const DURATION = 700;
const easing = cubicOut;
// When fullscreen_mode is OFF, the separate break window handles breaks,
// so the main window should keep showing whatever view it was on (dashboard).
const effectiveView = $derived(
$currentView === "breakScreen" && !$config.fullscreen_mode
? "dashboard"
: $currentView
);
</script>
<div class="relative h-full bg-black">
{#if $config.background_blobs_enabled}
<BackgroundBlobs accentColor={$config.accent_color} breakColor={$config.break_color} />
{/if}
<Titlebar />
<div
class="relative h-full overflow-hidden origin-top-left"
style="
transform: scale({zoomScale});
width: {100 / zoomScale}%;
height: {100 / zoomScale}%;
"
>
{#if effectiveView === "dashboard"}
<div
class="absolute inset-0"
in:fly={{ x: previousView === "settings" ? -200 : 0, duration: DURATION, easing, opacity: 0 }}
out:fly={{ x: previousView === "dashboard" ? -200 : 0, duration: DURATION * 0.6, easing, opacity: 0 }}
>
<Dashboard />
</div>
{/if}
{#if effectiveView === "breakScreen"}
<div
class="absolute inset-0"
in:scale={{ start: 1.08, duration: DURATION, easing, opacity: 0 }}
out:scale={{ start: 0.95, duration: DURATION * 0.6, easing, opacity: 0 }}
>
<BreakScreen />
</div>
{/if}
{#if effectiveView === "settings"}
<div
class="absolute inset-0"
in:fly={{ x: 200, duration: DURATION, easing, opacity: 0 }}
out:fly={{ x: 200, duration: DURATION * 0.6, easing, opacity: 0 }}
>
<Settings />
</div>
{/if}
{#if effectiveView === "stats"}
<div
class="absolute inset-0"
in:fly={{ y: 200, duration: DURATION, easing, opacity: 0 }}
out:fly={{ y: 200, duration: DURATION * 0.6, easing, opacity: 0 }}
>
<StatsView />
</div>
{/if}
</div>
</div>

57
src/app.css Normal file
View File

@@ -0,0 +1,57 @@
@import "tailwindcss";
@theme {
--color-bg: #000000;
--color-surface: #0e0e0e;
--color-card: #141414;
--color-card-lt: #1c1c1c;
--color-border: #222222;
--color-accent: #ff4d00;
--color-accent-lt: #ff7733;
--color-accent-dim: #ff4d0018;
--color-accent-glow: #ff4d0040;
--color-success: #3fb950;
--color-warning: #f0a500;
--color-danger: #f85149;
--color-text-pri: #ffffff;
--color-text-sec: #777777;
--color-text-dim: #3a3a3a;
--color-caption-bg: #050505;
}
html,
body {
margin: 0;
padding: 0;
background: #000000;
color: var(--color-text-pri);
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
Arial, sans-serif;
overflow: hidden;
height: 100%;
user-select: none;
-webkit-user-select: none;
}
#app {
height: 100%;
}
/* Scrollbar */
::-webkit-scrollbar {
width: 5px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #222;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #333;
}

View File

@@ -0,0 +1,111 @@
<script lang="ts">
interface Props {
accentColor: string;
breakColor: string;
}
let { accentColor, breakColor }: Props = $props();
</script>
<div class="pointer-events-none absolute inset-0 overflow-hidden">
<!-- Gradient blobs -->
<div
class="blob blob-1"
style="background: radial-gradient(circle, {accentColor} 0%, transparent 70%);"
></div>
<div
class="blob blob-2"
style="background: radial-gradient(circle, {breakColor} 0%, transparent 70%);"
></div>
<div
class="blob blob-3"
style="background: radial-gradient(circle, {accentColor} 0%, transparent 70%);"
></div>
<div
class="blob blob-4"
style="background: radial-gradient(circle, {breakColor} 0%, transparent 70%);"
></div>
<!-- Film grain overlay -->
<svg class="absolute inset-0 h-full w-full" style="opacity: 0.08; mix-blend-mode: overlay;">
<filter id="grain-filter">
<feTurbulence type="fractalNoise" baseFrequency="0.45" numOctaves="3" stitchTiles="stitch">
<animate
attributeName="seed"
from="0"
to="100"
dur="2s"
repeatCount="indefinite"
/>
</feTurbulence>
</filter>
<rect width="100%" height="100%" filter="url(#grain-filter)" />
</svg>
</div>
<style>
.blob {
position: absolute;
width: 300px;
height: 300px;
border-radius: 50%;
filter: blur(80px);
opacity: 0.4;
will-change: transform;
}
.blob-1 {
top: -15%;
left: -10%;
animation: drift-1 30s ease-in-out infinite;
}
.blob-2 {
bottom: -15%;
right: -10%;
animation: drift-2 35s ease-in-out infinite;
}
.blob-3 {
top: 40%;
right: -15%;
width: 250px;
height: 250px;
animation: drift-3 28s ease-in-out infinite;
}
.blob-4 {
bottom: 20%;
left: -15%;
width: 200px;
height: 200px;
animation: drift-4 32s ease-in-out infinite;
}
@keyframes drift-1 {
0%, 100% { transform: translate(0, 0) scale(1); }
20% { transform: translate(300px, 200px) scale(1.15); }
40% { transform: translate(150px, 450px) scale(0.9); }
60% { transform: translate(350px, 350px) scale(1.1); }
80% { transform: translate(100px, 100px) scale(0.95); }
}
@keyframes drift-2 {
0%, 100% { transform: translate(0, 0) scale(1); }
20% { transform: translate(-250px, -150px) scale(1.1); }
40% { transform: translate(-100px, -400px) scale(0.95); }
60% { transform: translate(-300px, -200px) scale(1.15); }
80% { transform: translate(-50px, -300px) scale(0.9); }
}
@keyframes drift-3 {
0%, 100% { transform: translate(0, 0) scale(1); }
25% { transform: translate(-300px, -200px) scale(1.1); }
50% { transform: translate(-150px, 150px) scale(0.9); }
75% { transform: translate(-350px, -50px) scale(1.15); }
}
@keyframes drift-4 {
0%, 100% { transform: translate(0, 0) scale(1); }
25% { transform: translate(280px, -180px) scale(1.15); }
50% { transform: translate(100px, 200px) scale(0.9); }
75% { transform: translate(320px, 50px) scale(1.1); }
}
</style>

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>

View File

@@ -0,0 +1,63 @@
<script lang="ts">
import { onMount } from "svelte";
import { listen } from "@tauri-apps/api/event";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { LogicalSize } from "@tauri-apps/api/dpi";
import { initTimerStore } from "../stores/timer";
import { loadConfig, config } from "../stores/config";
import BreakScreen from "./BreakScreen.svelte";
const appWindow = getCurrentWebviewWindow();
// Base window dimensions (from tauri.conf.json)
const BASE_W = 900;
const BASE_H = 540;
let ready = $state(false);
onMount(async () => {
await loadConfig();
await initTimerStore();
ready = true;
// Auto-hide when break ends (window is persistent, just hide it)
await listen("break-ended", () => {
appWindow.hide();
});
// Live-reload config when main window changes settings (zoom, colors, etc.)
await listen("config-changed", () => {
loadConfig();
});
});
const zoomScale = $derived($config.ui_zoom / 100);
// Resize the actual Tauri window when zoom changes
$effect(() => {
const scale = $config.ui_zoom / 100;
const w = Math.round(BASE_W * scale);
const h = Math.round(BASE_H * scale);
appWindow.setSize(new LogicalSize(w, h));
appWindow.center();
});
</script>
<div class="w-full h-full flex items-center justify-center overflow-hidden">
<div
style="
width: {100 / zoomScale}%;
height: {100 / zoomScale}%;
transform: scale({zoomScale});
transform-origin: center center;
display: flex;
align-items: center;
justify-content: center;
"
>
{#if ready}
<BreakScreen standalone={true} />
{/if}
</div>
</div>

View File

@@ -0,0 +1,301 @@
<script lang="ts">
import { slide } from "svelte/transition";
import { cubicOut } from "svelte/easing";
interface Props {
value: string;
label: string;
presets?: string[];
onchange?: (color: string) => void;
}
let {
value = $bindable(),
label,
presets = [
"#ff4d00", "#ff6b35", "#e63946", "#d62828",
"#f77f00", "#fcbf49", "#2ec4b6", "#3fb950",
"#7c6aef", "#9b5de5", "#4361ee", "#4895ef",
"#f72585", "#ff006e", "#ffffff", "#888888",
],
onchange,
}: Props = $props();
let showCustom = $state(false);
let hue = $state(0);
let sat = $state(100);
let light = $state(50);
let hexInput = $state(value);
let draggingSL = $state(false);
let draggingHue = $state(false);
// Pointer ID tracking for proper cleanup
let slPointerId = $state<number | null>(null);
let huePointerId = $state<number | null>(null);
// Parse current value into HSL on mount
$effect(() => {
const hsl = hexToHsl(value);
if (hsl) {
hue = hsl.h;
sat = hsl.s;
light = hsl.l;
}
hexInput = value;
});
function selectPreset(color: string) {
value = color;
hexInput = color;
showCustom = false;
const hsl = hexToHsl(color);
if (hsl) { hue = hsl.h; sat = hsl.s; light = hsl.l; }
onchange?.(color);
}
function updateFromHSL() {
const hex = hslToHex(hue, sat, light);
value = hex;
hexInput = hex;
onchange?.(hex);
}
function onHexInput(e: Event) {
const input = (e.target as HTMLInputElement).value;
hexInput = input;
if (/^#[0-9a-fA-F]{6}$/.test(input)) {
value = input;
const hsl = hexToHsl(input);
if (hsl) { hue = hsl.h; sat = hsl.s; light = hsl.l; }
onchange?.(input);
}
}
// --- SL pad interaction (Pointer Events) ---
let slPad = $state<HTMLDivElement>(undefined!);
function handleSLPointerDown(e: PointerEvent) {
e.preventDefault();
e.stopPropagation();
draggingSL = true;
slPointerId = e.pointerId;
// Capture pointer to keep receiving events even outside element
const target = e.currentTarget as HTMLElement;
target.setPointerCapture(e.pointerId);
// Update immediately on down
updateSLFromPointer(e);
}
function handleSLPointerMove(e: PointerEvent) {
if (!draggingSL || !slPad || e.pointerId !== slPointerId) return;
e.preventDefault();
updateSLFromPointer(e);
}
function updateSLFromPointer(e: PointerEvent) {
if (!slPad) return;
const rect = slPad.getBoundingClientRect();
const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
const y = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height));
sat = Math.round(x * 100);
light = Math.round((1 - y) * 100);
updateFromHSL();
}
function handleSLPointerUp(e: PointerEvent) {
if (!draggingSL || e.pointerId !== slPointerId) return;
e.preventDefault();
draggingSL = false;
slPointerId = null;
}
// --- Hue bar interaction (Pointer Events) ---
let hueBar = $state<HTMLDivElement>(undefined!);
function handleHuePointerDown(e: PointerEvent) {
e.preventDefault();
e.stopPropagation();
draggingHue = true;
huePointerId = e.pointerId;
// Capture pointer to keep receiving events even outside element
const target = e.currentTarget as HTMLElement;
target.setPointerCapture(e.pointerId);
// Update immediately on down
updateHueFromPointer(e);
}
function handleHuePointerMove(e: PointerEvent) {
if (!draggingHue || !hueBar || e.pointerId !== huePointerId) return;
e.preventDefault();
updateHueFromPointer(e);
}
function updateHueFromPointer(e: PointerEvent) {
if (!hueBar) return;
const rect = hueBar.getBoundingClientRect();
const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
hue = Math.round(x * 360);
updateFromHSL();
}
function handleHuePointerUp(e: PointerEvent) {
if (!draggingHue || e.pointerId !== huePointerId) return;
e.preventDefault();
draggingHue = false;
huePointerId = null;
}
// --- Color conversion helpers ---
function hexToHsl(hex: string): { h: number; s: number; l: number } | null {
if (!/^#[0-9a-fA-F]{6}$/.test(hex)) return null;
let r = parseInt(hex.slice(1, 3), 16) / 255;
let g = parseInt(hex.slice(3, 5), 16) / 255;
let b = parseInt(hex.slice(5, 7), 16) / 255;
const max = Math.max(r, g, b), min = Math.min(r, g, b);
let h = 0, s = 0, l = (max + min) / 2;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
else if (max === g) h = ((b - r) / d + 2) / 6;
else h = ((r - g) / d + 4) / 6;
}
return { h: Math.round(h * 360), s: Math.round(s * 100), l: Math.round(l * 100) };
}
function hslToHex(h: number, s: number, l: number): string {
const sl = s / 100, ll = l / 100;
const a = sl * Math.min(ll, 1 - ll);
function f(n: number) {
const k = (n + h / 30) % 12;
const color = ll - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
return Math.round(255 * color).toString(16).padStart(2, '0');
}
return `#${f(0)}${f(8)}${f(4)}`;
}
const isPreset = $derived(presets.includes(value));
// SL cursor position
const slX = $derived(sat);
const slY = $derived(100 - light);
</script>
<div class="flex flex-col gap-3">
<!-- Header row: label + current color preview -->
<div class="flex items-center justify-between">
<div>
<div class="text-[13px] text-white">{label}</div>
<div class="font-mono text-[11px] text-[#444]">{value}</div>
</div>
<div
class="h-6 w-6 rounded-full border border-[#333] shadow-[0_0_8px_0px] transition-shadow duration-300"
style="background: {value}; --tw-shadow-color: {value}40;"
></div>
</div>
<!-- Preset swatches -->
<div class="flex flex-wrap gap-[6px]">
{#each presets as color}
<button
type="button"
class="h-[22px] w-[22px] rounded-full transition-all duration-150
{value === color
? 'ring-[1.5px] ring-white ring-offset-1 ring-offset-black scale-110'
: 'hover:scale-110 opacity-80 hover:opacity-100'}"
style="background: {color};"
onclick={() => selectPreset(color)}
aria-label="Select {color}"
></button>
{/each}
<!-- Custom toggle swatch — ring shows when picker open OR value is custom -->
<button
type="button"
class="h-[22px] w-[22px] rounded-full transition-all duration-150
{showCustom || !isPreset
? 'ring-[1.5px] ring-white ring-offset-1 ring-offset-black scale-110'
: 'hover:scale-110 opacity-80 hover:opacity-100'}"
style="background: conic-gradient(from 0deg, #f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00);"
onclick={() => { showCustom = !showCustom; }}
aria-label="Custom color"
></button>
</div>
<!-- Inline custom picker — slides open/closed -->
{#if showCustom}
<div
class="flex flex-col gap-3 rounded-xl bg-[#0f0f0f] p-3 border border-[#1a1a1a]"
transition:slide={{ duration: 280, easing: cubicOut }}
>
<!-- Saturation / Lightness pad -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
bind:this={slPad}
class="relative h-[120px] w-full cursor-crosshair rounded-lg overflow-hidden touch-none"
style="background: linear-gradient(to right, hsl({hue}, 0%, 50%), hsl({hue}, 100%, 50%));"
onpointerdown={handleSLPointerDown}
onpointermove={handleSLPointerMove}
onpointerup={handleSLPointerUp}
onpointercancel={handleSLPointerUp}
role="application"
aria-label="Saturation and lightness"
tabindex="-1"
>
<!-- Lightness overlay: white at top, black at bottom -->
<div
class="absolute inset-0"
style="background: linear-gradient(to bottom, hsl({hue}, 100%, 100%), transparent, hsl({hue}, 100%, 0%));"
></div>
<!-- Cursor -->
<div
class="pointer-events-none absolute h-[14px] w-[14px] -translate-x-1/2 -translate-y-1/2 rounded-full
border-2 border-white shadow-[0_0_0_1px_rgba(0,0,0,0.4)]"
style="left: {slX}%; top: {slY}%; background: {value};"
></div>
</div>
<!-- Hue bar -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
bind:this={hueBar}
class="relative h-[16px] w-full cursor-pointer rounded-full overflow-hidden touch-none"
style="background: linear-gradient(to right,
hsl(0,100%,50%), hsl(60,100%,50%), hsl(120,100%,50%),
hsl(180,100%,50%), hsl(240,100%,50%), hsl(300,100%,50%), hsl(360,100%,50%));"
onpointerdown={handleHuePointerDown}
onpointermove={handleHuePointerMove}
onpointerup={handleHuePointerUp}
onpointercancel={handleHuePointerUp}
role="application"
aria-label="Hue"
tabindex="-1"
>
<!-- Hue cursor -->
<div
class="pointer-events-none absolute top-1/2 h-[12px] w-[12px] -translate-x-1/2 -translate-y-1/2 rounded-full
border-2 border-white shadow-[0_0_0_1px_rgba(0,0,0,0.3)]"
style="left: {(hue / 360) * 100}%; background: hsl({hue}, 100%, 50%);"
></div>
</div>
<!-- Hex input -->
<input
type="text"
class="w-full rounded-lg border border-[#1a1a1a] bg-black px-3 py-1.5 text-[12px]
font-mono text-white outline-none
placeholder:text-[#333] focus:border-[#333]"
placeholder="#ff4d00"
value={hexInput}
oninput={onHexInput}
maxlength={7}
/>
</div>
{/if}
</div>

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>

View File

@@ -0,0 +1,98 @@
<script lang="ts">
import { slide } from "svelte/transition";
import { cubicOut } from "svelte/easing";
interface Props {
value: string;
onchange?: (font: string) => void;
}
let {
value = $bindable(),
onchange,
}: Props = $props();
const fonts = [
{ family: "", label: "System Default" },
{ family: "JetBrains Mono", label: "JetBrains Mono" },
{ family: "Space Mono", label: "Space Mono" },
{ family: "Roboto Mono", label: "Roboto Mono" },
{ family: "Fira Code", label: "Fira Code" },
{ family: "IBM Plex Mono", label: "IBM Plex Mono" },
{ family: "Source Code Pro", label: "Source Code Pro" },
{ family: "Share Tech Mono", label: "Share Tech Mono" },
{ family: "Major Mono Display", label: "Major Mono" },
{ family: "Azeret Mono", label: "Azeret Mono" },
{ family: "DM Mono", label: "DM Mono" },
{ family: "Inconsolata", label: "Inconsolata" },
{ family: "Ubuntu Mono", label: "Ubuntu Mono" },
{ family: "Overpass Mono", label: "Overpass Mono" },
{ family: "Red Hat Mono", label: "Red Hat Mono" },
{ family: "Martian Mono", label: "Martian Mono" },
{ family: "Noto Sans Mono", label: "Noto Sans Mono" },
{ family: "Oxygen Mono", label: "Oxygen Mono" },
{ family: "Anonymous Pro", label: "Anonymous Pro" },
{ family: "Courier Prime", label: "Courier Prime" },
];
function selectFont(family: string) {
value = family;
onchange?.(family);
}
function fontStyle(family: string): string {
return family ? `font-family: '${family}', monospace;` : "";
}
let expanded = $state(false);
</script>
<div class="flex flex-col gap-3">
<!-- Header row -->
<div class="flex items-center justify-between">
<div>
<div class="text-[13px] text-white">Countdown font</div>
<div class="text-[11px] text-[#555]">
{value || "System default"}
</div>
</div>
<button
type="button"
class="rounded-lg border border-[#1a1a1a] bg-black px-3 py-1.5 text-[12px] text-[#666]
transition-colors hover:border-[#333] hover:text-white"
onclick={() => { expanded = !expanded; }}
>
{expanded ? "Close" : "Browse"}
</button>
</div>
<!-- Font preview grid -->
{#if expanded}
<div
class="grid grid-cols-2 gap-2"
transition:slide={{ duration: 280, easing: cubicOut }}
>
{#each fonts as font}
<button
type="button"
class="flex flex-col items-center gap-1.5 rounded-xl border p-3
transition-all duration-150
{value === font.family
? 'border-white/30 bg-[#141414]'
: 'border-[#141414] bg-[#0a0a0a] hover:border-[#222] hover:bg-[#0f0f0f]'}"
onclick={() => selectFont(font.family)}
>
<span
class="text-[28px] leading-none tabular-nums text-white"
style={fontStyle(font.family)}
>
25:00
</span>
<span class="text-[9px] tracking-wider text-[#555] uppercase">
{font.label}
</span>
</button>
{/each}
</div>
{/if}
</div>

View File

@@ -0,0 +1,336 @@
<script lang="ts">
import { onMount } from "svelte";
import { listen } from "@tauri-apps/api/event";
import { invoke } from "@tauri-apps/api/core";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { LogicalSize } from "@tauri-apps/api/dpi";
import type { TimerSnapshot } from "../stores/timer";
import { config, loadConfig } from "../stores/config";
const appWindow = getCurrentWebviewWindow();
let timeText = $state("--:--");
let state = $state<"running" | "paused" | "breakActive">("paused");
let progress = $state(0);
let accentColor = $state("#ff4d00");
let breakColor = $state("#7c6aef");
let countdownFont = $state("");
let draggable = $state(false);
// Use config store directly for live updates
const uiZoom = $derived($config.ui_zoom);
function formatTime(secs: number): string {
const m = Math.floor(secs / 60);
const s = secs % 60;
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
}
// Sync local color/font state from the config store
function applyConfigLocals() {
accentColor = $config.accent_color || "#ff4d00";
breakColor = $config.break_color || "#7c6aef";
countdownFont = $config.countdown_font || "";
}
onMount(async () => {
// Get initial state
try {
const snap = await invoke<TimerSnapshot>("get_timer_state");
updateFromSnapshot(snap);
} catch (e) {
console.error("Mini: Failed to get state", e);
}
// Load config into the shared config store
await loadConfig();
applyConfigLocals();
// Live-reload config when main window changes settings (zoom, colors, etc.)
await listen("config-changed", async () => {
await loadConfig();
applyConfigLocals();
});
// Listen for ticks
await listen<TimerSnapshot>("timer-tick", (event) => {
updateFromSnapshot(event.payload);
});
// Save position on move (debounced)
let posTimer: ReturnType<typeof setTimeout>;
appWindow.onMoved(() => {
clearTimeout(posTimer);
posTimer = setTimeout(async () => {
try {
const pos = await appWindow.outerPosition();
await invoke("save_window_position", {
label: "mini", x: pos.x, y: pos.y, width: 184, height: 92,
});
} catch {}
}, 500);
});
});
// Click-through mode: reactive to config changes
$effect(() => {
const isClickThrough = $config.mini_click_through;
const threshold = $config.mini_hover_threshold;
if (!isClickThrough) {
appWindow.setIgnoreCursorEvents(false);
draggable = true;
return;
}
appWindow.setIgnoreCursorEvents(true);
draggable = false;
let hoverTime = 0;
let localDraggable = false;
const POLL_MS = 200;
const interval = setInterval(async () => {
try {
const [cx, cy] = await invoke<[number, number]>("get_cursor_position");
const pos = await appWindow.outerPosition();
const size = await appWindow.outerSize();
const inside =
cx >= pos.x && cx <= pos.x + size.width &&
cy >= pos.y && cy <= pos.y + size.height;
if (inside) {
hoverTime += POLL_MS / 1000;
if (hoverTime >= threshold && !localDraggable) {
localDraggable = true;
draggable = true;
await appWindow.setIgnoreCursorEvents(false);
}
} else {
if (localDraggable) {
localDraggable = false;
draggable = false;
await appWindow.setIgnoreCursorEvents(true);
}
hoverTime = 0;
}
} catch {}
}, POLL_MS);
return () => clearInterval(interval);
});
function updateFromSnapshot(snap: TimerSnapshot) {
state = snap.state;
if (snap.state === "breakActive") {
timeText = formatTime(snap.breakTimeRemaining);
progress = snap.breakTotalDuration > 0
? snap.breakTimeRemaining / snap.breakTotalDuration
: 0;
} else {
timeText = formatTime(snap.timeRemaining);
progress = snap.progress;
}
}
// Click opens main window
async function openMain() {
try {
await invoke("set_view", { view: "dashboard" });
} catch {}
}
// Drag support
function startDrag(e: MouseEvent) {
if (e.button === 0) {
appWindow.startDragging();
}
}
// Ring SVG computations
const ringSize = 34;
const strokeW = 3;
const pad = 16;
const viewSize = ringSize + pad * 2;
const radius = (ringSize - strokeW) / 2;
const circumference = 2 * Math.PI * radius;
const dashOffset = $derived(circumference * (1 - progress));
const ctr = viewSize / 2;
const activeColor = $derived(state === "breakActive" ? breakColor : accentColor);
function lighten(hex: string, amount: number): string {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
const lr = Math.min(255, r + (255 - r) * amount);
const lg = Math.min(255, g + (255 - g) * amount);
const lb = Math.min(255, b + (255 - b) * amount);
return `#${Math.round(lr).toString(16).padStart(2, "0")}${Math.round(lg).toString(16).padStart(2, "0")}${Math.round(lb).toString(16).padStart(2, "0")}`;
}
const gradA = $derived(activeColor);
const gradB = $derived(lighten(activeColor, 0.25));
const fontStyle = $derived(
countdownFont ? `font-family: '${countdownFont}', monospace;` : ""
);
const zoomScale = $derived(uiZoom / 100);
// Base window dimensions (matches lib.rs toggle_mini_window)
const MINI_BASE_W = 184;
const MINI_BASE_H = 92;
// Resize the actual Tauri window when zoom changes
$effect(() => {
const scale = uiZoom / 100;
const w = Math.round(MINI_BASE_W * scale);
const h = Math.round(MINI_BASE_H * scale);
appWindow.setSize(new LogicalSize(w, h));
});
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="w-full h-full flex items-center justify-center overflow-hidden">
<div
style="
width: {100 / zoomScale}%;
height: {100 / zoomScale}%;
transform: scale({zoomScale});
transform-origin: center center;
"
>
<div
class="flex items-center justify-center w-full h-full"
style="padding: 22px 14px 22px 24px;"
>
<div
class="mini-pill flex h-full w-full items-center select-none"
class:mini-draggable={draggable}
style="
background: rgba(0, 0, 0, 0.85);
border-radius: 9999px;
border: 1px solid {draggable ? 'rgba(255, 255, 255, 0.25)' : 'rgba(255, 255, 255, 0.08)'};
backdrop-filter: blur(12px);
cursor: {draggable ? 'grab' : 'default'};
padding: 0 12px 0 5px;
transition: border-color 0.3s ease, box-shadow 0.3s ease;
{draggable ? `box-shadow: 0 0 12px rgba(255, 255, 255, 0.08);` : ''}
"
onmousedown={draggable ? startDrag : undefined}
ondblclick={draggable ? openMain : undefined}
>
<!-- Mini ring with glow -->
<div class="relative flex-shrink-0" style="width: {ringSize}px; height: {ringSize}px;">
<!-- Glow SVG (larger for blur room) -->
<svg
width={viewSize}
height={viewSize}
class="pointer-events-none absolute"
style="left: {-pad}px; top: {-pad}px; transform: rotate(-90deg); overflow: visible;"
>
<defs>
<linearGradient id="mini-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color={gradA} />
<stop offset="100%" stop-color={gradB} />
</linearGradient>
<filter id="mini-glow-wide" x="-100%" y="-100%" width="300%" height="300%">
<feGaussianBlur stdDeviation="8" />
</filter>
<filter id="mini-glow-mid" x="-60%" y="-60%" width="220%" height="220%">
<feGaussianBlur stdDeviation="4" />
</filter>
<filter id="mini-glow-core" x="-40%" y="-40%" width="180%" height="180%">
<feGaussianBlur stdDeviation="2" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
{#if progress > 0.002}
<!-- Layer 1: Wide ambient bloom -->
<circle
cx={ctr} cy={ctr} r={radius}
fill="none"
stroke="url(#mini-grad)"
stroke-width={strokeW * 4}
stroke-linecap="round"
stroke-dasharray={circumference}
stroke-dashoffset={dashOffset}
filter="url(#mini-glow-wide)"
opacity="0.35"
class="transition-[stroke-dashoffset] duration-1000 ease-linear"
/>
<!-- Layer 2: Medium glow -->
<circle
cx={ctr} cy={ctr} r={radius}
fill="none"
stroke="url(#mini-grad)"
stroke-width={strokeW * 2}
stroke-linecap="round"
stroke-dasharray={circumference}
stroke-dashoffset={dashOffset}
filter="url(#mini-glow-mid)"
opacity="0.5"
class="transition-[stroke-dashoffset] duration-1000 ease-linear"
/>
<!-- Layer 3: Core tight glow -->
<circle
cx={ctr} cy={ctr} r={radius}
fill="none"
stroke="url(#mini-grad)"
stroke-width={strokeW}
stroke-linecap="round"
stroke-dasharray={circumference}
stroke-dashoffset={dashOffset}
filter="url(#mini-glow-core)"
class="transition-[stroke-dashoffset] duration-1000 ease-linear"
/>
{/if}
</svg>
<!-- Non-glow SVG: track + crisp ring -->
<svg
width={ringSize}
height={ringSize}
class="absolute"
style="transform: rotate(-90deg);"
>
<!-- Background track -->
<circle
cx={ringSize / 2} cy={ringSize / 2} r={radius}
fill="none"
stroke={state === "paused" ? "#1a1a1a" : "#161616"}
stroke-width={strokeW}
/>
{#if progress > 0.002}
<!-- Foreground arc -->
<circle
cx={ringSize / 2} cy={ringSize / 2} r={radius}
fill="none"
stroke="url(#mini-grad)"
stroke-width={strokeW}
stroke-linecap="round"
stroke-dasharray={circumference}
stroke-dashoffset={dashOffset}
class="transition-[stroke-dashoffset] duration-1000 ease-linear"
/>
{/if}
</svg>
</div>
<!-- Countdown text -->
<span
class="ml-2.5 text-[18px] font-semibold leading-none tabular-nums"
style="color: {state === 'paused' ? '#555' : '#fff'}; {fontStyle}"
>
{timeText}
</span>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,801 @@
<script lang="ts">
import { invoke } from "@tauri-apps/api/core";
import { timer, currentView } from "../stores/timer";
import { config, autoSave, loadConfig, resetConfig } from "../stores/config";
import ToggleSwitch from "./ToggleSwitch.svelte";
import Stepper from "./Stepper.svelte";
import ColorPicker from "./ColorPicker.svelte";
import FontSelector from "./FontSelector.svelte";
import TimeSpinner from "./TimeSpinner.svelte";
import { fadeIn, inView, pressable, dragScroll } from "../utils/animate";
import { playSound } from "../utils/sounds";
import type { TimeRange } from "../stores/config";
const soundPresets = ["bell", "chime", "soft", "digital", "harp", "bowl", "rain", "whistle"] as const;
const daysOfWeek = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] as const;
function goBack() {
invoke("set_view", { view: "dashboard" });
currentView.set("dashboard");
}
function markChanged() {
autoSave();
}
// Working hours functions
function toggleDayEnabled(dayIndex: number) {
$config.working_hours_schedule[dayIndex].enabled = !$config.working_hours_schedule[dayIndex].enabled;
$config.working_hours_schedule = $config.working_hours_schedule;
markChanged();
}
function addTimeRange(dayIndex: number) {
$config.working_hours_schedule[dayIndex].ranges = [
...$config.working_hours_schedule[dayIndex].ranges,
{ start: "09:00", end: "18:00" }
];
$config.working_hours_schedule = $config.working_hours_schedule;
markChanged();
}
function removeTimeRange(dayIndex: number, rangeIndex: number) {
if ($config.working_hours_schedule[dayIndex].ranges.length > 1) {
$config.working_hours_schedule[dayIndex].ranges = $config.working_hours_schedule[dayIndex].ranges.filter((_, i) => i !== rangeIndex);
$config.working_hours_schedule = $config.working_hours_schedule;
markChanged();
}
}
function cloneTimeRange(dayIndex: number, rangeIndex: number) {
const range = $config.working_hours_schedule[dayIndex].ranges[rangeIndex];
$config.working_hours_schedule[dayIndex].ranges = [
...$config.working_hours_schedule[dayIndex].ranges,
{ ...range }
];
$config.working_hours_schedule = $config.working_hours_schedule;
markChanged();
}
function updateTimeRange(dayIndex: number, rangeIndex: number, field: "start" | "end", value: string) {
$config.working_hours_schedule[dayIndex].ranges[rangeIndex][field] = value;
$config.working_hours_schedule = $config.working_hours_schedule;
markChanged();
}
// Reset button two-click confirmation
let resetConfirming = $state(false);
let resetTimeout: ReturnType<typeof setTimeout> | null = null;
function handleReset() {
if (!resetConfirming) {
resetConfirming = true;
// Auto-cancel after 3 seconds
resetTimeout = setTimeout(() => {
resetConfirming = false;
}, 3000);
} else {
resetConfirming = false;
if (resetTimeout) clearTimeout(resetTimeout);
resetConfig();
autoSave();
}
}
// Reload config when entering settings
$effect(() => {
loadConfig();
});
</script>
<div class="flex h-full flex-col">
<!-- Header -->
<div
data-tauri-drag-region
class="flex items-center px-5 pt-5 pb-4"
use:fadeIn={{ duration: 0.4, y: 8 }}
>
<button
aria-label="Back to dashboard"
use:pressable
class="mr-3 flex h-8 w-8 items-center justify-center rounded-full
text-[#444] transition-colors hover:text-white"
onclick={goBack}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="15 18 9 12 15 6" />
</svg>
</button>
<h1
data-tauri-drag-region
class="flex-1 text-lg font-medium text-white"
>
Settings
</h1>
</div>
<!-- Scrollable content with drag scroll -->
<div
class="settings-scroll-container flex-1 overflow-y-auto px-5 pb-6"
use:dragScroll
>
<div class="space-y-3">
<!-- Timer -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0 }}>
<h3
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#666] uppercase"
>
Timer
</h3>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Break frequency</div>
<div class="text-[11px] text-[#777]">
Every {$config.break_frequency} min
</div>
</div>
<Stepper
bind:value={$config.break_frequency}
min={5}
max={120}
onchange={markChanged}
/>
</div>
<div class="my-4 h-px bg-[#161616]"></div>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Break duration</div>
<div class="text-[11px] text-[#777]">
{$config.break_duration} min
</div>
</div>
<Stepper
bind:value={$config.break_duration}
min={1}
max={60}
onchange={markChanged}
/>
</div>
<div class="my-4 h-px bg-[#161616]"></div>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Auto-start</div>
<div class="text-[11px] text-[#777]">Start timer on launch</div>
</div>
<ToggleSwitch
bind:checked={$config.auto_start}
onchange={markChanged}
/>
</div>
</section>
<!-- Break Screen -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.06 }}>
<h3
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#666] uppercase"
>
Break Screen
</h3>
<div class="flex flex-col gap-1.5">
<label class="text-[13px] text-white" for="break-title">
Break title
</label>
<input
id="break-title"
type="text"
class="rounded-xl border border-[#161616] bg-black px-3.5 py-2.5 text-[13px]
text-white outline-none transition-colors
placeholder:text-[#2a2a2a] focus:border-[#333]"
placeholder="Enter break title..."
bind:value={$config.break_title}
oninput={markChanged}
/>
</div>
<div class="my-4 h-px bg-[#161616]"></div>
<div class="flex flex-col gap-1.5">
<label class="text-[13px] text-white" for="break-message">
Break message
</label>
<input
id="break-message"
type="text"
class="rounded-xl border border-[#161616] bg-black px-3.5 py-2.5 text-[13px]
text-white outline-none transition-colors
placeholder:text-[#2a2a2a] focus:border-[#333]"
placeholder="Enter break message..."
bind:value={$config.break_message}
oninput={markChanged}
/>
</div>
<div class="my-4 h-px bg-[#161616]"></div>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Fullscreen break</div>
<div class="text-[11px] text-[#777]">
{$config.fullscreen_mode ? "Fills entire screen" : "Centered modal"}
</div>
</div>
<ToggleSwitch
bind:checked={$config.fullscreen_mode}
onchange={markChanged}
/>
</div>
<div class="my-4 h-px bg-[#161616]"></div>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Activity suggestions</div>
<div class="text-[11px] text-[#777]">
Exercise ideas during breaks
</div>
</div>
<ToggleSwitch
bind:checked={$config.show_break_activities}
onchange={markChanged}
/>
</div>
</section>
<!-- Behavior -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.12 }}>
<h3
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#666] uppercase"
>
Behavior
</h3>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Strict mode</div>
<div class="text-[11px] text-[#777]">
Disable skip and snooze
</div>
</div>
<ToggleSwitch
bind:checked={$config.strict_mode}
onchange={markChanged}
/>
</div>
{#if !$config.strict_mode}
<div class="my-4 h-px bg-[#161616]"></div>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Allow end early</div>
<div class="text-[11px] text-[#777]">After 50% of break</div>
</div>
<ToggleSwitch
bind:checked={$config.allow_end_early}
onchange={markChanged}
/>
</div>
<div class="my-4 h-px bg-[#161616]"></div>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Snooze duration</div>
<div class="text-[11px] text-[#777]">
{$config.snooze_duration} min
</div>
</div>
<Stepper
bind:value={$config.snooze_duration}
min={1}
max={30}
onchange={markChanged}
/>
</div>
<div class="my-4 h-px bg-[#161616]"></div>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Snooze limit</div>
<div class="text-[11px] text-[#777]">
{$config.snooze_limit === 0
? "Unlimited"
: `${$config.snooze_limit} per break`}
</div>
</div>
<Stepper
bind:value={$config.snooze_limit}
min={0}
max={5}
formatValue={(v) => (v === 0 ? "\u221E" : String(v))}
onchange={markChanged}
/>
</div>
{/if}
<div class="my-4 h-px bg-[#161616]"></div>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Immediate breaks</div>
<div class="text-[11px] text-[#777]">
Skip pre-break warning
</div>
</div>
<ToggleSwitch
bind:checked={$config.immediately_start_breaks}
onchange={markChanged}
/>
</div>
</section>
<!-- Working Hours -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.18 }}>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Working hours</div>
<div class="text-[11px] text-[#777]">
Only show breaks during your configured work schedule
</div>
</div>
<ToggleSwitch
bind:checked={$config.working_hours_enabled}
onchange={markChanged}
/>
</div>
{#if $config.working_hours_enabled}
<div class="my-4 h-px bg-[#161616]"></div>
{#each $config.working_hours_schedule as daySchedule, dayIndex}
{@const dayName = daysOfWeek[dayIndex]}
<div class="mb-4">
<!-- Day header with toggle -->
<div class="flex items-center gap-3 mb-3">
<ToggleSwitch
bind:checked={$config.working_hours_schedule[dayIndex].enabled}
onchange={markChanged}
/>
<span class="text-[13px] text-white w-20">{dayName}</span>
</div>
{#if daySchedule.enabled}
<!-- Time ranges for this day -->
<div class="space-y-2">
{#each daySchedule.ranges as range, rangeIndex}
<div class="flex items-center gap-2">
<TimeSpinner
value={range.start}
countdownFont={$config.countdown_font}
onchange={(v) => updateTimeRange(dayIndex, rangeIndex, "start", v)}
/>
<span class="text-[#555] text-[13px]">to</span>
<TimeSpinner
value={range.end}
countdownFont={$config.countdown_font}
onchange={(v) => updateTimeRange(dayIndex, rangeIndex, "end", v)}
/>
<!-- Add range button -->
{#if rangeIndex === daySchedule.ranges.length - 1}
<button
use:pressable
class="ml-2 w-8 h-8 flex items-center justify-center rounded-full text-[#444] hover:text-white hover:bg-[#1a1a1a] transition-colors"
onclick={() => addTimeRange(dayIndex)}
aria-label="Add time range"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 5v14M5 12h14"/>
</svg>
</button>
{/if}
<!-- Clone button -->
<button
use:pressable
class="w-8 h-8 flex items-center justify-center rounded-full text-[#444] hover:text-white hover:bg-[#1a1a1a] transition-colors"
onclick={() => cloneTimeRange(dayIndex, rangeIndex)}
aria-label="Clone time range"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
</button>
<!-- Delete button (never show for first range) -->
{#if rangeIndex > 0}
<button
use:pressable
class="w-8 h-8 flex items-center justify-center rounded-full text-[#444] hover:text-[#f85149] hover:bg-[#f8514915] transition-colors"
onclick={() => removeTimeRange(dayIndex, rangeIndex)}
aria-label="Remove time range"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 6L6 18M6 6l12 12"/>
</svg>
</button>
{/if}
</div>
{/each}
</div>
{/if}
</div>
{#if dayIndex < 6}
<div class="my-4 h-px bg-[#161616]"></div>
{/if}
{/each}
{/if}
</section>
<!-- Idle Detection -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.21 }}>
<h3
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#666] uppercase"
>
Idle Detection
</h3>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Auto-pause when idle</div>
<div class="text-[11px] text-[#777]">Pause timer when away</div>
</div>
<ToggleSwitch
bind:checked={$config.idle_detection_enabled}
onchange={markChanged}
/>
</div>
{#if $config.idle_detection_enabled}
<div class="my-4 h-px bg-[#161616]"></div>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Idle timeout</div>
<div class="text-[11px] text-[#777]">
{$config.idle_timeout}s of inactivity
</div>
</div>
<Stepper
bind:value={$config.idle_timeout}
min={30}
max={600}
step={30}
formatValue={(v) => `${v}s`}
onchange={markChanged}
/>
</div>
{/if}
</section>
<!-- Smart Breaks -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.24 }}>
<h3
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#666] uppercase"
>
Smart Breaks
</h3>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Enable smart breaks</div>
<div class="text-[11px] text-[#777]">Auto-reset timer when you step away</div>
</div>
<ToggleSwitch
bind:checked={$config.smart_breaks_enabled}
onchange={markChanged}
/>
</div>
{#if $config.smart_breaks_enabled}
<div class="my-4 h-px bg-[#161616]"></div>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Minimum away time</div>
<div class="text-[11px] text-[#777]">
{$config.smart_break_threshold >= 60
? `${Math.floor($config.smart_break_threshold / 60)} min`
: `${$config.smart_break_threshold}s`} to count as break
</div>
</div>
<Stepper
bind:value={$config.smart_break_threshold}
min={120}
max={900}
step={60}
formatValue={(v) => `${Math.floor(v / 60)}m`}
onchange={markChanged}
/>
</div>
<div class="my-4 h-px bg-[#161616]"></div>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Count in statistics</div>
<div class="text-[11px] text-[#777]">Track natural breaks in stats</div>
</div>
<ToggleSwitch
bind:checked={$config.smart_break_count_stats}
onchange={markChanged}
/>
</div>
{/if}
</section>
<!-- Notifications -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.27 }}>
<h3
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#666] uppercase"
>
Notifications
</h3>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Pre-break alert</div>
<div class="text-[11px] text-[#777]">Warn before breaks</div>
</div>
<ToggleSwitch
bind:checked={$config.notification_enabled}
onchange={markChanged}
/>
</div>
{#if $config.notification_enabled}
<div class="my-4 h-px bg-[#161616]"></div>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Alert timing</div>
<div class="text-[11px] text-[#777]">
{$config.notification_before_break}s before
</div>
</div>
<Stepper
bind:value={$config.notification_before_break}
min={0}
max={300}
step={10}
onchange={markChanged}
/>
</div>
{/if}
</section>
<!-- Sound -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.27 }}>
<h3
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#666] uppercase"
>
Sound
</h3>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Sound effects</div>
<div class="text-[11px] text-[#777]">Play sounds on break events</div>
</div>
<ToggleSwitch
bind:checked={$config.sound_enabled}
onchange={markChanged}
/>
</div>
{#if $config.sound_enabled}
<div class="my-4 h-px bg-[#161616]"></div>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Volume</div>
<div class="text-[11px] text-[#777]">{$config.sound_volume}%</div>
</div>
<Stepper
bind:value={$config.sound_volume}
min={0}
max={100}
step={10}
formatValue={(v) => `${v}%`}
onchange={markChanged}
/>
</div>
<div class="my-4 h-px bg-[#161616]"></div>
<div>
<div class="mb-3 text-[13px] text-white">Sound preset</div>
<div class="grid grid-cols-4 gap-2">
{#each soundPresets as preset}
<button
use:pressable
class="rounded-xl py-2.5 text-[11px] tracking-wider uppercase
transition-all duration-200
{$config.sound_preset === preset
? 'bg-[#1a1a1a] text-white border border-[#333]'
: 'bg-[#0a0a0a] text-[#555] border border-[#161616] hover:border-[#333] hover:text-[#999]'}"
onclick={() => {
$config.sound_preset = preset;
markChanged();
playSound(preset, $config.sound_volume);
}}
>
{preset}
</button>
{/each}
</div>
</div>
{/if}
</section>
<!-- Appearance -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.3 }}>
<h3
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#666] uppercase"
>
Appearance
</h3>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">UI zoom</div>
<div class="text-[11px] text-[#777]">
{$config.ui_zoom}%
</div>
</div>
<Stepper
bind:value={$config.ui_zoom}
min={50}
max={200}
step={5}
formatValue={(v) => `${v}%`}
onchange={markChanged}
/>
</div>
<div class="my-4 h-px bg-[#161616]"></div>
<ColorPicker
label="Accent color"
bind:value={$config.accent_color}
onchange={markChanged}
/>
<div class="my-4 h-px bg-[#161616]"></div>
<ColorPicker
label="Break screen color"
bind:value={$config.break_color}
presets={[
"#7c6aef", "#9b5de5", "#4361ee", "#4895ef",
"#2ec4b6", "#06d6a0", "#3fb950", "#80ed99",
"#f72585", "#ff006e", "#e63946", "#ff4d00",
"#fca311", "#ffbe0b", "#ffffff", "#888888",
]}
onchange={markChanged}
/>
<div class="my-4 h-px bg-[#161616]"></div>
<FontSelector
bind:value={$config.countdown_font}
onchange={markChanged}
/>
<div class="my-4 h-px bg-[#161616]"></div>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Animated background</div>
<div class="text-[11px] text-[#777]">
Gradient blobs with film grain
</div>
</div>
<ToggleSwitch
bind:checked={$config.background_blobs_enabled}
onchange={markChanged}
/>
</div>
</section>
<!-- Mini Mode -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.33 }}>
<h3
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#666] uppercase"
>
Mini Mode
</h3>
<div class="flex items-center justify-between">
<div>
<div class="text-[13px] text-white">Click-through</div>
<div class="text-[11px] text-[#555]">
Mini timer ignores clicks until you hover over it
</div>
</div>
<ToggleSwitch
bind:checked={$config.mini_click_through}
onchange={markChanged}
/>
</div>
{#if $config.mini_click_through}
<div class="mt-4 flex items-center justify-between">
<div>
<div class="text-[13px] text-white">Hover delay</div>
<div class="text-[11px] text-[#555]">
Seconds to hover before it becomes draggable
</div>
</div>
<Stepper
bind:value={$config.mini_hover_threshold}
min={1}
max={10}
step={0.5}
formatValue={(v) => `${v}s`}
onchange={markChanged}
/>
</div>
{/if}
</section>
<!-- Shortcuts -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.36 }}>
<h3
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#666] uppercase"
>
Keyboard Shortcuts
</h3>
<div class="space-y-3">
<div class="flex items-center justify-between">
<span class="text-[13px] text-white">Pause / Resume</span>
<kbd class="rounded-lg bg-[#161616] px-2.5 py-1 text-[11px] text-[#666]">Ctrl+Shift+P</kbd>
</div>
<div class="flex items-center justify-between">
<span class="text-[13px] text-white">Start break now</span>
<kbd class="rounded-lg bg-[#161616] px-2.5 py-1 text-[11px] text-[#666]">Ctrl+Shift+B</kbd>
</div>
<div class="flex items-center justify-between">
<span class="text-[13px] text-white">Show / Hide window</span>
<kbd class="rounded-lg bg-[#161616] px-2.5 py-1 text-[11px] text-[#666]">Ctrl+Shift+S</kbd>
</div>
</div>
</section>
<!-- Reset -->
<div class="pt-2 pb-6" use:inView={{ delay: 0.39 }}>
<button
use:pressable
class="w-full rounded-full border py-3 text-[12px]
tracking-wider uppercase
transition-all duration-200
{resetConfirming
? 'border-[#f85149] text-[#f85149] hover:bg-[#f85149] hover:text-white'
: 'border-[#1a1a1a] text-[#444] hover:border-[#333] hover:text-white'}"
onclick={handleReset}
>
{resetConfirming ? "Tap again to confirm reset" : "Reset to defaults"}
</button>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,274 @@
<script lang="ts">
import { invoke } from "@tauri-apps/api/core";
import { currentView } from "../stores/timer";
import { config } from "../stores/config";
import { fadeIn, inView, pressable, dragScroll } from "../utils/animate";
interface StatsSnapshot {
todayCompleted: number;
todaySkipped: number;
todaySnoozed: number;
todayBreakTimeSecs: number;
complianceRate: number;
currentStreak: number;
bestStreak: number;
}
interface DayRecord {
date: string;
breaksCompleted: number;
breaksSkipped: number;
breaksSnoozed: number;
totalBreakTimeSecs: number;
}
let stats = $state<StatsSnapshot | null>(null);
let history = $state<DayRecord[]>([]);
async function loadStats() {
try {
stats = await invoke<StatsSnapshot>("get_stats");
history = await invoke<DayRecord[]>("get_daily_history", { days: 7 });
} catch (e) {
console.error("Failed to load stats:", e);
}
}
$effect(() => {
loadStats();
});
function goBack() {
invoke("set_view", { view: "dashboard" });
currentView.set("dashboard");
}
const compliancePercent = $derived(
stats ? Math.round(stats.complianceRate * 100) : 100,
);
const breakTimeFormatted = $derived(() => {
if (!stats) return "0 min";
const mins = Math.floor(stats.todayBreakTimeSecs / 60);
if (mins < 60) return `${mins} min`;
const hrs = Math.floor(mins / 60);
const rem = mins % 60;
return `${hrs}h ${rem}m`;
});
// Chart rendering
let chartCanvas: HTMLCanvasElement | undefined = $state();
$effect(() => {
if (!chartCanvas || history.length === 0) return;
drawChart(chartCanvas, history);
});
function drawChart(canvas: HTMLCanvasElement, data: DayRecord[]) {
const ctx = canvas.getContext("2d");
if (!ctx) return;
const dpr = window.devicePixelRatio || 1;
const w = canvas.clientWidth;
const h = canvas.clientHeight;
canvas.width = w * dpr;
canvas.height = h * dpr;
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, w, h);
const maxBreaks = Math.max(1, ...data.map((d) => d.breaksCompleted + d.breaksSkipped));
const barWidth = Math.floor((w - 40) / data.length) - 8;
const barGap = 8;
const chartHeight = h - 30;
const accentColor = $config.accent_color || "#ff4d00";
data.forEach((day, i) => {
const x = 20 + i * (barWidth + barGap);
const total = day.breaksCompleted + day.breaksSkipped;
const completedH = total > 0 ? (day.breaksCompleted / maxBreaks) * chartHeight : 0;
const skippedH = total > 0 ? (day.breaksSkipped / maxBreaks) * chartHeight : 0;
// Completed bar
if (completedH > 0) {
ctx.fillStyle = accentColor;
ctx.beginPath();
const barY = chartHeight - completedH;
roundedRect(ctx, x, barY, barWidth, completedH, 4);
ctx.fill();
}
// Skipped bar (stacked on top)
if (skippedH > 0) {
ctx.fillStyle = "#333";
ctx.beginPath();
const barY = chartHeight - completedH - skippedH;
roundedRect(ctx, x, barY, barWidth, skippedH, 4);
ctx.fill();
}
// Day label
ctx.fillStyle = "#444";
ctx.font = "10px -apple-system, sans-serif";
ctx.textAlign = "center";
const label = day.date.slice(5); // "MM-DD"
ctx.fillText(label, x + barWidth / 2, h - 5);
});
}
function roundedRect(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
w: number,
h: number,
r: number,
) {
r = Math.min(r, h / 2, w / 2);
ctx.moveTo(x + r, y);
ctx.lineTo(x + w - r, y);
ctx.quadraticCurveTo(x + w, y, x + w, y + r);
ctx.lineTo(x + w, y + h - r);
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
ctx.lineTo(x + r, y + h);
ctx.quadraticCurveTo(x, y + h, x, y + h - r);
ctx.lineTo(x, y + r);
ctx.quadraticCurveTo(x, y, x + r, y);
}
</script>
<div class="flex h-full flex-col">
<!-- Header -->
<div
data-tauri-drag-region
class="flex items-center px-5 pt-5 pb-4"
use:fadeIn={{ duration: 0.4, y: 8 }}
>
<button
aria-label="Back to dashboard"
use:pressable
class="mr-3 flex h-8 w-8 items-center justify-center rounded-full
text-[#444] transition-colors hover:text-white"
onclick={goBack}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="15 18 9 12 15 6" />
</svg>
</button>
<h1
data-tauri-drag-region
class="flex-1 text-lg font-medium text-white"
>
Statistics
</h1>
</div>
<!-- Scrollable content -->
<div class="flex-1 overflow-y-auto px-5 pb-6" use:dragScroll>
<div class="space-y-3">
<!-- Today's summary -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0 }}>
<h3
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#666] uppercase"
>
Today
</h3>
<div class="grid grid-cols-2 gap-4">
<div class="text-center">
<div class="text-[28px] font-semibold text-white tabular-nums">
{stats?.todayCompleted ?? 0}
</div>
<div class="text-[11px] text-[#777]">Breaks taken</div>
</div>
<div class="text-center">
<div class="text-[28px] font-semibold tabular-nums"
style="color: {$config.accent_color}"
>
{compliancePercent}%
</div>
<div class="text-[11px] text-[#777]">Compliance</div>
</div>
<div class="text-center">
<div class="text-[28px] font-semibold text-white tabular-nums">
{breakTimeFormatted()}
</div>
<div class="text-[11px] text-[#777]">Break time</div>
</div>
<div class="text-center">
<div class="text-[28px] font-semibold text-white tabular-nums">
{stats?.todaySkipped ?? 0}
</div>
<div class="text-[11px] text-[#777]">Skipped</div>
</div>
</div>
</section>
<!-- Streak -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.06 }}>
<h3
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#666] uppercase"
>
Streak
</h3>
<div class="flex items-center justify-between">
<div>
<div class="text-[13px] text-white">Current streak</div>
<div class="text-[11px] text-[#777]">Consecutive days with breaks</div>
</div>
<div class="text-[24px] font-semibold tabular-nums" style="color: {$config.accent_color}">
{stats?.currentStreak ?? 0}
</div>
</div>
<div class="my-4 h-px bg-[#161616]"></div>
<div class="flex items-center justify-between">
<div>
<div class="text-[13px] text-white">Best streak</div>
<div class="text-[11px] text-[#777]">All-time record</div>
</div>
<div class="text-[24px] font-semibold text-white tabular-nums">
{stats?.bestStreak ?? 0}
</div>
</div>
</section>
<!-- Weekly chart -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.12 }}>
<h3
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#666] uppercase"
>
Last 7 Days
</h3>
<canvas
bind:this={chartCanvas}
class="h-[140px] w-full"
></canvas>
<div class="mt-3 flex items-center justify-center gap-4 text-[10px] text-[#777]">
<div class="flex items-center gap-1.5">
<div class="h-2 w-2 rounded-sm" style="background: {$config.accent_color}"></div>
Completed
</div>
<div class="flex items-center gap-1.5">
<div class="h-2 w-2 rounded-sm bg-[#333]"></div>
Skipped
</div>
</div>
</section>
</div>
</div>
</div>

View File

@@ -0,0 +1,88 @@
<script lang="ts">
interface Props {
value: number;
min?: number;
max?: number;
step?: number;
formatValue?: (v: number) => string;
onchange?: (value: number) => void;
}
let {
value = $bindable(),
min = 0,
max = 100,
step = 1,
formatValue = (v: number) => String(v),
onchange,
}: Props = $props();
let holdTimer: ReturnType<typeof setTimeout> | null = null;
let holdInterval: ReturnType<typeof setInterval> | null = null;
function decrement() {
if (value > min) {
value = Math.max(min, value - step);
onchange?.(value);
}
}
function increment() {
if (value < max) {
value = Math.min(max, value + step);
onchange?.(value);
}
}
function startHold(fn: () => void) {
fn();
// Initial delay before repeating
holdTimer = setTimeout(() => {
// Start repeating, accelerate over time
let delay = 150;
function tick() {
fn();
delay = Math.max(40, delay * 0.85);
holdInterval = setTimeout(tick, delay) as unknown as ReturnType<typeof setInterval>;
}
tick();
}, 400);
}
function stopHold() {
if (holdTimer) { clearTimeout(holdTimer); holdTimer = null; }
if (holdInterval) { clearTimeout(holdInterval as unknown as ReturnType<typeof setTimeout>); holdInterval = null; }
}
</script>
<div class="flex items-center gap-1.5">
<button
type="button"
class="flex h-7 w-7 items-center justify-center rounded-lg
bg-[#141414] text-[#444] transition-colors
hover:bg-[#1c1c1c] hover:text-white
disabled:opacity-20"
onmousedown={() => startHold(decrement)}
onmouseup={stopHold}
onmouseleave={stopHold}
disabled={value <= min}
>
&minus;
</button>
<span class="min-w-[38px] text-center text-[13px] tabular-nums text-white">
{formatValue(value)}
</span>
<button
type="button"
class="flex h-7 w-7 items-center justify-center rounded-lg
bg-[#141414] text-[#444] transition-colors
hover:bg-[#1c1c1c] hover:text-white
disabled:opacity-20"
onmousedown={() => startHold(increment)}
onmouseup={stopHold}
onmouseleave={stopHold}
disabled={value >= max}
>
+
</button>
</div>

View File

@@ -0,0 +1,399 @@
<script lang="ts">
interface Props {
value: string;
onchange?: (value: string) => void;
countdownFont?: string;
}
let { value, onchange, countdownFont = "" }: Props = $props();
// Local display values — driven by prop normally, overridden during drag/momentum
let displayHours = $state(0);
let displayMinutes = $state(0);
let isAnimating = $state(false); // true during drag OR momentum
// Fractional offset for smooth wheel rotation (0 to <1)
let hoursFraction = $state(0);
let minutesFraction = $state(0);
// Sync display from prop when NOT dragging/animating
$effect(() => {
if (!isAnimating) {
displayHours = parseInt(value.split(":")[0]) || 0;
displayMinutes = parseInt(value.split(":")[1]) || 0;
hoursFraction = 0;
minutesFraction = 0;
}
});
// ── Drag state ──
let hoursDragging = $state(false);
let hoursLastY = $state(0);
let hoursAccumPx = $state(0);
let hoursBaseValue = $state(0);
let hoursVelocity = $state(0);
let hoursLastMoveTime = $state(0);
let minutesDragging = $state(false);
let minutesLastY = $state(0);
let minutesAccumPx = $state(0);
let minutesBaseValue = $state(0);
let minutesVelocity = $state(0);
let minutesLastMoveTime = $state(0);
// Momentum animation handle
let momentumRaf: number | null = null;
const SENSITIVITY = 20; // pixels per value step
const ITEM_ANGLE = 30; // degrees between items on the cylinder
const WHEEL_RADIUS = 26; // cylinder radius in px
const FRICTION = 0.93; // momentum decay per frame
const MIN_VELOCITY = 0.3; // px/frame threshold to stop momentum
function wrapValue(v: number, max: number): number {
return ((v % max) + max) % max;
}
function emitValue(h: number, m: number) {
onchange?.(`${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`);
}
function stopMomentum() {
if (momentumRaf !== null) {
cancelAnimationFrame(momentumRaf);
momentumRaf = null;
}
}
// Build the visible items for the 3D wheel
function getWheelItems(current: number, fraction: number, maxVal: number) {
const items = [];
for (let i = -2; i <= 2; i++) {
const val = wrapValue(current + i, maxVal);
const angle = (i - fraction) * ITEM_ANGLE;
const absAngle = Math.abs(angle);
// Center item bright, off-center very dim
const isCenter = absAngle < ITEM_ANGLE * 0.5;
const opacity = isCenter
? Math.max(0.7, 1 - absAngle / 60)
: Math.max(0.05, 0.35 - absAngle / 120);
items.push({ value: val, angle, opacity });
}
return items;
}
const hoursItems = $derived(getWheelItems(displayHours, hoursFraction, 24));
const minutesItems = $derived(getWheelItems(displayMinutes, minutesFraction, 60));
// ── Shared step logic (used by both drag and momentum) ──
function applyAccum(
field: "hours" | "minutes",
accumPx: number,
baseValue: number,
): { newVal: number; fraction: number } {
const totalSteps = accumPx / SENSITIVITY;
const wholeSteps = Math.round(totalSteps);
const fraction = totalSteps - wholeSteps;
const maxVal = field === "hours" ? 24 : 60;
const newVal = wrapValue(baseValue + wholeSteps, maxVal);
return { newVal, fraction };
}
// ── Hours pointer handlers ──
function handleHoursPointerDown(e: PointerEvent) {
e.preventDefault();
e.stopPropagation();
stopMomentum();
hoursDragging = true;
isAnimating = true;
hoursLastY = e.clientY;
hoursAccumPx = 0;
hoursBaseValue = displayHours;
hoursVelocity = 0;
hoursLastMoveTime = performance.now();
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
}
function handleHoursPointerMove(e: PointerEvent) {
if (!hoursDragging) return;
e.preventDefault();
const dy = hoursLastY - e.clientY;
hoursAccumPx += dy;
hoursLastY = e.clientY;
// Track velocity (px/ms)
const now = performance.now();
const dt = now - hoursLastMoveTime;
if (dt > 0) hoursVelocity = dy / dt;
hoursLastMoveTime = now;
const { newVal, fraction } = applyAccum("hours", hoursAccumPx, hoursBaseValue);
hoursFraction = fraction;
if (newVal !== displayHours) {
displayHours = newVal;
emitValue(newVal, displayMinutes);
}
}
function handleHoursPointerUp(e: PointerEvent) {
if (!hoursDragging) return;
e.preventDefault();
hoursDragging = false;
// Launch momentum if velocity is significant
const velocityPxPerFrame = hoursVelocity * 16; // convert px/ms to px/frame (~16ms)
if (Math.abs(velocityPxPerFrame) > MIN_VELOCITY) {
startMomentum("hours", velocityPxPerFrame);
} else {
hoursFraction = 0;
isAnimating = false;
}
}
// ── Minutes pointer handlers ──
function handleMinutesPointerDown(e: PointerEvent) {
e.preventDefault();
e.stopPropagation();
stopMomentum();
minutesDragging = true;
isAnimating = true;
minutesLastY = e.clientY;
minutesAccumPx = 0;
minutesBaseValue = displayMinutes;
minutesVelocity = 0;
minutesLastMoveTime = performance.now();
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
}
function handleMinutesPointerMove(e: PointerEvent) {
if (!minutesDragging) return;
e.preventDefault();
const dy = minutesLastY - e.clientY;
minutesAccumPx += dy;
minutesLastY = e.clientY;
const now = performance.now();
const dt = now - minutesLastMoveTime;
if (dt > 0) minutesVelocity = dy / dt;
minutesLastMoveTime = now;
const { newVal, fraction } = applyAccum("minutes", minutesAccumPx, minutesBaseValue);
minutesFraction = fraction;
if (newVal !== displayMinutes) {
displayMinutes = newVal;
emitValue(displayHours, newVal);
}
}
function handleMinutesPointerUp(e: PointerEvent) {
if (!minutesDragging) return;
e.preventDefault();
minutesDragging = false;
const velocityPxPerFrame = minutesVelocity * 16;
if (Math.abs(velocityPxPerFrame) > MIN_VELOCITY) {
startMomentum("minutes", velocityPxPerFrame);
} else {
minutesFraction = 0;
isAnimating = false;
}
}
// ── Momentum animation ──
function startMomentum(field: "hours" | "minutes", velocity: number) {
stopMomentum();
function tick() {
velocity *= FRICTION;
if (Math.abs(velocity) < MIN_VELOCITY) {
// Snap to nearest value
if (field === "hours") hoursFraction = 0;
else minutesFraction = 0;
isAnimating = false;
momentumRaf = null;
return;
}
if (field === "hours") {
hoursAccumPx += velocity;
const { newVal, fraction } = applyAccum("hours", hoursAccumPx, hoursBaseValue);
hoursFraction = fraction;
if (newVal !== displayHours) {
displayHours = newVal;
emitValue(newVal, displayMinutes);
}
} else {
minutesAccumPx += velocity;
const { newVal, fraction } = applyAccum("minutes", minutesAccumPx, minutesBaseValue);
minutesFraction = fraction;
if (newVal !== displayMinutes) {
displayMinutes = newVal;
emitValue(displayHours, newVal);
}
}
momentumRaf = requestAnimationFrame(tick);
}
momentumRaf = requestAnimationFrame(tick);
}
function format(n: number): string {
return String(n).padStart(2, "0");
}
</script>
<div class="time-spinner" style="font-family: {countdownFont ? `'${countdownFont}', monospace` : 'monospace'};">
<!-- Hours wheel -->
<div
class="wheel-field"
class:dragging={hoursDragging}
role="slider"
aria-label="Hours"
aria-valuemin={0}
aria-valuemax={23}
aria-valuenow={displayHours}
tabindex={0}
onpointerdown={handleHoursPointerDown}
onpointermove={handleHoursPointerMove}
onpointerup={handleHoursPointerUp}
onpointercancel={handleHoursPointerUp}
>
<div class="wheel-viewport">
<div class="wheel-cylinder">
{#each hoursItems as item}
<div
class="wheel-item"
style="
transform: translateY(-50%) rotateX({-item.angle}deg) translateZ({WHEEL_RADIUS}px);
opacity: {item.opacity};
"
>
{format(item.value)}
</div>
{/each}
</div>
</div>
<span class="unit-badge">h</span>
</div>
<span class="separator">:</span>
<!-- Minutes wheel -->
<div
class="wheel-field"
class:dragging={minutesDragging}
role="slider"
aria-label="Minutes"
aria-valuemin={0}
aria-valuemax={59}
aria-valuenow={displayMinutes}
tabindex={0}
onpointerdown={handleMinutesPointerDown}
onpointermove={handleMinutesPointerMove}
onpointerup={handleMinutesPointerUp}
onpointercancel={handleMinutesPointerUp}
>
<div class="wheel-viewport">
<div class="wheel-cylinder">
{#each minutesItems as item}
<div
class="wheel-item"
style="
transform: translateY(-50%) rotateX({-item.angle}deg) translateZ({WHEEL_RADIUS}px);
opacity: {item.opacity};
"
>
{format(item.value)}
</div>
{/each}
</div>
</div>
<span class="unit-badge">m</span>
</div>
</div>
<style>
.time-spinner {
display: inline-flex;
align-items: center;
gap: 4px;
user-select: none;
touch-action: none;
}
.wheel-field {
position: relative;
width: 50px;
height: 36px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.08);
cursor: ns-resize;
touch-action: none;
overflow: hidden;
}
.wheel-field.dragging {
background: rgba(255, 255, 255, 0.07);
border-color: rgba(255, 255, 255, 0.18);
}
/* Perspective container — looking into the cylinder from outside */
.wheel-viewport {
width: 100%;
height: 100%;
perspective: 90px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
/* The 3D cylinder that holds number items */
.wheel-cylinder {
position: relative;
width: 100%;
height: 0;
transform-style: preserve-3d;
}
/* Individual number on the cylinder surface */
.wheel-item {
position: absolute;
width: 100%;
text-align: center;
font-size: 11px;
font-weight: 600;
color: rgba(255, 255, 255, 0.95);
line-height: 1;
backface-visibility: hidden;
pointer-events: none;
padding-right: 12px;
}
/* Unit label pinned to the right of the field */
.unit-badge {
position: absolute;
right: 5px;
top: 50%;
transform: translateY(-50%);
font-size: 10px;
font-weight: 500;
color: rgba(255, 255, 255, 0.3);
pointer-events: none;
}
.separator {
color: rgba(255, 255, 255, 0.25);
font-weight: 500;
user-select: none;
}
</style>

View File

@@ -0,0 +1,177 @@
<script lang="ts">
interface Props {
progress: number;
size?: number;
strokeWidth?: number;
accentColor?: string;
children?: import("svelte").Snippet;
}
let {
progress,
size = 280,
strokeWidth = 8,
accentColor = "#ff4d00",
children,
}: Props = $props();
const padding = 40;
const viewSize = $derived(size + padding * 2);
const radius = $derived((size - strokeWidth) / 2);
const circumference = $derived(2 * Math.PI * radius);
const dashOffset = $derived(circumference * (1 - progress));
const center = $derived(viewSize / 2);
// Unique filter IDs based on color to avoid SVG collisions
const fid = $derived(`ring-${size}-${accentColor.replace('#', '')}`);
// Derive lighter shade for gradient end
function lighten(hex: string, amount: number): string {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
const lr = Math.min(255, r + (255 - r) * amount);
const lg = Math.min(255, g + (255 - g) * amount);
const lb = Math.min(255, b + (255 - b) * amount);
return `#${Math.round(lr).toString(16).padStart(2, '0')}${Math.round(lg).toString(16).padStart(2, '0')}${Math.round(lb).toString(16).padStart(2, '0')}`;
}
const gradA = $derived(accentColor);
const gradB = $derived(lighten(accentColor, 0.2));
const gradC = $derived(lighten(accentColor, 0.4));
</script>
<div
class="relative flex items-center justify-center"
style="width: {size}px; height: {size}px;"
>
<!-- Glow SVG — drawn larger than the container so blur isn't clipped -->
<svg
width={viewSize}
height={viewSize}
class="pointer-events-none absolute"
style="
left: {-padding}px;
top: {-padding}px;
transform: rotate(-90deg);
overflow: visible;
"
>
<defs>
<linearGradient id="ring-grad-{fid}" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color={gradA} />
<stop offset="50%" stop-color={gradB} />
<stop offset="100%" stop-color={gradC} />
</linearGradient>
<!-- Wide ambient glow filter -->
<filter id="glow-wide-{fid}" x="-100%" y="-100%" width="300%" height="300%">
<feGaussianBlur stdDeviation="28" />
</filter>
<!-- Medium glow filter -->
<filter id="glow-mid-{fid}" x="-60%" y="-60%" width="220%" height="220%">
<feGaussianBlur stdDeviation="12" />
</filter>
<!-- Core tight glow filter -->
<filter id="glow-core-{fid}" x="-40%" y="-40%" width="180%" height="180%">
<feGaussianBlur stdDeviation="5" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
{#if progress > 0.002}
<!-- Layer 1: Wide ambient bloom -->
<circle
cx={center}
cy={center}
r={radius}
fill="none"
stroke="url(#ring-grad-{fid})"
stroke-width={strokeWidth * 4}
stroke-linecap="round"
stroke-dasharray={circumference}
stroke-dashoffset={dashOffset}
filter="url(#glow-wide-{fid})"
opacity="0.35"
class="transition-[stroke-dashoffset] duration-1000 ease-linear"
/>
<!-- Layer 2: Medium glow -->
<circle
cx={center}
cy={center}
r={radius}
fill="none"
stroke="url(#ring-grad-{fid})"
stroke-width={strokeWidth * 2}
stroke-linecap="round"
stroke-dasharray={circumference}
stroke-dashoffset={dashOffset}
filter="url(#glow-mid-{fid})"
opacity="0.5"
class="transition-[stroke-dashoffset] duration-1000 ease-linear"
/>
<!-- Layer 3: Core ring with tight glow -->
<circle
cx={center}
cy={center}
r={radius}
fill="none"
stroke="url(#ring-grad-{fid})"
stroke-width={strokeWidth}
stroke-linecap="round"
stroke-dasharray={circumference}
stroke-dashoffset={dashOffset}
filter="url(#glow-core-{fid})"
class="transition-[stroke-dashoffset] duration-1000 ease-linear"
/>
{/if}
</svg>
<!-- Non-glow SVG — exact size, draws the track + crisp ring -->
<svg
width={size}
height={size}
class="absolute"
style="transform: rotate(-90deg);"
>
<!-- Background track -->
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="#161616"
stroke-width={strokeWidth}
/>
{#if progress > 0.002}
<!-- Sharp foreground ring (no filter) -->
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="url(#ring-grad-{fid})"
stroke-width={strokeWidth}
stroke-linecap="round"
stroke-dasharray={circumference}
stroke-dashoffset={dashOffset}
class="transition-[stroke-dashoffset] duration-1000 ease-linear"
/>
{/if}
</svg>
<!-- Content overlay -->
<div class="absolute inset-0 flex flex-col items-center justify-center">
{#if children}
{@render children()}
{/if}
</div>
</div>

View File

@@ -0,0 +1,83 @@
<script lang="ts">
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
const appWindow = getCurrentWebviewWindow();
</script>
<!-- Invisible drag region traffic lights on the right -->
<div
data-tauri-drag-region
class="group absolute top-0 left-0 right-0 z-50 flex h-10 items-center justify-end pr-3.5 select-none"
>
<!-- Centered app name -->
<span
class="pointer-events-none absolute inset-0 flex items-center justify-center
text-[11px] font-semibold tracking-[0.3em] text-[#383838] uppercase"
style="font-family: 'Space Mono', monospace;"
>
Core Cooldown
</span>
<!-- Traffic light buttons (order: maximize, minimize, close → close is rightmost) -->
<div class="flex items-center gap-[8px] opacity-10 transition-opacity duration-300 group-hover:opacity-100">
<!-- Maximize (green) -->
<button
aria-label="Maximize"
class="traffic-btn group/btn relative flex h-[15px] w-[15px] items-center justify-center
rounded-full bg-[#27C93F] transition-all duration-150
hover:brightness-110"
onclick={() => appWindow.toggleMaximize()}
>
<svg
class="absolute opacity-0 transition-opacity duration-150 group-hover/btn:opacity-100"
width="8"
height="8"
viewBox="0 0 8 8"
fill="none"
>
<polyline points="1,5 1,7 3,7" stroke="#006500" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" fill="none" />
<polyline points="7,3 7,1 5,1" stroke="#006500" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" fill="none" />
<line x1="1" y1="7" x2="7" y2="1" stroke="#006500" stroke-width="1.2" stroke-linecap="round" />
</svg>
</button>
<!-- Minimize (yellow) -->
<button
aria-label="Minimize"
class="traffic-btn group/btn relative flex h-[15px] w-[15px] items-center justify-center
rounded-full bg-[#FFBD2E] transition-all duration-150
hover:brightness-110"
onclick={() => appWindow.minimize()}
>
<svg
class="absolute opacity-0 transition-opacity duration-150 group-hover/btn:opacity-100"
width="8"
height="2"
viewBox="0 0 8 2"
fill="none"
>
<line x1="1" y1="1" x2="7" y2="1" stroke="#995700" stroke-width="1.3" stroke-linecap="round" />
</svg>
</button>
<!-- Close (red) — rightmost -->
<button
aria-label="Close"
class="traffic-btn group/btn relative flex h-[15px] w-[15px] items-center justify-center
rounded-full bg-[#FF5F57] transition-all duration-150
hover:brightness-110"
onclick={() => appWindow.close()}
>
<svg
class="absolute opacity-0 transition-opacity duration-150 group-hover/btn:opacity-100"
width="8"
height="8"
viewBox="0 0 8 8"
fill="none"
>
<line x1="1.5" y1="1.5" x2="6.5" y2="6.5" stroke="#4a0002" stroke-width="1.3" stroke-linecap="round" />
<line x1="6.5" y1="1.5" x2="1.5" y2="6.5" stroke="#4a0002" stroke-width="1.3" stroke-linecap="round" />
</svg>
</button>
</div>
</div>

View File

@@ -0,0 +1,32 @@
<script lang="ts">
import { config } from "../stores/config";
interface Props {
checked: boolean;
onchange?: (value: boolean) => void;
}
let { checked = $bindable(), onchange }: Props = $props();
function toggle() {
checked = !checked;
onchange?.(checked);
}
</script>
<button
type="button"
role="switch"
aria-label="Toggle"
aria-checked={checked}
class="relative inline-flex h-[24px] w-[48px] shrink-0 cursor-pointer rounded-full
transition-colors duration-200 ease-in-out focus:outline-none"
style="background: {checked ? $config.accent_color : '#1a1a1a'};"
onclick={toggle}
>
<span
class="pointer-events-none inline-block h-[19px] w-[19px] rounded-full
shadow-sm transition-transform duration-200 ease-in-out
{checked ? 'translate-x-[26px] bg-white' : 'translate-x-[3px] bg-[#444]'} mt-[2.5px]"
></span>
</button>

149
src/lib/stores/config.ts Normal file
View File

@@ -0,0 +1,149 @@
import { writable, get } from "svelte/store";
import { invoke } from "@tauri-apps/api/core";
export interface TimeRange {
start: string; // Format: "HH:MM"
end: string; // Format: "HH:MM"
}
export interface DaySchedule {
enabled: boolean;
ranges: TimeRange[];
}
export type { TimeRange, DaySchedule };
export interface Config {
break_duration: number;
break_frequency: number;
auto_start: boolean;
break_title: string;
break_message: string;
fullscreen_mode: boolean;
strict_mode: boolean;
allow_end_early: boolean;
immediately_start_breaks: boolean;
working_hours_enabled: boolean;
working_hours_schedule: DaySchedule[]; // 7 days: Mon, Tue, Wed, Thu, Fri, Sat, Sun
dark_mode: boolean;
color_scheme: string;
backdrop_opacity: number;
notification_enabled: boolean;
notification_before_break: number;
snooze_duration: number;
snooze_limit: number;
skip_cooldown: number;
sound_enabled: boolean;
sound_volume: number;
sound_preset: string;
idle_detection_enabled: boolean;
idle_timeout: number;
smart_breaks_enabled: boolean;
smart_break_threshold: number;
smart_break_count_stats: boolean;
show_break_activities: boolean;
ui_zoom: number;
accent_color: string;
break_color: string;
countdown_font: string;
background_blobs_enabled: boolean;
mini_click_through: boolean;
mini_hover_threshold: number;
}
const defaultConfig: Config = {
break_duration: 5,
break_frequency: 25,
auto_start: true,
break_title: "Rest your eyes",
break_message: "Look away from the screen. Stretch and relax.",
fullscreen_mode: true,
strict_mode: false,
allow_end_early: true,
immediately_start_breaks: false,
working_hours_enabled: false,
working_hours_schedule: [
{ enabled: true, ranges: [{ start: "09:00", end: "18:00" }] }, // Monday
{ enabled: true, ranges: [{ start: "09:00", end: "18:00" }] }, // Tuesday
{ enabled: true, ranges: [{ start: "09:00", end: "18:00" }] }, // Wednesday
{ enabled: true, ranges: [{ start: "09:00", end: "18:00" }] }, // Thursday
{ enabled: true, ranges: [{ start: "09:00", end: "18:00" }] }, // Friday
{ enabled: false, ranges: [{ start: "09:00", end: "18:00" }] }, // Saturday
{ enabled: false, ranges: [{ start: "09:00", end: "18:00" }] }, // Sunday
],
dark_mode: true,
color_scheme: "Ocean",
backdrop_opacity: 0.92,
notification_enabled: true,
notification_before_break: 30,
snooze_duration: 5,
snooze_limit: 3,
skip_cooldown: 60,
sound_enabled: true,
sound_volume: 70,
sound_preset: "bell",
idle_detection_enabled: true,
idle_timeout: 120,
smart_breaks_enabled: true,
smart_break_threshold: 300,
smart_break_count_stats: false,
show_break_activities: true,
ui_zoom: 100,
accent_color: "#ff4d00",
break_color: "#7c6aef",
countdown_font: "",
background_blobs_enabled: true,
mini_click_through: true,
mini_hover_threshold: 3.0,
};
export const config = writable<Config>(defaultConfig);
export async function loadConfig() {
try {
const cfg = await invoke<Config>("get_config");
config.set(cfg);
} catch (e) {
console.error("Failed to load config:", e);
}
}
export async function saveConfig(): Promise<boolean> {
try {
const cfg = get(config);
await invoke("save_config", { config: cfg });
return true;
} catch (e) {
console.error("Failed to save config:", e);
return false;
}
}
export async function notifyConfigChanged() {
try {
const cfg = get(config);
await invoke("update_pending_config", { config: cfg });
} catch (e) {
console.error("Failed to update pending config:", e);
}
}
export async function resetConfig() {
try {
const cfg = await invoke<Config>("reset_config");
config.set(cfg);
} catch (e) {
console.error("Failed to reset config:", e);
}
}
// Debounced auto-save: updates backend immediately, persists to disk after 400ms idle
let saveTimer: ReturnType<typeof setTimeout> | null = null;
export function autoSave() {
notifyConfigChanged();
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
saveConfig();
}, 400);
}

133
src/lib/stores/timer.ts Normal file
View File

@@ -0,0 +1,133 @@
import { writable, get } from "svelte/store";
import { listen } from "@tauri-apps/api/event";
import { invoke } from "@tauri-apps/api/core";
import { playSound, playBreakEndSound } from "../utils/sounds";
import { config } from "./config";
export interface TimerSnapshot {
state: "running" | "paused" | "breakActive";
currentView: "dashboard" | "breakScreen" | "settings" | "stats";
timeRemaining: number;
totalDuration: number;
progress: number;
hasHadBreak: boolean;
secondsSinceLastBreak: number;
prebreakWarning: boolean;
snoozesUsed: number;
canSnooze: boolean;
breakTitle: string;
breakMessage: string;
breakProgress: number;
breakTimeRemaining: number;
breakTotalDuration: number;
breakPastHalf: boolean;
settingsModified: boolean;
idlePaused: boolean;
naturalBreakOccurred: boolean;
smartBreaksEnabled: boolean;
smartBreakThreshold: number;
}
const defaultSnapshot: TimerSnapshot = {
state: "paused",
currentView: "dashboard",
timeRemaining: 1500,
totalDuration: 1500,
progress: 1.0,
hasHadBreak: false,
secondsSinceLastBreak: 0,
prebreakWarning: false,
snoozesUsed: 0,
canSnooze: true,
breakTitle: "Rest your eyes",
breakMessage: "Look away from the screen. Stretch and relax.",
breakProgress: 0,
breakTimeRemaining: 0,
breakTotalDuration: 0,
breakPastHalf: false,
settingsModified: false,
idlePaused: false,
naturalBreakOccurred: false,
smartBreaksEnabled: true,
smartBreakThreshold: 300,
};
export const timer = writable<TimerSnapshot>(defaultSnapshot);
// Track the current view separately so UI can switch views optimistically
export const currentView = writable<
"dashboard" | "breakScreen" | "settings" | "stats"
>("dashboard");
let initialized = false;
export async function initTimerStore() {
if (initialized) return;
initialized = true;
// Get initial state
try {
const snapshot = await invoke<TimerSnapshot>("get_timer_state");
timer.set(snapshot);
currentView.set(snapshot.currentView);
} catch (e) {
console.error("Failed to get initial timer state:", e);
}
// Listen for tick events
await listen<TimerSnapshot>("timer-tick", (event) => {
timer.set(event.payload);
// Sync view from backend (backend is authoritative for break transitions)
currentView.set(event.payload.currentView);
});
// Listen for pre-break warning
await listen("prebreak-warning", () => {
const cfg = get(config);
if (cfg.sound_enabled) {
playSound(cfg.sound_preset as any, cfg.sound_volume * 0.6);
}
});
// Listen for break started
await listen("break-started", () => {
currentView.set("breakScreen");
const cfg = get(config);
if (cfg.sound_enabled) {
playSound(cfg.sound_preset as any, cfg.sound_volume);
}
});
// Listen for break ended
await listen("break-ended", () => {
currentView.set("dashboard");
const cfg = get(config);
if (cfg.sound_enabled) {
playBreakEndSound(cfg.sound_preset as any, cfg.sound_volume);
}
});
// Listen for natural break detected
await listen<number>("natural-break-detected", (event) => {
const cfg = get(config);
if (cfg.sound_enabled) {
playSound(cfg.sound_preset as any, cfg.sound_volume * 0.5);
}
});
}
// Helper: format seconds as MM:SS
export function formatTime(secs: number): string {
const m = Math.floor(secs / 60);
const s = secs % 60;
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
}
// Helper: format "X ago" string
export function formatDurationAgo(secs: number): string {
if (secs < 60) return `${secs} sec ago`;
const m = Math.floor(secs / 60);
if (m < 60) return m === 1 ? "1 min ago" : `${m} min ago`;
const h = Math.floor(secs / 3600);
return h === 1 ? "1 hr ago" : `${h} hrs ago`;
}

115
src/lib/utils/activities.ts Normal file
View File

@@ -0,0 +1,115 @@
export interface BreakActivity {
category: "eyes" | "stretch" | "breathing" | "movement";
text: string;
}
export const breakActivities: BreakActivity[] = [
// Eyes
{ category: "eyes", text: "Look at something 20 feet away for 20 seconds" },
{ category: "eyes", text: "Close your eyes and gently press your palms over them" },
{ category: "eyes", text: "Slowly roll your eyes in circles, 5 times each direction" },
{ category: "eyes", text: "Focus on a distant object, then a near one — repeat 5 times" },
{ category: "eyes", text: "Blink rapidly 20 times to refresh your eyes" },
{ category: "eyes", text: "Look up, down, left, right — hold each for 2 seconds" },
{ category: "eyes", text: "Cup your hands over closed eyes and breathe deeply" },
{ category: "eyes", text: "Trace a figure-8 with your eyes, slowly" },
{ category: "eyes", text: "Look out the nearest window and find the farthest point" },
{ category: "eyes", text: "Gently massage your temples in small circles" },
{ category: "eyes", text: "Close your eyes and visualize a calm, dark space for 20 seconds" },
{ category: "eyes", text: "Warm your palms by rubbing them together, then rest them over closed eyes" },
{ category: "eyes", text: "Slowly shift your gaze along the horizon line outside" },
{ category: "eyes", text: "Focus on the tip of your nose for 5 seconds, then a far wall" },
{ category: "eyes", text: "Look at something green — plants reduce eye strain naturally" },
{ category: "eyes", text: "Close your eyes and gently press your eyebrows outward from center" },
{ category: "eyes", text: "Squeeze your eyes shut for 3 seconds, then open wide — repeat 5 times" },
{ category: "eyes", text: "Trace the outline of a window frame with your eyes, slowly" },
// Stretches
{ category: "stretch", text: "Roll your shoulders backward slowly, 5 times" },
{ category: "stretch", text: "Interlace fingers behind your back and open your chest" },
{ category: "stretch", text: "Tilt your head to each side, holding for 10 seconds" },
{ category: "stretch", text: "Stretch your arms overhead and reach for the ceiling" },
{ category: "stretch", text: "Rotate your wrists in circles, 10 times each direction" },
{ category: "stretch", text: "Clasp hands together and push palms away from you" },
{ category: "stretch", text: "Gently twist your torso left and right from your chair" },
{ category: "stretch", text: "Drop your chin to your chest and slowly roll your neck" },
{ category: "stretch", text: "Extend each arm across your chest, hold for 15 seconds" },
{ category: "stretch", text: "Open and close your hands, spreading fingers wide" },
{ category: "stretch", text: "Place your right hand on your left knee and twist gently — switch sides" },
{ category: "stretch", text: "Reach one arm overhead and lean to the opposite side, hold 10 seconds each" },
{ category: "stretch", text: "Interlace your fingers and flip your palms to the ceiling, push up" },
{ category: "stretch", text: "Pull each finger gently backward for 3 seconds to stretch your forearms" },
{ category: "stretch", text: "Press your palms together at chest height and push for 10 seconds" },
{ category: "stretch", text: "Sit tall, reach behind to grab the back of your chair, and open your chest" },
{ category: "stretch", text: "Cross one ankle over the opposite knee and lean forward gently" },
{ category: "stretch", text: "Shrug your shoulders up to your ears, hold 5 seconds, release slowly" },
// Breathing
{ category: "breathing", text: "Inhale for 4 seconds, hold for 4, exhale for 6" },
{ category: "breathing", text: "Take 5 deep belly breaths — feel your diaphragm expand" },
{ category: "breathing", text: "Box breathing: inhale 4s, hold 4s, exhale 4s, hold 4s" },
{ category: "breathing", text: "Breathe in through your nose, out through your mouth — 5 times" },
{ category: "breathing", text: "Sigh deeply 3 times to release tension" },
{ category: "breathing", text: "Place a hand on your chest and breathe so only your belly moves" },
{ category: "breathing", text: "Alternate nostril breathing: 3 cycles each side" },
{ category: "breathing", text: "Exhale completely, then take the deepest breath you can" },
{ category: "breathing", text: "Count your breaths backward from 10 to 1" },
{ category: "breathing", text: "Breathe in calm, breathe out tension — 5 rounds" },
{ category: "breathing", text: "4-7-8 breathing: inhale 4s, hold 7s, exhale slowly for 8s" },
{ category: "breathing", text: "Take 3 breaths making your exhale twice as long as your inhale" },
{ category: "breathing", text: "Breathe in for 2 counts, out for 2 — gradually increase to 6 each" },
{ category: "breathing", text: "Hum gently on each exhale for 5 breaths — feel the vibration in your chest" },
{ category: "breathing", text: "Imagine breathing in cool blue air and exhaling warm red air — 5 rounds" },
{ category: "breathing", text: "Place both hands on your ribs and breathe so you feel them expand sideways" },
{ category: "breathing", text: "Breathe in through pursed lips slowly, then exhale in a long, steady stream" },
// Movement
{ category: "movement", text: "Stand up and walk to the nearest window" },
{ category: "movement", text: "Do 10 standing calf raises" },
{ category: "movement", text: "Walk to get a glass of water" },
{ category: "movement", text: "Stand and do 5 gentle squats" },
{ category: "movement", text: "Take a short walk around your room" },
{ category: "movement", text: "Stand on one foot for 15 seconds, then switch" },
{ category: "movement", text: "Do 10 arm circles, forward then backward" },
{ category: "movement", text: "March in place for 30 seconds" },
{ category: "movement", text: "Shake out your hands and arms for 10 seconds" },
{ category: "movement", text: "Stand up, touch your toes, and slowly rise" },
{ category: "movement", text: "Walk to the farthest room in your home and back" },
{ category: "movement", text: "Do 5 wall push-ups — hands on the wall, lean in and push back" },
{ category: "movement", text: "Stand up and slowly shift your weight side to side for 20 seconds" },
{ category: "movement", text: "Walk in a small circle, paying attention to each footstep" },
{ category: "movement", text: "Rise onto your tiptoes, hold for 5 seconds, lower slowly — repeat 5 times" },
{ category: "movement", text: "Do a gentle standing forward fold — let your arms hang loose" },
{ category: "movement", text: "Step away from your desk and do 10 hip circles in each direction" },
{ category: "movement", text: "Stand with your back against a wall and slide down into a gentle wall sit for 15 seconds" },
];
const categoryIcons: Record<BreakActivity["category"], string> = {
eyes: "👁",
stretch: "🤸",
breathing: "🌬",
movement: "🚶",
};
const categoryLabels: Record<BreakActivity["category"], string> = {
eyes: "Eye Exercise",
stretch: "Stretch",
breathing: "Breathing",
movement: "Movement",
};
export function getCategoryIcon(cat: BreakActivity["category"]): string {
return categoryIcons[cat];
}
export function getCategoryLabel(cat: BreakActivity["category"]): string {
return categoryLabels[cat];
}
/** Pick a random activity, optionally excluding a previous one */
export function pickRandomActivity(exclude?: BreakActivity): BreakActivity {
const pool = exclude
? breakActivities.filter((a) => a.text !== exclude.text)
: breakActivities;
return pool[Math.floor(Math.random() * pool.length)];
}

366
src/lib/utils/animate.ts Normal file
View File

@@ -0,0 +1,366 @@
import { animate } from "motion";
/**
* Svelte action: fade in + slide up on mount
*/
export function fadeIn(
node: HTMLElement,
options?: { duration?: number; delay?: number; y?: number },
) {
const { duration = 0.5, delay = 0, y = 15 } = options ?? {};
node.style.opacity = "0";
const controls = animate(
node,
{ opacity: [0, 1], transform: [`translateY(${y}px)`, "translateY(0px)"] },
{ duration, delay, easing: [0.22, 0.03, 0.26, 1] },
);
return {
destroy() {
controls.cancel();
},
};
}
/**
* Svelte action: scale in + fade on mount
*/
export function scaleIn(
node: HTMLElement,
options?: { duration?: number; delay?: number },
) {
const { duration = 0.6, delay = 0 } = options ?? {};
node.style.opacity = "0";
const controls = animate(
node,
{ opacity: [0, 1], transform: ["scale(0.92)", "scale(1)"] },
{ duration, delay, easing: [0.22, 0.03, 0.26, 1] },
);
return {
destroy() {
controls.cancel();
},
};
}
/**
* Svelte action: animate when scrolled into view (IntersectionObserver)
*/
export function inView(
node: HTMLElement,
options?: { delay?: number; y?: number; threshold?: number },
) {
const { delay = 0, y = 20, threshold = 0.05 } = options ?? {};
node.style.opacity = "0";
node.style.transform = `translateY(${y}px)`;
let controls: ReturnType<typeof animate> | null = null;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
controls = animate(
node,
{
opacity: [0, 1],
transform: [`translateY(${y}px)`, "translateY(0px)"],
},
{ duration: 0.45, delay, easing: [0.22, 0.03, 0.26, 1] },
);
observer.disconnect();
}
},
{ threshold },
);
observer.observe(node);
return {
destroy() {
observer.disconnect();
controls?.cancel();
},
};
}
/**
* Svelte action: spring-scale press feedback on buttons
*/
export function pressable(node: HTMLElement) {
let active: ReturnType<typeof animate> | null = null;
function onDown() {
active?.cancel();
// Explicit [from, to] avoids transform conflicts with fadeIn's translateY
active = animate(
node,
{ transform: ["scale(1)", "scale(0.95)"] },
{ duration: 0.1, easing: [0.22, 0.03, 0.26, 1] },
);
}
function onUp() {
active?.cancel();
active = animate(
node,
{ transform: ["scale(0.95)", "scale(1)"] },
{ duration: 0.3, easing: [0.22, 1.2, 0.36, 1] },
);
}
node.addEventListener("mousedown", onDown);
node.addEventListener("mouseup", onUp);
node.addEventListener("mouseleave", onUp);
return {
destroy() {
node.removeEventListener("mousedown", onDown);
node.removeEventListener("mouseup", onUp);
node.removeEventListener("mouseleave", onUp);
active?.cancel();
},
};
}
/**
* Svelte action: animated glow band on hover.
* Creates a crisp ring "band" plus a soft atmospheric glow.
* Pass a hex color (e.g. "#ff4d00").
*
* Uses same-hue zero-alpha as the "off" state so the Web Animations API
* interpolates through the correct color channel instead of through black.
*/
export function glowHover(
node: HTMLElement,
options?: { color?: string },
) {
let color = options?.color ?? "#ff4d00";
let enterAnim: ReturnType<typeof animate> | null = null;
let leaveAnim: ReturnType<typeof animate> | null = null;
// "off" state: same hue at zero alpha (NOT transparent, which is rgba(0,0,0,0))
function off() { return `0 0 0 0px ${color}00, 0 0 0px 0px ${color}00`; }
function on() { return `0 0 0 1.5px ${color}90, 0 0 22px 6px ${color}40`; }
node.style.boxShadow = off();
function onEnter() {
leaveAnim?.cancel();
enterAnim = animate(
node,
{ boxShadow: [off(), on()] },
{ duration: 0.4, easing: [0.22, 0.03, 0.26, 1] },
);
}
function onLeave() {
enterAnim?.cancel();
leaveAnim = animate(
node,
{ boxShadow: [on(), off()] },
{ duration: 0.5, easing: [0.22, 0.03, 0.26, 1] },
);
}
node.addEventListener("mouseenter", onEnter);
node.addEventListener("mouseleave", onLeave);
return {
update(newOptions?: { color?: string }) {
color = newOptions?.color ?? "#ff4d00";
node.style.boxShadow = off();
},
destroy() {
node.removeEventListener("mouseenter", onEnter);
node.removeEventListener("mouseleave", onLeave);
enterAnim?.cancel();
leaveAnim?.cancel();
},
};
}
/**
* Svelte action: momentum-based grab-and-drag scrolling
* with elastic overscroll and spring-back at boundaries.
*
* IMPORTANT: The node must have exactly one child element (a wrapper div).
* Overscroll transforms are applied to that child, NOT to the scroll
* container itself (which would break overflow clipping).
*/
export function dragScroll(node: HTMLElement) {
const content = node.children[0] as HTMLElement | null;
let isDown = false;
let startY = 0;
let scrollStart = 0;
let lastY = 0;
let lastTime = 0;
let animFrame = 0;
let velocitySamples: number[] = [];
let overscrollAmount = 0;
let springAnim: ReturnType<typeof animate> | null = null;
function getMaxScroll() {
return node.scrollHeight - node.clientHeight;
}
function setOverscroll(amount: number) {
if (!content) return;
overscrollAmount = amount;
if (Math.abs(amount) < 0.5) {
content.style.transform = "";
overscrollAmount = 0;
} else {
content.style.transform = `translateY(${-amount}px)`;
}
}
function springBack() {
if (!content) return;
const from = overscrollAmount;
if (Math.abs(from) < 0.5) {
setOverscroll(0);
return;
}
springAnim = animate(
content,
{ transform: [`translateY(${-from}px)`, "translateY(0px)"] },
{ duration: 0.6, easing: [0.16, 1, 0.3, 1] },
);
overscrollAmount = 0;
// Ensure DOM is clean after animation completes
springAnim.finished.then(() => {
if (content) content.style.transform = "";
}).catch(() => { /* cancelled — onDown handles cleanup */ });
}
function forceReset() {
springAnim?.cancel();
cancelAnimationFrame(animFrame);
overscrollAmount = 0;
if (content) content.style.transform = "";
}
function onDown(e: MouseEvent) {
if (e.button !== 0) return;
const tag = (e.target as HTMLElement).tagName;
if (["BUTTON", "INPUT", "LABEL", "SELECT", "TEXTAREA"].includes(tag)) return;
// Force-reset any lingering animation/transform state
forceReset();
isDown = true;
startY = e.pageY;
scrollStart = node.scrollTop;
lastY = e.pageY;
lastTime = Date.now();
velocitySamples = [];
node.style.cursor = "grabbing";
}
function onMove(e: MouseEvent) {
if (!isDown) return;
e.preventDefault();
const y = e.pageY;
const now = Date.now();
const dt = now - lastTime;
if (dt > 0) {
velocitySamples.push((lastY - y) / dt);
if (velocitySamples.length > 5) velocitySamples.shift();
}
lastY = y;
lastTime = now;
const desiredScroll = scrollStart - (y - startY);
const maxScroll = getMaxScroll();
if (desiredScroll < 0) {
node.scrollTop = 0;
setOverscroll(desiredScroll * 0.3);
} else if (desiredScroll > maxScroll) {
node.scrollTop = maxScroll;
setOverscroll((desiredScroll - maxScroll) * 0.3);
} else {
node.scrollTop = desiredScroll;
if (overscrollAmount !== 0) setOverscroll(0);
}
}
function onUp() {
if (!isDown) return;
isDown = false;
node.style.cursor = "";
if (overscrollAmount !== 0) {
springBack();
return;
}
const avgVelocity = velocitySamples.length > 0
? velocitySamples.reduce((a, b) => a + b, 0) / velocitySamples.length
: 0;
// Clamp velocity (px/ms) and bail if negligible
const maxV = 4;
const v0 = Math.max(-maxV, Math.min(maxV, avgVelocity));
if (Math.abs(v0) < 0.005) return;
// Time-based exponential decay (iOS-style scroll physics).
// position(t) = start + v0 * tau * (1 - e^(-t/tau))
// velocity(t) = v0 * e^(-t/tau) → naturally asymptotes to zero
const tau = 325; // time constant in ms — iOS UIScrollView feel
const coastStart = performance.now();
const scrollStart2 = node.scrollTop;
const totalDist = v0 * tau;
function coast() {
const t = performance.now() - coastStart;
const decay = Math.exp(-t / tau);
const offset = totalDist * (1 - decay);
const targetScroll = scrollStart2 + offset;
const maxScroll = getMaxScroll();
if (targetScroll < 0) {
node.scrollTop = 0;
const currentV = Math.abs(v0 * decay);
const bounce = Math.min(40, currentV * 50);
setOverscroll(-bounce);
springBack();
return;
}
if (targetScroll > maxScroll) {
node.scrollTop = maxScroll;
const currentV = Math.abs(v0 * decay);
const bounce = Math.min(40, currentV * 50);
setOverscroll(bounce);
springBack();
return;
}
node.scrollTop = targetScroll;
// Stop when velocity < 0.5 px/sec (completely imperceptible)
if (Math.abs(v0 * decay) > 0.0005) {
animFrame = requestAnimationFrame(coast);
}
}
coast();
}
node.addEventListener("mousedown", onDown);
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
return {
destroy() {
node.removeEventListener("mousedown", onDown);
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
forceReset();
},
};
}

283
src/lib/utils/sounds.ts Normal file
View File

@@ -0,0 +1,283 @@
/**
* Synthesized notification sounds using the Web Audio API.
* No external audio files needed — all sounds are generated programmatically.
*/
let audioCtx: AudioContext | null = null;
function getAudioContext(): AudioContext {
if (!audioCtx) {
audioCtx = new AudioContext();
}
return audioCtx;
}
type SoundPreset = "bell" | "chime" | "soft" | "digital" | "harp" | "bowl" | "rain" | "whistle";
/**
* Play a notification sound with the given preset and volume.
* @param preset - One of: "bell", "chime", "soft", "digital"
* @param volume - 0 to 100
*/
export function playSound(preset: SoundPreset, volume: number): void {
const ctx = getAudioContext();
const gain = ctx.createGain();
gain.connect(ctx.destination);
const vol = (volume / 100) * 0.3; // Scale to reasonable max
switch (preset) {
case "bell":
playBell(ctx, gain, vol);
break;
case "chime":
playChime(ctx, gain, vol);
break;
case "soft":
playSoft(ctx, gain, vol);
break;
case "digital":
playDigital(ctx, gain, vol);
break;
case "harp":
playHarp(ctx, gain, vol);
break;
case "bowl":
playBowl(ctx, gain, vol);
break;
case "rain":
playRain(ctx, gain, vol);
break;
case "whistle":
playWhistle(ctx, gain, vol);
break;
}
}
/** Warm bell — two sine tones with harmonics and slow decay */
function playBell(ctx: AudioContext, destination: GainNode, vol: number) {
const now = ctx.currentTime;
// Fundamental
const osc1 = ctx.createOscillator();
const g1 = ctx.createGain();
osc1.type = "sine";
osc1.frequency.setValueAtTime(830, now);
g1.gain.setValueAtTime(vol, now);
g1.gain.exponentialRampToValueAtTime(0.001, now + 1.5);
osc1.connect(g1);
g1.connect(destination);
osc1.start(now);
osc1.stop(now + 1.5);
// Harmonic
const osc2 = ctx.createOscillator();
const g2 = ctx.createGain();
osc2.type = "sine";
osc2.frequency.setValueAtTime(1245, now);
g2.gain.setValueAtTime(vol * 0.4, now);
g2.gain.exponentialRampToValueAtTime(0.001, now + 1.0);
osc2.connect(g2);
g2.connect(destination);
osc2.start(now);
osc2.stop(now + 1.0);
}
/** Two-note ascending chime */
function playChime(ctx: AudioContext, destination: GainNode, vol: number) {
const now = ctx.currentTime;
const notes = [523.25, 659.25]; // C5, E5
notes.forEach((freq, i) => {
const osc = ctx.createOscillator();
const g = ctx.createGain();
osc.type = "sine";
osc.frequency.setValueAtTime(freq, now);
const start = now + i * 0.15;
g.gain.setValueAtTime(0, start);
g.gain.linearRampToValueAtTime(vol, start + 0.02);
g.gain.exponentialRampToValueAtTime(0.001, start + 0.8);
osc.connect(g);
g.connect(destination);
osc.start(start);
osc.stop(start + 0.8);
});
}
/** Gentle soft ping — filtered triangle wave */
function playSoft(ctx: AudioContext, destination: GainNode, vol: number) {
const now = ctx.currentTime;
const osc = ctx.createOscillator();
const filter = ctx.createBiquadFilter();
const g = ctx.createGain();
osc.type = "triangle";
osc.frequency.setValueAtTime(600, now);
osc.frequency.exponentialRampToValueAtTime(400, now + 0.5);
filter.type = "lowpass";
filter.frequency.setValueAtTime(2000, now);
filter.frequency.exponentialRampToValueAtTime(400, now + 0.8);
g.gain.setValueAtTime(vol, now);
g.gain.exponentialRampToValueAtTime(0.001, now + 1.2);
osc.connect(filter);
filter.connect(g);
g.connect(destination);
osc.start(now);
osc.stop(now + 1.2);
}
/** Digital blip — short square wave burst */
function playDigital(ctx: AudioContext, destination: GainNode, vol: number) {
const now = ctx.currentTime;
for (let i = 0; i < 2; i++) {
const osc = ctx.createOscillator();
const g = ctx.createGain();
osc.type = "square";
osc.frequency.setValueAtTime(880, now);
const start = now + i * 0.12;
g.gain.setValueAtTime(0, start);
g.gain.linearRampToValueAtTime(vol * 0.5, start + 0.01);
g.gain.exponentialRampToValueAtTime(0.001, start + 0.15);
osc.connect(g);
g.connect(destination);
osc.start(start);
osc.stop(start + 0.15);
}
}
/** Harp — cascading arpeggiated sine tones (C5-E5-G5-C6) */
function playHarp(ctx: AudioContext, destination: GainNode, vol: number) {
const now = ctx.currentTime;
const notes = [523.25, 659.25, 783.99, 1046.5]; // C5, E5, G5, C6
notes.forEach((freq, i) => {
const osc = ctx.createOscillator();
const g = ctx.createGain();
osc.type = "sine";
osc.frequency.setValueAtTime(freq, now);
const start = now + i * 0.09;
g.gain.setValueAtTime(0, start);
g.gain.linearRampToValueAtTime(vol * 0.8, start + 0.01);
g.gain.exponentialRampToValueAtTime(0.001, start + 1.2);
osc.connect(g);
g.connect(destination);
osc.start(start);
osc.stop(start + 1.2);
});
}
/** Singing bowl — low sine with slow beating from detuned pair */
function playBowl(ctx: AudioContext, destination: GainNode, vol: number) {
const now = ctx.currentTime;
// Two slightly detuned sines create a beating/shimmering effect
for (const freq of [293.66, 295.5]) { // ~D4 with 2Hz beat
const osc = ctx.createOscillator();
const g = ctx.createGain();
osc.type = "sine";
osc.frequency.setValueAtTime(freq, now);
g.gain.setValueAtTime(vol, now);
g.gain.exponentialRampToValueAtTime(0.001, now + 2.5);
osc.connect(g);
g.connect(destination);
osc.start(now);
osc.stop(now + 2.5);
}
// Upper harmonic shimmer
const osc3 = ctx.createOscillator();
const g3 = ctx.createGain();
osc3.type = "sine";
osc3.frequency.setValueAtTime(880, now);
g3.gain.setValueAtTime(vol * 0.15, now);
g3.gain.exponentialRampToValueAtTime(0.001, now + 1.5);
osc3.connect(g3);
g3.connect(destination);
osc3.start(now);
osc3.stop(now + 1.5);
}
/** Rain — filtered noise burst with gentle decay */
function playRain(ctx: AudioContext, destination: GainNode, vol: number) {
const now = ctx.currentTime;
const bufferSize = ctx.sampleRate * 1;
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
const data = buffer.getChannelData(0);
// White noise
for (let i = 0; i < bufferSize; i++) {
data[i] = Math.random() * 2 - 1;
}
const noise = ctx.createBufferSource();
noise.buffer = buffer;
const filter = ctx.createBiquadFilter();
filter.type = "bandpass";
filter.frequency.setValueAtTime(3000, now);
filter.frequency.exponentialRampToValueAtTime(800, now + 0.8);
filter.Q.setValueAtTime(0.5, now);
const g = ctx.createGain();
g.gain.setValueAtTime(vol * 0.6, now);
g.gain.exponentialRampToValueAtTime(0.001, now + 1.0);
noise.connect(filter);
filter.connect(g);
g.connect(destination);
noise.start(now);
noise.stop(now + 1.0);
}
/** Whistle — gentle two-note sine glide */
function playWhistle(ctx: AudioContext, destination: GainNode, vol: number) {
const now = ctx.currentTime;
const osc = ctx.createOscillator();
const g = ctx.createGain();
osc.type = "sine";
osc.frequency.setValueAtTime(880, now);
osc.frequency.exponentialRampToValueAtTime(1318.5, now + 0.3); // A5 → E6 glide up
osc.frequency.setValueAtTime(1318.5, now + 0.5);
osc.frequency.exponentialRampToValueAtTime(1046.5, now + 0.8); // E6 → C6 settle
g.gain.setValueAtTime(0, now);
g.gain.linearRampToValueAtTime(vol * 0.5, now + 0.05);
g.gain.setValueAtTime(vol * 0.5, now + 0.6);
g.gain.exponentialRampToValueAtTime(0.001, now + 1.0);
osc.connect(g);
g.connect(destination);
osc.start(now);
osc.stop(now + 1.0);
}
/** Play a completion sound — slightly different from start (descending) */
export function playBreakEndSound(preset: SoundPreset, volume: number): void {
const ctx = getAudioContext();
const gain = ctx.createGain();
gain.connect(ctx.destination);
const vol = (volume / 100) * 0.3;
const now = ctx.currentTime;
// Always a gentle descending two-note resolution
const notes = [659.25, 523.25]; // E5, C5 (descending = "done" feeling)
notes.forEach((freq, i) => {
const osc = ctx.createOscillator();
const g = ctx.createGain();
osc.type = preset === "digital" ? "square" : "sine";
osc.frequency.setValueAtTime(freq, now);
const start = now + i * 0.2;
g.gain.setValueAtTime(0, start);
g.gain.linearRampToValueAtTime(vol, start + 0.02);
g.gain.exponentialRampToValueAtTime(0.001, start + 0.6);
osc.connect(g);
g.connect(gain);
osc.start(start);
osc.stop(start + 0.6);
});
}

23
src/main.ts Normal file
View File

@@ -0,0 +1,23 @@
import "./app.css";
import App from "./App.svelte";
import MiniTimer from "./lib/components/MiniTimer.svelte";
import BreakWindow from "./lib/components/BreakWindow.svelte";
import { mount } from "svelte";
const params = new URLSearchParams(window.location.search);
const isMini = params.has("mini");
const isBreak = params.has("break");
if (isMini || isBreak) {
// Transparent body so rounded shapes show through the transparent window
document.body.style.background = "transparent";
document.documentElement.style.background = "transparent";
}
const component = isMini ? MiniTimer : isBreak ? BreakWindow : App;
const app = mount(component, {
target: document.getElementById("app")!,
});
export default app;

2
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
/// <reference types="svelte" />
/// <reference types="vite/client" />