Files
core-cooldown/src/lib/components/BreakScreen.svelte
Your Name acf06c8d32 a11y: Tasks 7-12 - Dashboard, Settings, StatsView, BreakScreen, Celebration
- Dashboard: text-text-sec tokens, nav landmark, toast hover persistence,
  goal progressbar ARIA, pomodoro sr-only text
- Settings: h3→h2 heading hierarchy, section aria-labelledby with ids,
  Working Hours heading added
- StatsView: h3→h2, tablist/tab/tabpanel ARIA pattern, sr-only data tables
  for 30-day chart and heatmap, contrast tokens
- BreakScreen: strict-mode focus safety span, breathing phase-only
  announcements, contrast tokens
- Celebration: JS-controlled hover/focus persistence, dismiss buttons,
  Escape key, removed pointer-events:none
- Titlebar: removed redundant role="banner" on <header>
2026-02-18 19:18:15 +02:00

552 lines
19 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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 { onMount } from "svelte";
import { scaleIn, fadeIn, pressable } from "../utils/animate";
import { pickRandomActivity, getCategoryIcon, getCategoryLabel, type BreakActivity } from "../utils/activities";
import BreathingGuide from "./BreathingGuide.svelte";
interface Props {
standalone?: boolean;
}
let { standalone = false }: Props = $props();
const appWindow = $derived(standalone ? getCurrentWebviewWindow() : null);
let currentActivity = $state<BreakActivity>(pickRandomActivity(undefined, $config));
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, $config);
}, 30_000);
}
return () => {
if (activityCycleTimer) {
clearInterval(activityCycleTimer);
activityCycleTimer = null;
}
};
});
// F3: Long break indicator
const isLongBreak = $derived($timer.isLongBreak);
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);
// Breathing guide bindable state
let breathPhase = $state("Inhale");
let breathCountdown = $state(4);
let breathScale = $state(0.6);
// Only announce phase name changes (not countdown ticks) to screen readers
let breathAnnouncement = $state("");
let lastBreathPhase = $state("");
$effect(() => {
// Extract just the phase name (e.g., "Inhale" from "Inhale 4")
const phaseName = breathPhase?.split(' ')[0] ?? "";
if (phaseName && phaseName !== lastBreathPhase) {
lastBreathPhase = phaseName;
breathAnnouncement = phaseName;
}
});
// Map raw 0.61.0 scale to 0.91.6 range for visible breathing text
const textScale = $derived(0.9 + (breathScale - 0.6) * (0.7 / 0.4));
// Interpolate color between break_color (inhale) and accent_color (exhale)
// breathScale: 0.6 = exhale (accent), 1.0 = inhale (break)
function hexToRgb(hex: string): [number, number, number] {
const h = hex.replace("#", "");
return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];
}
function lerpColor(c1: string, c2: string, t: number): string {
const [r1, g1, b1] = hexToRgb(c1);
const [r2, g2, b2] = hexToRgb(c2);
const r = Math.round(r1 + (r2 - r1) * t);
const g = Math.round(g1 + (g2 - g1) * t);
const b = Math.round(b1 + (b2 - b1) * t);
return `rgb(${r},${g},${b})`;
}
// t=0 at exhale (scale=0.6), t=1 at inhale (scale=1.0)
const breathT = $derived((breathScale - 0.6) / 0.4);
const breathColor = $derived(lerpColor($config.accent_color, $config.break_color, breathT));
// 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);
// Focus trap: keep Tab cycling within break screen
let breakContainer = $state<HTMLElement>(undefined!);
$effect(() => {
if (!breakContainer) return;
function trapFocus(e: KeyboardEvent) {
if (e.key !== "Tab") return;
const focusable = breakContainer.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey) {
if (document.activeElement === first) { e.preventDefault(); last.focus(); }
} else {
if (document.activeElement === last) { e.preventDefault(); first.focus(); }
}
}
breakContainer.addEventListener("keydown", trapFocus);
return () => breakContainer.removeEventListener("keydown", trapFocus);
});
</script>
{#if standalone}
<!-- ── Standalone break window: horizontal card, transparent background ── -->
<div class="standalone-card" use:scaleIn={{ duration: 0.5 }} bind:this={breakContainer}>
<!-- Ripples emanate from the ring, visible outside the card -->
<div class="standalone-ring-area">
<div class="ripple-container" aria-hidden="true">
<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>
<!-- Hidden: runs breathing animation logic, we only use its phase data -->
{#if $config.breathing_guide_enabled}
<div class="hidden" aria-hidden="true">
<BreathingGuide
pattern={$config.breathing_pattern}
size={0}
color={$config.break_color}
showLabel={false}
bind:phaseLabel={breathPhase}
bind:countdown={breathCountdown}
bind:breathScale={breathScale}
/>
</div>
{/if}
<div>
<TimerRing
progress={breakRingProgress}
size={140}
strokeWidth={5}
accentColor={$config.break_color}
label="Break timer"
valueText="{formatTime($timer.breakTimeRemaining)} remaining"
>
<div class="flex flex-col items-center">
<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>
{#if $config.breathing_guide_enabled}
<span
class="block mt-1.5 text-[9px] tracking-wider uppercase text-center font-medium"
style="transform: scale({textScale}); color: {breathColor}; opacity: {0.5 + breathT * 0.5}; transition: transform 0.15s ease-out, opacity 0.15s ease-out, color 0.15s ease-out;"
aria-hidden="true"
>
{breathPhase}{breathCountdown > 0 ? ` ${breathCountdown}s` : ""}
</span>
<span class="sr-only" aria-live="polite" aria-atomic="true">{breathAnnouncement}</span>
{/if}
</div>
</TimerRing>
</div>
</div>
<!-- Right side: text + buttons -->
<div class="standalone-content">
<h2 class="text-[17px] font-medium text-white mb-1.5" tabindex="-1">
{$timer.breakTitle}
</h2>
<p class="text-[12px] leading-relaxed text-text-sec 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-text-sec uppercase mb-1">
{getCategoryLabel(currentActivity.category)}
</div>
<p class="text-[12px] leading-relaxed text-text-sec" aria-live="polite">
{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-text-sec uppercase
transition-colors duration-200
hover:border-[#444] hover:text-[#ccc]"
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-text-sec">
{$timer.snoozesUsed}/{$config.snooze_limit} snoozes used
</p>
{/if}
{:else}
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<span tabindex="0" class="sr-only" aria-live="polite">
Break in progress, please wait
</span>
{/if}
</div>
<!-- Bottom progress bar with clip-path -->
<div class="standalone-progress-container" role="progressbar" aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round($timer.breakProgress * 100)} aria-label="Break progress">
<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 flex items-center justify-center"
style="background: #000;"
bind:this={breakContainer}
>
<div
class="relative flex flex-col items-center"
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" aria-hidden="true">
<div class="break-ripple ripple-1" style="--ripple-color: {$config.break_color}; --ripple-size: {isModal ? 160 : 200}px"></div>
<div class="break-ripple ripple-2" style="--ripple-color: {$config.break_color}; --ripple-size: {isModal ? 160 : 200}px"></div>
<div class="break-ripple ripple-3" style="--ripple-color: {$config.break_color}; --ripple-size: {isModal ? 160 : 200}px"></div>
</div>
<!-- Hidden: runs breathing animation logic, we only use its phase data -->
{#if $config.breathing_guide_enabled}
<div class="hidden" aria-hidden="true">
<BreathingGuide
pattern={$config.breathing_pattern}
size={0}
color={$config.break_color}
showLabel={false}
bind:phaseLabel={breathPhase}
bind:countdown={breathCountdown}
bind:breathScale={breathScale}
/>
</div>
{/if}
<div class="relative">
<TimerRing
progress={breakRingProgress}
size={isModal ? 160 : 200}
strokeWidth={isModal ? 5 : 6}
accentColor={$config.break_color}
label="Break timer"
valueText="{formatTime($timer.breakTimeRemaining)} remaining"
>
<div class="flex flex-col items-center">
<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>
{#if $config.breathing_guide_enabled}
<span
class="block mt-2 tracking-wider uppercase text-center font-medium"
class:text-[10px]={!isModal}
class:text-[9px]={isModal}
style="transform: scale({textScale}); color: {breathColor}; opacity: {0.5 + breathT * 0.5}; transition: transform 0.15s ease-out, opacity 0.15s ease-out, color 0.15s ease-out;"
aria-hidden="true"
>
{breathPhase}{breathCountdown > 0 ? ` ${breathCountdown}s` : ""}
</span>
<span class="sr-only" aria-live="polite" aria-atomic="true">{breathAnnouncement}</span>
{/if}
</div>
</TimerRing>
</div>
</div>
<!-- F3: Long break badge -->
{#if isLongBreak}
<div class="mb-2 rounded-full px-3 py-1 text-[10px] font-medium tracking-wider uppercase"
style="background: {$config.break_color}20; color: {$config.break_color}; border: 1px solid {$config.break_color}30;"
use:fadeIn={{ delay: 0.2, y: 8 }}
>
Long break
</div>
{/if}
<h2 class="mb-2 text-lg font-medium text-white" tabindex="-1" use:fadeIn={{ delay: 0.25, y: 10 }}>
{$timer.breakTitle}
</h2>
<p
class="max-w-[300px] text-center text-[13px] leading-relaxed text-text-sec"
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-text-sec uppercase">
{getCategoryLabel(currentActivity.category)}
</div>
<p class="text-[13px] leading-relaxed text-text-sec" aria-live="polite">
{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-border px-6 py-2.5 text-[12px]
tracking-wider text-text-sec uppercase
transition-colors duration-200
hover:border-[#333] hover:text-[#ccc]"
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-text-sec">
{$timer.snoozesUsed}/{$config.snooze_limit} snoozes used
</p>
{/if}
{:else}
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<span tabindex="0" class="sr-only" aria-live="polite">
Break in progress, please wait
</span>
{/if}
<!-- Bottom progress bar for modal -->
{#if isModal}
<div class="break-modal-progress-container" role="progressbar" aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round($timer.breakProgress * 100)} aria-label="Break progress">
<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>
{/if}
</div>
<!-- Fullscreen progress bar - anchored to bottom of screen -->
{#if !isModal}
<div class="absolute bottom-8 left-12 right-12 h-[3px] rounded-full overflow-hidden" role="progressbar" aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round($timer.breakProgress * 100)} aria-label="Break progress">
<div class="w-full h-full" style="background: rgba(255,255,255,0.03);">
<div
class="h-full transition-[width] duration-1000 ease-linear"
style="width: {$timer.breakProgress * 100}%; background: {barGradient};"
></div>
</div>
</div>
{/if}
</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);
}
/* ── Ripple circles ── */
.break-ripple {
position: absolute;
width: var(--ripple-size, 140px);
height: var(--ripple-size, 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.25;
}
100% {
transform: scale(2.5);
opacity: 0;
}
}
</style>