Add pomodoro, microbreaks, breathing guide, screen dimming, presentation mode, goals, multi-monitor, and activity manager
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
|
||||
interface Props {
|
||||
pattern?: string;
|
||||
size?: number;
|
||||
color?: string;
|
||||
showLabel?: boolean;
|
||||
phaseLabel?: string;
|
||||
countdown?: number;
|
||||
breathScale?: number;
|
||||
}
|
||||
|
||||
let {
|
||||
pattern = "box",
|
||||
size = 200,
|
||||
color = "#7c6aef",
|
||||
showLabel = true,
|
||||
phaseLabel = $bindable("Inhale"),
|
||||
countdown = $bindable(4),
|
||||
breathScale = $bindable(0.6),
|
||||
}: Props = $props();
|
||||
|
||||
// Breathing patterns: arrays of [phase, durationSeconds]
|
||||
const patterns: Record<string, [string, number][]> = {
|
||||
box: [["Inhale", 4], ["Hold", 4], ["Exhale", 4], ["Hold", 4]],
|
||||
relaxing: [["Inhale", 4], ["Hold", 7], ["Exhale", 8]],
|
||||
energizing: [["Inhale", 6], ["Hold", 2], ["Exhale", 6], ["Hold", 2]],
|
||||
calm: [["Inhale", 4], ["Hold", 4], ["Exhale", 6]],
|
||||
deep: [["Inhale", 5], ["Exhale", 5]],
|
||||
};
|
||||
|
||||
const phases = $derived(patterns[pattern] ?? patterns.box);
|
||||
const totalCycleDuration = $derived(phases.reduce((sum, [, d]) => sum + d, 0));
|
||||
let scale = $state(0.6);
|
||||
let animationId: number | null = null;
|
||||
let startTime = 0;
|
||||
|
||||
function animate(timestamp: number) {
|
||||
if (!startTime) startTime = timestamp;
|
||||
const elapsed = ((timestamp - startTime) / 1000) % totalCycleDuration;
|
||||
|
||||
let accumulated = 0;
|
||||
for (const [label, duration] of phases) {
|
||||
if (elapsed < accumulated + duration) {
|
||||
phaseLabel = label;
|
||||
const phaseElapsed = elapsed - accumulated;
|
||||
countdown = Math.ceil(duration - phaseElapsed);
|
||||
|
||||
// Calculate scale based on phase
|
||||
const t = phaseElapsed / duration;
|
||||
if (label === "Inhale") {
|
||||
scale = 0.6 + 0.4 * t; // 60% -> 100%
|
||||
} else if (label === "Exhale") {
|
||||
scale = 1.0 - 0.4 * t; // 100% -> 60%
|
||||
}
|
||||
// Hold phases keep current scale
|
||||
breathScale = scale;
|
||||
break;
|
||||
}
|
||||
accumulated += duration;
|
||||
}
|
||||
|
||||
animationId = requestAnimationFrame(animate);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
// Check reduced motion preference
|
||||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
||||
phaseLabel = "Breathe";
|
||||
countdown = 0;
|
||||
scale = 0.8;
|
||||
return;
|
||||
}
|
||||
|
||||
animationId = requestAnimationFrame(animate);
|
||||
return () => {
|
||||
if (animationId !== null) cancelAnimationFrame(animationId);
|
||||
};
|
||||
});
|
||||
|
||||
const circleR = $derived(size / 2 - 8);
|
||||
const cx = $derived(size / 2);
|
||||
const cy = $derived(size / 2);
|
||||
</script>
|
||||
|
||||
<div class="breathing-guide" style="width: {size}px; height: {size}px;">
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 {size} {size}"
|
||||
class="breathing-svg"
|
||||
aria-label="Breathing guide: {phaseLabel}"
|
||||
role="img"
|
||||
>
|
||||
<defs>
|
||||
<filter id="breathing-glow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="12" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Outer glow circle -->
|
||||
<circle
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
r={circleR * scale}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
stroke-width="2"
|
||||
opacity="0.15"
|
||||
filter="url(#breathing-glow)"
|
||||
class="transition-r"
|
||||
/>
|
||||
|
||||
<!-- Main circle -->
|
||||
<circle
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
r={circleR * scale}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
stroke-width="2"
|
||||
opacity="0.4"
|
||||
class="transition-r"
|
||||
/>
|
||||
|
||||
<!-- Inner fill -->
|
||||
<circle
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
r={circleR * scale * 0.85}
|
||||
fill={color}
|
||||
opacity="0.06"
|
||||
class="transition-r"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<!-- Phase label + countdown -->
|
||||
{#if showLabel}
|
||||
<div class="breathing-label">
|
||||
<span class="text-[14px] font-medium text-white tracking-wider uppercase opacity-80">
|
||||
{phaseLabel}
|
||||
</span>
|
||||
{#if countdown > 0}
|
||||
<span class="text-[24px] font-semibold text-white tabular-nums mt-0.5">
|
||||
{countdown}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.breathing-guide {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.breathing-svg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.breathing-label {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Smooth radius transitions via CSS */
|
||||
.transition-r {
|
||||
transition: r 0.3s ease-out, opacity 0.3s ease-out;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user