Add WCAG 2.1 Level AA accessibility across all components
This commit is contained in:
@@ -5,9 +5,17 @@
|
||||
}
|
||||
|
||||
let { accentColor, breakColor }: Props = $props();
|
||||
|
||||
let reducedMotion = $state(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
|
||||
$effect(() => {
|
||||
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
const handler = (e: MediaQueryListEvent) => { reducedMotion = e.matches; };
|
||||
mq.addEventListener("change", handler);
|
||||
return () => mq.removeEventListener("change", handler);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
<div class="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden="true">
|
||||
<!-- Gradient blobs -->
|
||||
<div
|
||||
class="blob blob-1"
|
||||
@@ -30,13 +38,15 @@
|
||||
<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"
|
||||
/>
|
||||
{#if !reducedMotion}
|
||||
<animate
|
||||
attributeName="seed"
|
||||
from="0"
|
||||
to="100"
|
||||
dur="2s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
{/if}
|
||||
</feTurbulence>
|
||||
</filter>
|
||||
<rect width="100%" height="100%" filter="url(#grain-filter)" />
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
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";
|
||||
|
||||
@@ -70,14 +71,36 @@
|
||||
);
|
||||
|
||||
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 }}>
|
||||
<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">
|
||||
<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>
|
||||
@@ -88,6 +111,8 @@
|
||||
size={140}
|
||||
strokeWidth={5}
|
||||
accentColor={$config.break_color}
|
||||
label="Break timer"
|
||||
valueText="{formatTime($timer.breakTimeRemaining)} remaining"
|
||||
>
|
||||
<div class="break-breathe-counter">
|
||||
<span
|
||||
@@ -103,19 +128,19 @@
|
||||
|
||||
<!-- Right side: text + buttons -->
|
||||
<div class="standalone-content">
|
||||
<h2 class="text-[17px] font-medium text-white mb-1.5">
|
||||
<h2 class="text-[17px] font-medium text-white mb-1.5" tabindex="-1">
|
||||
{$timer.breakTitle}
|
||||
</h2>
|
||||
<p class="text-[12px] leading-relaxed text-[#666] mb-4 max-w-[240px]">
|
||||
<p class="text-[12px] leading-relaxed text-[#8a8a8a] 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">
|
||||
<div class="text-[9px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase mb-1">
|
||||
{getCategoryLabel(currentActivity.category)}
|
||||
</div>
|
||||
<p class="text-[12px] leading-relaxed text-[#999]">
|
||||
<p class="text-[12px] leading-relaxed text-[#999]" aria-live="polite">
|
||||
{currentActivity.text}
|
||||
</p>
|
||||
</div>
|
||||
@@ -126,9 +151,9 @@
|
||||
<button
|
||||
use:pressable
|
||||
class="rounded-full border border-[#333] px-5 py-2 text-[11px]
|
||||
tracking-wider text-[#666] uppercase
|
||||
tracking-wider text-[#8a8a8a] uppercase
|
||||
transition-colors duration-200
|
||||
hover:border-[#444] hover:text-[#aaa]"
|
||||
hover:border-[#444] hover:text-[#ccc]"
|
||||
onclick={cancelBreak}
|
||||
>
|
||||
{cancelBtnText}
|
||||
@@ -147,7 +172,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
{#if $config.snooze_limit > 0}
|
||||
<p class="mt-2 text-[9px] text-[#333]">
|
||||
<p class="mt-2 text-[9px] text-[#8a8a8a]">
|
||||
{$timer.snoozesUsed}/{$config.snooze_limit} snoozes used
|
||||
</p>
|
||||
{/if}
|
||||
@@ -155,7 +180,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Bottom progress bar with clip-path -->
|
||||
<div class="standalone-progress-container">
|
||||
<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"
|
||||
@@ -170,6 +195,7 @@
|
||||
<div
|
||||
class="relative h-full flex items-center justify-center"
|
||||
style="background: #000;"
|
||||
bind:this={breakContainer}
|
||||
>
|
||||
<div
|
||||
class="relative flex flex-col items-center"
|
||||
@@ -177,7 +203,7 @@
|
||||
>
|
||||
<!-- 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="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>
|
||||
@@ -189,6 +215,8 @@
|
||||
size={isModal ? 160 : 200}
|
||||
strokeWidth={isModal ? 5 : 6}
|
||||
accentColor={$config.break_color}
|
||||
label="Break timer"
|
||||
valueText="{formatTime($timer.breakTimeRemaining)} remaining"
|
||||
>
|
||||
<div class="break-breathe-counter">
|
||||
<span
|
||||
@@ -204,12 +232,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="mb-2 text-lg font-medium text-white" use:fadeIn={{ delay: 0.25, y: 10 }}>
|
||||
<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-[#555]"
|
||||
class="max-w-[300px] text-center text-[13px] leading-relaxed text-[#8a8a8a]"
|
||||
class:mb-4={$config.show_break_activities}
|
||||
class:mb-8={!$config.show_break_activities}
|
||||
use:fadeIn={{ delay: 0.35, y: 10 }}
|
||||
@@ -222,10 +250,10 @@
|
||||
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">
|
||||
<div class="mb-1.5 text-[10px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
|
||||
{getCategoryLabel(currentActivity.category)}
|
||||
</div>
|
||||
<p class="text-[13px] leading-relaxed text-[#999]">
|
||||
<p class="text-[13px] leading-relaxed text-[#999]" aria-live="polite">
|
||||
{currentActivity.text}
|
||||
</p>
|
||||
</div>
|
||||
@@ -236,9 +264,9 @@
|
||||
<button
|
||||
use:pressable
|
||||
class="rounded-full border border-[#222] px-6 py-2.5 text-[12px]
|
||||
tracking-wider text-[#555] uppercase
|
||||
tracking-wider text-[#8a8a8a] uppercase
|
||||
transition-colors duration-200
|
||||
hover:border-[#333] hover:text-[#999]"
|
||||
hover:border-[#333] hover:text-[#ccc]"
|
||||
onclick={cancelBreak}
|
||||
>
|
||||
{cancelBtnText}
|
||||
@@ -257,7 +285,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
{#if $config.snooze_limit > 0}
|
||||
<p class="mt-3 text-[10px] text-[#2a2a2a]">
|
||||
<p class="mt-3 text-[10px] text-[#8a8a8a]">
|
||||
{$timer.snoozesUsed}/{$config.snooze_limit} snoozes used
|
||||
</p>
|
||||
{/if}
|
||||
@@ -265,7 +293,7 @@
|
||||
|
||||
<!-- Bottom progress bar for modal -->
|
||||
{#if isModal}
|
||||
<div class="break-modal-progress-container">
|
||||
<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"
|
||||
@@ -278,7 +306,7 @@
|
||||
|
||||
<!-- 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">
|
||||
<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"
|
||||
|
||||
@@ -182,6 +182,45 @@
|
||||
|
||||
const isPreset = $derived(presets.includes(value));
|
||||
|
||||
// Color name lookup for accessible swatch labels
|
||||
const colorNames: Record<string, string> = {
|
||||
"#ff4d00": "Orange", "#ff6b35": "Tangerine", "#e63946": "Red", "#d62828": "Dark Red",
|
||||
"#f77f00": "Amber", "#fcbf49": "Gold", "#2ec4b6": "Teal", "#3fb950": "Green",
|
||||
"#7c6aef": "Purple", "#9b5de5": "Violet", "#4361ee": "Blue", "#4895ef": "Sky Blue",
|
||||
"#f72585": "Pink", "#ff006e": "Hot Pink", "#ffffff": "White", "#888888": "Gray",
|
||||
"#06d6a0": "Mint", "#80ed99": "Light Green", "#fca311": "Marigold", "#ffbe0b": "Yellow",
|
||||
};
|
||||
|
||||
function getColorName(hex: string): string {
|
||||
return colorNames[hex.toLowerCase()] ?? hex;
|
||||
}
|
||||
|
||||
// Keyboard handlers for SL pad
|
||||
function handleSLKeydown(e: KeyboardEvent) {
|
||||
let handled = true;
|
||||
switch (e.key) {
|
||||
case "ArrowRight": sat = Math.min(100, sat + 5); break;
|
||||
case "ArrowLeft": sat = Math.max(0, sat - 5); break;
|
||||
case "ArrowUp": light = Math.min(100, light + 5); break;
|
||||
case "ArrowDown": light = Math.max(0, light - 5); break;
|
||||
default: handled = false;
|
||||
}
|
||||
if (handled) { e.preventDefault(); updateFromHSL(); }
|
||||
}
|
||||
|
||||
// Keyboard handlers for Hue bar
|
||||
function handleHueKeydown(e: KeyboardEvent) {
|
||||
let handled = true;
|
||||
switch (e.key) {
|
||||
case "ArrowRight": hue = Math.min(360, hue + 5); break;
|
||||
case "ArrowLeft": hue = Math.max(0, hue - 5); break;
|
||||
case "ArrowUp": hue = Math.min(360, hue + 5); break;
|
||||
case "ArrowDown": hue = Math.max(0, hue - 5); break;
|
||||
default: handled = false;
|
||||
}
|
||||
if (handled) { e.preventDefault(); updateFromHSL(); }
|
||||
}
|
||||
|
||||
// SL cursor position
|
||||
const slX = $derived(sat);
|
||||
const slY = $derived(100 - light);
|
||||
@@ -192,7 +231,7 @@
|
||||
<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 class="font-mono text-[11px] text-[#8a8a8a]">{value}</div>
|
||||
</div>
|
||||
<div
|
||||
class="h-6 w-6 rounded-full border border-[#333] shadow-[0_0_8px_0px] transition-shadow duration-300"
|
||||
@@ -211,7 +250,7 @@
|
||||
: 'hover:scale-110 opacity-80 hover:opacity-100'}"
|
||||
style="background: {color};"
|
||||
onclick={() => selectPreset(color)}
|
||||
aria-label="Select {color}"
|
||||
aria-label="Select {getColorName(color)}"
|
||||
></button>
|
||||
{/each}
|
||||
|
||||
@@ -235,7 +274,7 @@
|
||||
transition:slide={{ duration: 280, easing: cubicOut }}
|
||||
>
|
||||
<!-- Saturation / Lightness pad -->
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex a11y_no_noninteractive_element_interactions -->
|
||||
<div
|
||||
bind:this={slPad}
|
||||
class="relative h-[120px] w-full cursor-crosshair rounded-lg overflow-hidden touch-none"
|
||||
@@ -244,9 +283,10 @@
|
||||
onpointermove={handleSLPointerMove}
|
||||
onpointerup={handleSLPointerUp}
|
||||
onpointercancel={handleSLPointerUp}
|
||||
onkeydown={handleSLKeydown}
|
||||
role="application"
|
||||
aria-label="Saturation and lightness"
|
||||
tabindex="-1"
|
||||
aria-label="Saturation and lightness. Use arrow keys to adjust."
|
||||
tabindex="0"
|
||||
>
|
||||
<!-- Lightness overlay: white at top, black at bottom -->
|
||||
<div
|
||||
@@ -262,7 +302,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Hue bar -->
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex a11y_no_noninteractive_element_interactions -->
|
||||
<div
|
||||
bind:this={hueBar}
|
||||
class="relative h-[16px] w-full cursor-pointer rounded-full overflow-hidden touch-none"
|
||||
@@ -273,9 +313,10 @@
|
||||
onpointermove={handleHuePointerMove}
|
||||
onpointerup={handleHuePointerUp}
|
||||
onpointercancel={handleHuePointerUp}
|
||||
onkeydown={handleHueKeydown}
|
||||
role="application"
|
||||
aria-label="Hue"
|
||||
tabindex="-1"
|
||||
aria-label="Hue. Use arrow keys to adjust."
|
||||
tabindex="0"
|
||||
>
|
||||
<!-- Hue cursor -->
|
||||
<div
|
||||
@@ -288,8 +329,9 @@
|
||||
<!-- Hex input -->
|
||||
<input
|
||||
type="text"
|
||||
aria-label="Hex color value"
|
||||
class="w-full rounded-lg border border-[#1a1a1a] bg-black px-3 py-1.5 text-[12px]
|
||||
font-mono text-white outline-none
|
||||
font-mono text-white
|
||||
placeholder:text-[#333] focus:border-[#333]"
|
||||
placeholder="#ff4d00"
|
||||
value={hexInput}
|
||||
|
||||
@@ -37,6 +37,16 @@
|
||||
: "PAUSED",
|
||||
);
|
||||
|
||||
// Track status changes for aria-live region (announce only on change, not every tick)
|
||||
let lastAnnouncedStatus = $state("");
|
||||
let statusAnnouncement = $state("");
|
||||
$effect(() => {
|
||||
if (statusText !== lastAnnouncedStatus) {
|
||||
lastAnnouncedStatus = statusText;
|
||||
statusAnnouncement = `Timer status: ${statusText}. ${formatTime($timer.timeRemaining)} remaining.`;
|
||||
}
|
||||
});
|
||||
|
||||
const toggleBtnText = $derived(
|
||||
$timer.state === "running" ? "PAUSE" : "START",
|
||||
);
|
||||
@@ -87,6 +97,9 @@
|
||||
|
||||
<svelte:window bind:innerWidth={windowW} bind:innerHeight={windowH} />
|
||||
|
||||
<h1 class="sr-only" tabindex="-1">Dashboard</h1>
|
||||
<div aria-live="polite" class="sr-only">{statusAnnouncement}</div>
|
||||
|
||||
<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;">
|
||||
@@ -96,17 +109,20 @@
|
||||
size={280}
|
||||
strokeWidth={8}
|
||||
accentColor={$config.accent_color}
|
||||
label="Focus timer"
|
||||
valueText="{formatTime($timer.timeRemaining)} remaining"
|
||||
>
|
||||
<!-- Counter-scale wrapper: text shrinks less than ring -->
|
||||
<div style="transform: scale({textCounterScale}); transform-origin: center center;">
|
||||
<!-- Eye icon -->
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="mx-auto mb-3 eye-blink"
|
||||
width="26"
|
||||
height="26"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#444"
|
||||
stroke="#888"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
@@ -130,10 +146,7 @@
|
||||
<!-- 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-[#8a8a8a]={!$timer.prebreakWarning}
|
||||
class:text-warning={$timer.prebreakWarning}
|
||||
>
|
||||
{statusText}
|
||||
@@ -146,7 +159,7 @@
|
||||
<!-- 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]">
|
||||
<p style="margin-bottom: {ringMargin}px;" class="text-[12px] text-[#8a8a8a]">
|
||||
Last break {formatDurationAgo($timer.secondsSinceLastBreak)}
|
||||
</p>
|
||||
{:else}
|
||||
@@ -157,12 +170,13 @@
|
||||
<!-- Natural break notification toast -->
|
||||
{#if showNaturalBreakToast}
|
||||
<div
|
||||
role="alert"
|
||||
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">
|
||||
<svg aria-hidden="true" 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>
|
||||
@@ -190,12 +204,13 @@
|
||||
aria-label="Start break now"
|
||||
use:pressable
|
||||
class="flex h-11 w-11 items-center justify-center rounded-full
|
||||
border border-[#222] text-[#383838]
|
||||
border border-[#222] text-[#8a8a8a]
|
||||
transition-colors duration-200
|
||||
hover:border-[#333] hover:text-[#666]"
|
||||
hover:border-[#333] hover:text-[#aaa]"
|
||||
onclick={startBreakNow}
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -216,15 +231,16 @@
|
||||
aria-label="Statistics"
|
||||
use:pressable
|
||||
class="flex h-11 w-11 items-center justify-center rounded-full
|
||||
border border-[#222] text-[#383838]
|
||||
border border-[#222] text-[#8a8a8a]
|
||||
transition-colors duration-200
|
||||
hover:border-[#333] hover:text-[#666]"
|
||||
hover:border-[#333] hover:text-[#aaa]"
|
||||
onclick={() => {
|
||||
invoke("set_view", { view: "stats" });
|
||||
currentView.set("stats");
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
width="17"
|
||||
height="17"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -247,12 +263,13 @@
|
||||
aria-label="Settings"
|
||||
use:pressable
|
||||
class="flex h-11 w-11 items-center justify-center rounded-full
|
||||
border border-[#222] text-[#383838]
|
||||
border border-[#222] text-[#8a8a8a]
|
||||
transition-colors duration-200
|
||||
hover:border-[#333] hover:text-[#666]"
|
||||
hover:border-[#333] hover:text-[#aaa]"
|
||||
onclick={openSettings}
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
width="17"
|
||||
height="17"
|
||||
viewBox="0 0 24 24"
|
||||
|
||||
@@ -52,13 +52,15 @@
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-[13px] text-white">Countdown font</div>
|
||||
<div class="text-[11px] text-[#555]">
|
||||
<div class="text-[11px] text-[#8a8a8a]">
|
||||
{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]
|
||||
aria-expanded={expanded}
|
||||
aria-label={expanded ? "Close font browser" : "Browse fonts"}
|
||||
class="rounded-lg border border-[#1a1a1a] bg-black px-3 py-1.5 text-[12px] text-[#8a8a8a]
|
||||
transition-colors hover:border-[#333] hover:text-white"
|
||||
onclick={() => { expanded = !expanded; }}
|
||||
>
|
||||
@@ -75,6 +77,8 @@
|
||||
{#each fonts as font}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Select font: {font.label}"
|
||||
aria-pressed={value === font.family}
|
||||
class="flex flex-col items-center gap-1.5 rounded-xl border p-3
|
||||
transition-all duration-150
|
||||
{value === font.family
|
||||
@@ -88,7 +92,7 @@
|
||||
>
|
||||
25:00
|
||||
</span>
|
||||
<span class="text-[9px] tracking-wider text-[#555] uppercase">
|
||||
<span class="text-[9px] tracking-wider text-[#8a8a8a] uppercase">
|
||||
{font.label}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -192,8 +192,7 @@ const fontStyle = $derived(
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="w-full h-full flex items-center justify-center overflow-hidden">
|
||||
<div class="w-full h-full flex items-center justify-center overflow-hidden" role="status" aria-label="Mini timer: {timeText} {state === 'breakActive' ? 'break active' : state === 'running' ? 'running' : 'paused'}">
|
||||
<div
|
||||
style="
|
||||
width: {100 / zoomScale}%;
|
||||
@@ -206,8 +205,10 @@ const fontStyle = $derived(
|
||||
class="flex items-center justify-center w-full h-full"
|
||||
style="padding: 22px 14px 22px 24px;"
|
||||
>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions a11y_no_noninteractive_element_interactions -->
|
||||
<div
|
||||
class="mini-pill flex h-full w-full items-center select-none"
|
||||
role="application"
|
||||
class:mini-draggable={draggable}
|
||||
style="
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
@@ -226,6 +227,7 @@ const fontStyle = $derived(
|
||||
<div class="relative flex-shrink-0" style="width: {ringSize}px; height: {ringSize}px;">
|
||||
<!-- Glow SVG (larger for blur room) -->
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
width={viewSize}
|
||||
height={viewSize}
|
||||
class="pointer-events-none absolute"
|
||||
@@ -295,6 +297,7 @@ const fontStyle = $derived(
|
||||
|
||||
<!-- Non-glow SVG: track + crisp ring -->
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
width={ringSize}
|
||||
height={ringSize}
|
||||
class="absolute"
|
||||
|
||||
@@ -99,10 +99,11 @@
|
||||
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"
|
||||
text-[#8a8a8a] transition-colors hover:text-white"
|
||||
onclick={goBack}
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -117,6 +118,7 @@
|
||||
</button>
|
||||
<h1
|
||||
data-tauri-drag-region
|
||||
tabindex="-1"
|
||||
class="flex-1 text-lg font-medium text-white"
|
||||
>
|
||||
Settings
|
||||
@@ -132,7 +134,7 @@
|
||||
<!-- 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"
|
||||
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase"
|
||||
>
|
||||
Timer
|
||||
</h3>
|
||||
@@ -140,12 +142,13 @@
|
||||
<div class="flex items-center">
|
||||
<div class="flex-1">
|
||||
<div class="text-[13px] text-white">Break frequency</div>
|
||||
<div class="text-[11px] text-[#777]">
|
||||
<div class="text-[11px] text-[#8a8a8a]">
|
||||
Every {$config.break_frequency} min
|
||||
</div>
|
||||
</div>
|
||||
<Stepper
|
||||
bind:value={$config.break_frequency}
|
||||
label="Break frequency"
|
||||
min={5}
|
||||
max={120}
|
||||
onchange={markChanged}
|
||||
@@ -157,12 +160,13 @@
|
||||
<div class="flex items-center">
|
||||
<div class="flex-1">
|
||||
<div class="text-[13px] text-white">Break duration</div>
|
||||
<div class="text-[11px] text-[#777]">
|
||||
<div class="text-[11px] text-[#8a8a8a]">
|
||||
{$config.break_duration} min
|
||||
</div>
|
||||
</div>
|
||||
<Stepper
|
||||
bind:value={$config.break_duration}
|
||||
label="Break duration"
|
||||
min={1}
|
||||
max={60}
|
||||
onchange={markChanged}
|
||||
@@ -174,10 +178,11 @@
|
||||
<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 class="text-[11px] text-[#8a8a8a]">Start timer on launch</div>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
bind:checked={$config.auto_start}
|
||||
label="Auto-start"
|
||||
onchange={markChanged}
|
||||
/>
|
||||
</div>
|
||||
@@ -186,7 +191,7 @@
|
||||
<!-- 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"
|
||||
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase"
|
||||
>
|
||||
Break Screen
|
||||
</h3>
|
||||
@@ -230,12 +235,13 @@
|
||||
<div class="flex items-center">
|
||||
<div class="flex-1">
|
||||
<div class="text-[13px] text-white">Fullscreen break</div>
|
||||
<div class="text-[11px] text-[#777]">
|
||||
<div class="text-[11px] text-[#8a8a8a]">
|
||||
{$config.fullscreen_mode ? "Fills entire screen" : "Centered modal"}
|
||||
</div>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
bind:checked={$config.fullscreen_mode}
|
||||
label="Fullscreen break"
|
||||
onchange={markChanged}
|
||||
/>
|
||||
</div>
|
||||
@@ -245,12 +251,13 @@
|
||||
<div class="flex items-center">
|
||||
<div class="flex-1">
|
||||
<div class="text-[13px] text-white">Activity suggestions</div>
|
||||
<div class="text-[11px] text-[#777]">
|
||||
<div class="text-[11px] text-[#8a8a8a]">
|
||||
Exercise ideas during breaks
|
||||
</div>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
bind:checked={$config.show_break_activities}
|
||||
label="Activity suggestions"
|
||||
onchange={markChanged}
|
||||
/>
|
||||
</div>
|
||||
@@ -259,7 +266,7 @@
|
||||
<!-- 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"
|
||||
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase"
|
||||
>
|
||||
Behavior
|
||||
</h3>
|
||||
@@ -267,12 +274,13 @@
|
||||
<div class="flex items-center">
|
||||
<div class="flex-1">
|
||||
<div class="text-[13px] text-white">Strict mode</div>
|
||||
<div class="text-[11px] text-[#777]">
|
||||
<div class="text-[11px] text-[#8a8a8a]">
|
||||
Disable skip and snooze
|
||||
</div>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
bind:checked={$config.strict_mode}
|
||||
label="Strict mode"
|
||||
onchange={markChanged}
|
||||
/>
|
||||
</div>
|
||||
@@ -283,10 +291,11 @@
|
||||
<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 class="text-[11px] text-[#8a8a8a]">After 50% of break</div>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
bind:checked={$config.allow_end_early}
|
||||
label="Allow end early"
|
||||
onchange={markChanged}
|
||||
/>
|
||||
</div>
|
||||
@@ -296,12 +305,13 @@
|
||||
<div class="flex items-center">
|
||||
<div class="flex-1">
|
||||
<div class="text-[13px] text-white">Snooze duration</div>
|
||||
<div class="text-[11px] text-[#777]">
|
||||
<div class="text-[11px] text-[#8a8a8a]">
|
||||
{$config.snooze_duration} min
|
||||
</div>
|
||||
</div>
|
||||
<Stepper
|
||||
bind:value={$config.snooze_duration}
|
||||
label="Snooze duration"
|
||||
min={1}
|
||||
max={30}
|
||||
onchange={markChanged}
|
||||
@@ -313,7 +323,7 @@
|
||||
<div class="flex items-center">
|
||||
<div class="flex-1">
|
||||
<div class="text-[13px] text-white">Snooze limit</div>
|
||||
<div class="text-[11px] text-[#777]">
|
||||
<div class="text-[11px] text-[#8a8a8a]">
|
||||
{$config.snooze_limit === 0
|
||||
? "Unlimited"
|
||||
: `${$config.snooze_limit} per break`}
|
||||
@@ -321,6 +331,7 @@
|
||||
</div>
|
||||
<Stepper
|
||||
bind:value={$config.snooze_limit}
|
||||
label="Snooze limit"
|
||||
min={0}
|
||||
max={5}
|
||||
formatValue={(v) => (v === 0 ? "\u221E" : String(v))}
|
||||
@@ -334,12 +345,13 @@
|
||||
<div class="flex items-center">
|
||||
<div class="flex-1">
|
||||
<div class="text-[13px] text-white">Immediate breaks</div>
|
||||
<div class="text-[11px] text-[#777]">
|
||||
<div class="text-[11px] text-[#8a8a8a]">
|
||||
Skip pre-break warning
|
||||
</div>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
bind:checked={$config.immediately_start_breaks}
|
||||
label="Immediate breaks"
|
||||
onchange={markChanged}
|
||||
/>
|
||||
</div>
|
||||
@@ -350,12 +362,13 @@
|
||||
<div class="flex items-center">
|
||||
<div class="flex-1">
|
||||
<div class="text-[13px] text-white">Working hours</div>
|
||||
<div class="text-[11px] text-[#777]">
|
||||
<div class="text-[11px] text-[#8a8a8a]">
|
||||
Only show breaks during your configured work schedule
|
||||
</div>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
bind:checked={$config.working_hours_enabled}
|
||||
label="Working hours"
|
||||
onchange={markChanged}
|
||||
/>
|
||||
</div>
|
||||
@@ -370,6 +383,7 @@
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<ToggleSwitch
|
||||
bind:checked={$config.working_hours_schedule[dayIndex].enabled}
|
||||
label={dayName}
|
||||
onchange={markChanged}
|
||||
/>
|
||||
<span class="text-[13px] text-white w-20">{dayName}</span>
|
||||
@@ -448,7 +462,7 @@
|
||||
<!-- 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"
|
||||
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase"
|
||||
>
|
||||
Idle Detection
|
||||
</h3>
|
||||
@@ -456,10 +470,11 @@
|
||||
<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 class="text-[11px] text-[#8a8a8a]">Pause timer when away</div>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
bind:checked={$config.idle_detection_enabled}
|
||||
label="Auto-pause when idle"
|
||||
onchange={markChanged}
|
||||
/>
|
||||
</div>
|
||||
@@ -470,12 +485,13 @@
|
||||
<div class="flex items-center">
|
||||
<div class="flex-1">
|
||||
<div class="text-[13px] text-white">Idle timeout</div>
|
||||
<div class="text-[11px] text-[#777]">
|
||||
<div class="text-[11px] text-[#8a8a8a]">
|
||||
{$config.idle_timeout}s of inactivity
|
||||
</div>
|
||||
</div>
|
||||
<Stepper
|
||||
bind:value={$config.idle_timeout}
|
||||
label="Idle timeout"
|
||||
min={30}
|
||||
max={600}
|
||||
step={30}
|
||||
@@ -489,7 +505,7 @@
|
||||
<!-- 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"
|
||||
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase"
|
||||
>
|
||||
Smart Breaks
|
||||
</h3>
|
||||
@@ -497,10 +513,11 @@
|
||||
<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 class="text-[11px] text-[#8a8a8a]">Auto-reset timer when you step away</div>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
bind:checked={$config.smart_breaks_enabled}
|
||||
label="Enable smart breaks"
|
||||
onchange={markChanged}
|
||||
/>
|
||||
</div>
|
||||
@@ -511,7 +528,7 @@
|
||||
<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]">
|
||||
<div class="text-[11px] text-[#8a8a8a]">
|
||||
{$config.smart_break_threshold >= 60
|
||||
? `${Math.floor($config.smart_break_threshold / 60)} min`
|
||||
: `${$config.smart_break_threshold}s`} to count as break
|
||||
@@ -519,6 +536,7 @@
|
||||
</div>
|
||||
<Stepper
|
||||
bind:value={$config.smart_break_threshold}
|
||||
label="Minimum away time"
|
||||
min={120}
|
||||
max={900}
|
||||
step={60}
|
||||
@@ -532,10 +550,11 @@
|
||||
<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 class="text-[11px] text-[#8a8a8a]">Track natural breaks in stats</div>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
bind:checked={$config.smart_break_count_stats}
|
||||
label="Count in statistics"
|
||||
onchange={markChanged}
|
||||
/>
|
||||
</div>
|
||||
@@ -545,7 +564,7 @@
|
||||
<!-- 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"
|
||||
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase"
|
||||
>
|
||||
Notifications
|
||||
</h3>
|
||||
@@ -553,10 +572,11 @@
|
||||
<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 class="text-[11px] text-[#8a8a8a]">Warn before breaks</div>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
bind:checked={$config.notification_enabled}
|
||||
label="Pre-break alert"
|
||||
onchange={markChanged}
|
||||
/>
|
||||
</div>
|
||||
@@ -567,12 +587,13 @@
|
||||
<div class="flex items-center">
|
||||
<div class="flex-1">
|
||||
<div class="text-[13px] text-white">Alert timing</div>
|
||||
<div class="text-[11px] text-[#777]">
|
||||
<div class="text-[11px] text-[#8a8a8a]">
|
||||
{$config.notification_before_break}s before
|
||||
</div>
|
||||
</div>
|
||||
<Stepper
|
||||
bind:value={$config.notification_before_break}
|
||||
label="Alert timing"
|
||||
min={0}
|
||||
max={300}
|
||||
step={10}
|
||||
@@ -585,7 +606,7 @@
|
||||
<!-- 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"
|
||||
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase"
|
||||
>
|
||||
Sound
|
||||
</h3>
|
||||
@@ -593,10 +614,11 @@
|
||||
<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 class="text-[11px] text-[#8a8a8a]">Play sounds on break events</div>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
bind:checked={$config.sound_enabled}
|
||||
label="Sound effects"
|
||||
onchange={markChanged}
|
||||
/>
|
||||
</div>
|
||||
@@ -607,10 +629,11 @@
|
||||
<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 class="text-[11px] text-[#8a8a8a]">{$config.sound_volume}%</div>
|
||||
</div>
|
||||
<Stepper
|
||||
bind:value={$config.sound_volume}
|
||||
label="Volume"
|
||||
min={0}
|
||||
max={100}
|
||||
step={10}
|
||||
@@ -649,7 +672,7 @@
|
||||
<!-- 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"
|
||||
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase"
|
||||
>
|
||||
Appearance
|
||||
</h3>
|
||||
@@ -657,12 +680,13 @@
|
||||
<div class="flex items-center">
|
||||
<div class="flex-1">
|
||||
<div class="text-[13px] text-white">UI zoom</div>
|
||||
<div class="text-[11px] text-[#777]">
|
||||
<div class="text-[11px] text-[#8a8a8a]">
|
||||
{$config.ui_zoom}%
|
||||
</div>
|
||||
</div>
|
||||
<Stepper
|
||||
bind:value={$config.ui_zoom}
|
||||
label="UI zoom"
|
||||
min={50}
|
||||
max={200}
|
||||
step={5}
|
||||
@@ -705,12 +729,13 @@
|
||||
<div class="flex items-center">
|
||||
<div class="flex-1">
|
||||
<div class="text-[13px] text-white">Animated background</div>
|
||||
<div class="text-[11px] text-[#777]">
|
||||
<div class="text-[11px] text-[#8a8a8a]">
|
||||
Gradient blobs with film grain
|
||||
</div>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
bind:checked={$config.background_blobs_enabled}
|
||||
label="Animated background"
|
||||
onchange={markChanged}
|
||||
/>
|
||||
</div>
|
||||
@@ -719,7 +744,7 @@
|
||||
<!-- 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"
|
||||
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase"
|
||||
>
|
||||
Mini Mode
|
||||
</h3>
|
||||
@@ -727,12 +752,13 @@
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-[13px] text-white">Click-through</div>
|
||||
<div class="text-[11px] text-[#555]">
|
||||
<div class="text-[11px] text-[#8a8a8a]">
|
||||
Mini timer ignores clicks until you hover over it
|
||||
</div>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
bind:checked={$config.mini_click_through}
|
||||
label="Click-through"
|
||||
onchange={markChanged}
|
||||
/>
|
||||
</div>
|
||||
@@ -741,12 +767,13 @@
|
||||
<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]">
|
||||
<div class="text-[11px] text-[#8a8a8a]">
|
||||
Seconds to hover before it becomes draggable
|
||||
</div>
|
||||
</div>
|
||||
<Stepper
|
||||
bind:value={$config.mini_hover_threshold}
|
||||
label="Hover delay"
|
||||
min={1}
|
||||
max={10}
|
||||
step={0.5}
|
||||
@@ -760,7 +787,7 @@
|
||||
<!-- 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"
|
||||
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase"
|
||||
>
|
||||
Keyboard Shortcuts
|
||||
</h3>
|
||||
@@ -768,15 +795,15 @@
|
||||
<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>
|
||||
<kbd class="rounded-lg bg-[#161616] px-2.5 py-1 text-[11px] text-[#8a8a8a]">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>
|
||||
<kbd class="rounded-lg bg-[#161616] px-2.5 py-1 text-[11px] text-[#8a8a8a]">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>
|
||||
<kbd class="rounded-lg bg-[#161616] px-2.5 py-1 text-[11px] text-[#8a8a8a]">Ctrl+Shift+S</kbd>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -109,7 +109,7 @@
|
||||
}
|
||||
|
||||
// Day label
|
||||
ctx.fillStyle = "#444";
|
||||
ctx.fillStyle = "#8a8a8a";
|
||||
ctx.font = "10px -apple-system, sans-serif";
|
||||
ctx.textAlign = "center";
|
||||
const label = day.date.slice(5); // "MM-DD"
|
||||
@@ -117,6 +117,14 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Accessible chart summary
|
||||
const chartAriaLabel = $derived(() => {
|
||||
if (history.length === 0) return "No break history data available";
|
||||
const total = history.reduce((sum, d) => sum + d.breaksCompleted, 0);
|
||||
const skipped = history.reduce((sum, d) => sum + d.breaksSkipped, 0);
|
||||
return `Weekly break chart: ${total} completed and ${skipped} skipped over ${history.length} days`;
|
||||
});
|
||||
|
||||
function roundedRect(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
@@ -149,10 +157,11 @@
|
||||
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"
|
||||
text-[#8a8a8a] transition-colors hover:text-white"
|
||||
onclick={goBack}
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -167,6 +176,7 @@
|
||||
</button>
|
||||
<h1
|
||||
data-tauri-drag-region
|
||||
tabindex="-1"
|
||||
class="flex-1 text-lg font-medium text-white"
|
||||
>
|
||||
Statistics
|
||||
@@ -179,7 +189,7 @@
|
||||
<!-- 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"
|
||||
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase"
|
||||
>
|
||||
Today
|
||||
</h3>
|
||||
@@ -189,7 +199,7 @@
|
||||
<div class="text-[28px] font-semibold text-white tabular-nums">
|
||||
{stats?.todayCompleted ?? 0}
|
||||
</div>
|
||||
<div class="text-[11px] text-[#777]">Breaks taken</div>
|
||||
<div class="text-[11px] text-[#8a8a8a]">Breaks taken</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-[28px] font-semibold tabular-nums"
|
||||
@@ -197,19 +207,19 @@
|
||||
>
|
||||
{compliancePercent}%
|
||||
</div>
|
||||
<div class="text-[11px] text-[#777]">Compliance</div>
|
||||
<div class="text-[11px] text-[#8a8a8a]">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 class="text-[11px] text-[#8a8a8a]">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 class="text-[11px] text-[#8a8a8a]">Skipped</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -217,7 +227,7 @@
|
||||
<!-- 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"
|
||||
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase"
|
||||
>
|
||||
Streak
|
||||
</h3>
|
||||
@@ -225,7 +235,7 @@
|
||||
<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 class="text-[11px] text-[#8a8a8a]">Consecutive days with breaks</div>
|
||||
</div>
|
||||
<div class="text-[24px] font-semibold tabular-nums" style="color: {$config.accent_color}">
|
||||
{stats?.currentStreak ?? 0}
|
||||
@@ -237,7 +247,7 @@
|
||||
<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 class="text-[11px] text-[#8a8a8a]">All-time record</div>
|
||||
</div>
|
||||
<div class="text-[24px] font-semibold text-white tabular-nums">
|
||||
{stats?.bestStreak ?? 0}
|
||||
@@ -248,17 +258,39 @@
|
||||
<!-- 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"
|
||||
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase"
|
||||
>
|
||||
Last 7 Days
|
||||
</h3>
|
||||
|
||||
<!-- svelte-ignore a11y_no_interactive_element_to_noninteractive_role -->
|
||||
<canvas
|
||||
bind:this={chartCanvas}
|
||||
class="h-[140px] w-full"
|
||||
role="img"
|
||||
aria-label={chartAriaLabel()}
|
||||
></canvas>
|
||||
|
||||
<div class="mt-3 flex items-center justify-center gap-4 text-[10px] text-[#777]">
|
||||
<!-- Screen-reader accessible data table for the chart -->
|
||||
{#if history.length > 0}
|
||||
<table class="sr-only">
|
||||
<caption>Break history for the last {history.length} days</caption>
|
||||
<thead>
|
||||
<tr><th>Date</th><th>Completed</th><th>Skipped</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each history as day}
|
||||
<tr>
|
||||
<td>{day.date}</td>
|
||||
<td>{day.breaksCompleted}</td>
|
||||
<td>{day.breaksSkipped}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
|
||||
<div class="mt-3 flex items-center justify-center gap-4 text-[10px] text-[#8a8a8a]">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="h-2 w-2 rounded-sm" style="background: {$config.accent_color}"></div>
|
||||
Completed
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
step?: number;
|
||||
formatValue?: (v: number) => string;
|
||||
onchange?: (value: number) => void;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -15,6 +16,7 @@
|
||||
step = 1,
|
||||
formatValue = (v: number) => String(v),
|
||||
onchange,
|
||||
label = "Value",
|
||||
}: Props = $props();
|
||||
|
||||
let holdTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
@@ -55,32 +57,36 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="flex items-center gap-1.5" role="group" aria-label={label}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Decrease"
|
||||
class="flex h-7 w-7 items-center justify-center rounded-lg
|
||||
bg-[#141414] text-[#444] transition-colors
|
||||
bg-[#141414] text-[#999] transition-colors
|
||||
hover:bg-[#1c1c1c] hover:text-white
|
||||
disabled:opacity-20"
|
||||
onmousedown={() => startHold(decrement)}
|
||||
onmouseup={stopHold}
|
||||
onmouseleave={stopHold}
|
||||
onclick={(e) => { if (e.detail === 0) decrement(); }}
|
||||
disabled={value <= min}
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span class="min-w-[38px] text-center text-[13px] tabular-nums text-white">
|
||||
<span class="min-w-[38px] text-center text-[13px] tabular-nums text-white" aria-live="polite" aria-atomic="true">
|
||||
{formatValue(value)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Increase"
|
||||
class="flex h-7 w-7 items-center justify-center rounded-lg
|
||||
bg-[#141414] text-[#444] transition-colors
|
||||
bg-[#141414] text-[#999] transition-colors
|
||||
hover:bg-[#1c1c1c] hover:text-white
|
||||
disabled:opacity-20"
|
||||
onmousedown={() => startHold(increment)}
|
||||
onmouseup={stopHold}
|
||||
onmouseleave={stopHold}
|
||||
onclick={(e) => { if (e.detail === 0) increment(); }}
|
||||
disabled={value >= max}
|
||||
>
|
||||
+
|
||||
|
||||
@@ -247,6 +247,31 @@
|
||||
function format(n: number): string {
|
||||
return String(n).padStart(2, "0");
|
||||
}
|
||||
|
||||
// Keyboard handlers for arrow key operation
|
||||
function handleHoursKeydown(e: KeyboardEvent) {
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
displayHours = wrapValue(displayHours + 1, 24);
|
||||
emitValue(displayHours, displayMinutes);
|
||||
} else if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
displayHours = wrapValue(displayHours - 1, 24);
|
||||
emitValue(displayHours, displayMinutes);
|
||||
}
|
||||
}
|
||||
|
||||
function handleMinutesKeydown(e: KeyboardEvent) {
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
displayMinutes = wrapValue(displayMinutes + 1, 60);
|
||||
emitValue(displayHours, displayMinutes);
|
||||
} else if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
displayMinutes = wrapValue(displayMinutes - 1, 60);
|
||||
emitValue(displayHours, displayMinutes);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="time-spinner" style="font-family: {countdownFont ? `'${countdownFont}', monospace` : 'monospace'};">
|
||||
@@ -265,6 +290,7 @@
|
||||
onpointermove={handleHoursPointerMove}
|
||||
onpointerup={handleHoursPointerUp}
|
||||
onpointercancel={handleHoursPointerUp}
|
||||
onkeydown={handleHoursKeydown}
|
||||
>
|
||||
<div class="wheel-viewport">
|
||||
<div class="wheel-cylinder">
|
||||
@@ -300,6 +326,7 @@
|
||||
onpointermove={handleMinutesPointerMove}
|
||||
onpointerup={handleMinutesPointerUp}
|
||||
onpointercancel={handleMinutesPointerUp}
|
||||
onkeydown={handleMinutesKeydown}
|
||||
>
|
||||
<div class="wheel-viewport">
|
||||
<div class="wheel-cylinder">
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
size?: number;
|
||||
strokeWidth?: number;
|
||||
accentColor?: string;
|
||||
label?: string;
|
||||
valueText?: string;
|
||||
children?: import("svelte").Snippet;
|
||||
}
|
||||
|
||||
@@ -12,6 +14,8 @@
|
||||
size = 280,
|
||||
strokeWidth = 8,
|
||||
accentColor = "#ff4d00",
|
||||
label = "Timer",
|
||||
valueText = "",
|
||||
children,
|
||||
}: Props = $props();
|
||||
|
||||
@@ -44,9 +48,16 @@
|
||||
<div
|
||||
class="relative flex items-center justify-center"
|
||||
style="width: {size}px; height: {size}px;"
|
||||
role="progressbar"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={Math.round(progress * 100)}
|
||||
aria-label={label}
|
||||
aria-valuetext={valueText}
|
||||
>
|
||||
<!-- Glow SVG - drawn larger than the container so blur isn't clipped -->
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
width={viewSize}
|
||||
height={viewSize}
|
||||
class="pointer-events-none absolute"
|
||||
@@ -136,6 +147,7 @@
|
||||
|
||||
<!-- Non-glow SVG - exact size, draws the track + crisp ring -->
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
width={size}
|
||||
height={size}
|
||||
class="absolute"
|
||||
|
||||
@@ -9,8 +9,9 @@
|
||||
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 -->
|
||||
<!-- Centered app name (decorative - OS window title handles screen readers) -->
|
||||
<span
|
||||
aria-hidden="true"
|
||||
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;"
|
||||
@@ -19,7 +20,7 @@
|
||||
</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">
|
||||
<div class="flex items-center gap-[8px] opacity-10 transition-opacity duration-300 group-hover:opacity-100 group-focus-within:opacity-100">
|
||||
<!-- Maximize (green) -->
|
||||
<button
|
||||
aria-label="Maximize"
|
||||
|
||||
@@ -4,9 +4,10 @@
|
||||
interface Props {
|
||||
checked: boolean;
|
||||
onchange?: (value: boolean) => void;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
let { checked = $bindable(), onchange }: Props = $props();
|
||||
let { checked = $bindable(), onchange, label = "Toggle" }: Props = $props();
|
||||
|
||||
function toggle() {
|
||||
checked = !checked;
|
||||
@@ -17,10 +18,10 @@
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-label="Toggle"
|
||||
aria-label={label}
|
||||
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"
|
||||
transition-colors duration-200 ease-in-out"
|
||||
style="background: {checked ? $config.accent_color : '#1a1a1a'};"
|
||||
onclick={toggle}
|
||||
>
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import { animate } from "motion";
|
||||
|
||||
// Module-level reduced motion query — shared across all actions
|
||||
const reducedMotionQuery =
|
||||
typeof window !== "undefined"
|
||||
? window.matchMedia("(prefers-reduced-motion: reduce)")
|
||||
: null;
|
||||
|
||||
function prefersReducedMotion(): boolean {
|
||||
return reducedMotionQuery?.matches ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Svelte action: fade in + slide up on mount
|
||||
*/
|
||||
@@ -7,6 +17,11 @@ export function fadeIn(
|
||||
node: HTMLElement,
|
||||
options?: { duration?: number; delay?: number; y?: number },
|
||||
) {
|
||||
if (prefersReducedMotion()) {
|
||||
node.style.opacity = "1";
|
||||
return { destroy() {} };
|
||||
}
|
||||
|
||||
const { duration = 0.5, delay = 0, y = 15 } = options ?? {};
|
||||
node.style.opacity = "0";
|
||||
|
||||
@@ -30,6 +45,11 @@ export function scaleIn(
|
||||
node: HTMLElement,
|
||||
options?: { duration?: number; delay?: number },
|
||||
) {
|
||||
if (prefersReducedMotion()) {
|
||||
node.style.opacity = "1";
|
||||
return { destroy() {} };
|
||||
}
|
||||
|
||||
const { duration = 0.6, delay = 0 } = options ?? {};
|
||||
node.style.opacity = "0";
|
||||
|
||||
@@ -53,6 +73,11 @@ export function inView(
|
||||
node: HTMLElement,
|
||||
options?: { delay?: number; y?: number; threshold?: number },
|
||||
) {
|
||||
if (prefersReducedMotion()) {
|
||||
node.style.opacity = "1";
|
||||
return { destroy() {} };
|
||||
}
|
||||
|
||||
const { delay = 0, y = 20, threshold = 0.05 } = options ?? {};
|
||||
node.style.opacity = "0";
|
||||
node.style.transform = `translateY(${y}px)`;
|
||||
@@ -90,6 +115,10 @@ export function inView(
|
||||
* Svelte action: spring-scale press feedback on buttons
|
||||
*/
|
||||
export function pressable(node: HTMLElement) {
|
||||
if (prefersReducedMotion()) {
|
||||
return { destroy() {} };
|
||||
}
|
||||
|
||||
let active: ReturnType<typeof animate> | null = null;
|
||||
|
||||
function onDown() {
|
||||
@@ -137,6 +166,10 @@ export function glowHover(
|
||||
node: HTMLElement,
|
||||
options?: { color?: string },
|
||||
) {
|
||||
if (prefersReducedMotion()) {
|
||||
return { update() {}, destroy() {} };
|
||||
}
|
||||
|
||||
let color = options?.color ?? "#ff4d00";
|
||||
let enterAnim: ReturnType<typeof animate> | null = null;
|
||||
let leaveAnim: ReturnType<typeof animate> | null = null;
|
||||
@@ -191,6 +224,11 @@ export function glowHover(
|
||||
* container itself (which would break overflow clipping).
|
||||
*/
|
||||
export function dragScroll(node: HTMLElement) {
|
||||
if (prefersReducedMotion()) {
|
||||
// Allow normal scrolling without the momentum/elastic physics
|
||||
return { destroy() {} };
|
||||
}
|
||||
|
||||
const content = node.children[0] as HTMLElement | null;
|
||||
|
||||
let isDown = false;
|
||||
|
||||
Reference in New Issue
Block a user