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:
2026-02-07 01:12:32 +02:00
commit 7272b80910
48 changed files with 15133 additions and 0 deletions

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>