diff --git a/src/components/clock.ts b/src/components/clock.ts index ec9102a..b26ee1c 100644 --- a/src/components/clock.ts +++ b/src/components/clock.ts @@ -107,6 +107,7 @@ export function initClock(): { start: () => void; stop: () => void } | null { } function start() { + tick(); const ms = msToNextSecond(new Date()); setTimeout(() => { tick(); diff --git a/src/components/timer.ts b/src/components/timer.ts index 9cb8499..ad68ba4 100644 --- a/src/components/timer.ts +++ b/src/components/timer.ts @@ -105,6 +105,7 @@ export function initTimer(_callbacks: TimerCallbacks = {}) { const presets = parsePresets(localStorage.getItem(STORAGE_KEY)); let core = createTimerCore(presets); let intervalId: number | null = null; + let beepIntervalId: number | null = null; let isPanelOpen = false; let activeInput: 'min' | 'sec' = 'min'; @@ -267,11 +268,14 @@ export function initTimer(_callbacks: TimerCallbacks = {}) { document.body.classList.add('timer-finished'); playBeep(); let beepCount = 0; - const beepInterval = window.setInterval(() => { + beepIntervalId = window.setInterval(() => { playBeep(); beepCount++; if (beepCount >= 2) { - clearInterval(beepInterval); + if (beepIntervalId !== null) { + clearInterval(beepIntervalId); + beepIntervalId = null; + } } }, 700); } @@ -317,6 +321,10 @@ export function initTimer(_callbacks: TimerCallbacks = {}) { clearInterval(intervalId); intervalId = null; } + if (beepIntervalId !== null) { + clearInterval(beepIntervalId); + beepIntervalId = null; + } core = resetTimer(core); setInputsFromSeconds(core.configuredDuration); document.body.classList.remove('timer-finished'); @@ -351,18 +359,87 @@ export function initTimer(_callbacks: TimerCallbacks = {}) { // ---------- Panel toggle ---------- + // FLIP animation: smoothly animate #clock-container movement when the + // panel is added/removed (which re-centers the container in the body). + // The clock sliding up/down IS the open/close animation for the + // surrounding layout, so open and close feel symmetric. + function flipAnimateClockContainer(action: () => void) { + const container = document.getElementById('clock-container'); + if (!container) { + action(); + return; + } + + // Lock body overflow so the page can't scroll while the height changes + const prevBodyOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + + // First: record the current position of #clock-container + const firstRect = container.getBoundingClientRect(); + + // Run the DOM mutation (open/close panel) + action(); + + // Last: measure the new position synchronously + const lastRect = container.getBoundingClientRect(); + + // Compute the delta and invert it with a transform + const dx = firstRect.left - lastRect.left; + const dy = firstRect.top - lastRect.top; + if (dx === 0 && dy === 0) { + document.body.style.overflow = prevBodyOverflow; + return; + } + + container.style.transition = 'none'; + container.style.transform = `translate(${dx}px, ${dy}px)`; + + // Play: on the next frame, animate the transform back to identity + requestAnimationFrame(() => { + container.style.transition = 'transform 0.3s ease-out'; + container.style.transform = ''; + }); + + // Clean up inline styles after the transition completes + const onEnd = () => { + container.removeEventListener('transitionend', onEnd); + container.style.transition = ''; + container.style.transform = ''; + document.body.style.overflow = prevBodyOverflow; + }; + container.addEventListener('transitionend', onEnd); + + // Fallback in case transitionend doesn't fire + setTimeout(onEnd, 400); + } + function openPanel() { - isPanelOpen = true; - dom.panel.removeAttribute('hidden'); - dom.toggleBtn.classList.add('active'); - renderToggle(); + flipAnimateClockContainer(() => { + isPanelOpen = true; + dom.panel.removeAttribute('hidden'); + dom.toggleBtn.classList.add('active'); + renderToggle(); + }); } function closePanel() { - isPanelOpen = false; - dom.panel.setAttribute('hidden', ''); - dom.toggleBtn.classList.remove('active'); - renderToggle(); + // Mirror open exactly: hide the panel immediately and let the FLIP + // clock slide-up be the close animation. This avoids the lag of + // waiting for a separate CSS close animation on the panel. + flipAnimateClockContainer(() => { + isPanelOpen = false; + dom.toggleBtn.classList.remove('active'); + dom.panel.classList.remove('closing'); + dom.panel.setAttribute('hidden', ''); + // If the timer had just finished, auto-reset it on close so the + // next time the panel opens, it's ready to start a new countdown + // at the configured duration (and silences any in-flight beeps). + if (core.state === 'finished') { + onReset(); + } else { + renderToggle(); + } + }); } function togglePanel() { @@ -410,8 +487,6 @@ export function initTimer(_callbacks: TimerCallbacks = {}) { dom.presetAddBtn.addEventListener('click', onAddPreset); document.addEventListener('click', onDocumentClick); document.addEventListener('keydown', onDocumentKeydown); - - closePanel(); } function destroy() { @@ -419,6 +494,10 @@ export function initTimer(_callbacks: TimerCallbacks = {}) { clearInterval(intervalId); intervalId = null; } + if (beepIntervalId !== null) { + clearInterval(beepIntervalId); + beepIntervalId = null; + } document.removeEventListener('click', onDocumentClick); document.removeEventListener('keydown', onDocumentKeydown); } diff --git a/src/styles/animations.css b/src/styles/animations.css index e3ddd7a..9a0629e 100644 --- a/src/styles/animations.css +++ b/src/styles/animations.css @@ -14,15 +14,27 @@ } } -/* Panel fade-in — timer panel opens */ -@keyframes timer-fade-in { +/* Panel open — timer panel pops in */ +@keyframes timer-panel-open { from { opacity: 0; - transform: translateY(-6px); + transform: translateY(-8px) scale(0.95); } to { opacity: 1; - transform: translateY(0); + transform: translateY(0) scale(1); + } +} + +/* Panel close — timer panel pops out */ +@keyframes timer-panel-close { + from { + opacity: 1; + transform: translateY(0) scale(1); + } + to { + opacity: 0; + transform: translateY(-8px) scale(0.95); } } diff --git a/src/styles/base.css b/src/styles/base.css index 9b2e703..5df32a3 100644 --- a/src/styles/base.css +++ b/src/styles/base.css @@ -12,7 +12,8 @@ body { background-color: var(--color-bg); color: var(--color-primary); font-family: var(--font-family); - overflow: hidden; + overflow-y: auto; + overflow-x: hidden; } /* ---------- Environment Marker ---------- */ diff --git a/src/styles/clock.css b/src/styles/clock.css index d2fa3ed..93cd79b 100644 --- a/src/styles/clock.css +++ b/src/styles/clock.css @@ -9,6 +9,7 @@ align-items: center; justify-content: center; gap: var(--space-3xl); + margin-top: -8vh; } #clock { diff --git a/src/styles/timer.css b/src/styles/timer.css index 355da79..207c9fa 100644 --- a/src/styles/timer.css +++ b/src/styles/timer.css @@ -11,6 +11,7 @@ flex-direction: column; align-items: center; gap: var(--space-md); + position: relative; } #timer-toggle { @@ -52,16 +53,21 @@ border: var(--border-width) solid var(--color-primary); border-radius: var(--border-radius-lg); background-color: var(--color-surface); - animation: timer-fade-in var(--transition-normal) ease-out; box-sizing: border-box; width: var(--panel-width); box-shadow: var(--glow-subtle); + animation: timer-panel-open 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) both; } #timer-panel[hidden] { display: none; } +#timer-panel.closing { + animation: timer-panel-close 0.2s ease-in both; + pointer-events: none; +} + /* ---------- Display ---------- */ #timer-display {