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:
177
src/lib/components/TimerRing.svelte
Normal file
177
src/lib/components/TimerRing.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user