From 78055a8c9c456b7ecae840c01ca132a2d68ddc38 Mon Sep 17 00:00:00 2001 From: Hector Alvarez Date: Fri, 5 Jun 2026 08:33:20 -0500 Subject: [PATCH 1/6] feat: convert timer panel to overlay with pop in/out animation --- src/components/timer.ts | 15 ++++++++++++++- src/styles/animations.css | 20 ++++++++++++++++---- src/styles/clock.css | 1 + src/styles/timer.css | 13 ++++++++++++- 4 files changed, 43 insertions(+), 6 deletions(-) diff --git a/src/components/timer.ts b/src/components/timer.ts index 9cb8499..41d5ff3 100644 --- a/src/components/timer.ts +++ b/src/components/timer.ts @@ -360,9 +360,22 @@ export function initTimer(_callbacks: TimerCallbacks = {}) { function closePanel() { isPanelOpen = false; - dom.panel.setAttribute('hidden', ''); dom.toggleBtn.classList.remove('active'); renderToggle(); + + // Play close animation before hiding + dom.panel.classList.add('closing'); + dom.panel.removeAttribute('hidden'); + + const onEnd = () => { + dom.panel.removeEventListener('animationend', onEnd); + dom.panel.classList.remove('closing'); + dom.panel.setAttribute('hidden', ''); + }; + dom.panel.addEventListener('animationend', onEnd); + + // Fallback in case animationend doesn't fire + setTimeout(onEnd, 300); } function togglePanel() { diff --git a/src/styles/animations.css b/src/styles/animations.css index e3ddd7a..b5dd81c 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: translateX(-50%) translateY(-8px) scale(0.95); } to { opacity: 1; - transform: translateY(0); + transform: translateX(-50%) translateY(0) scale(1); + } +} + +/* Panel close — timer panel pops out */ +@keyframes timer-panel-close { + from { + opacity: 1; + transform: translateX(-50%) translateY(0) scale(1); + } + to { + opacity: 0; + transform: translateX(-50%) translateY(-8px) scale(0.95); } } 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..5cd4655 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,26 @@ 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); + position: absolute; + top: calc(100% + var(--space-2xl)); + left: 50%; + transform: translateX(-50%); + z-index: 100; + 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 { From 641b3be99dd49620d5c3c38a1c1f3cc116ea6cb0 Mon Sep 17 00:00:00 2001 From: Hector Alvarez Date: Fri, 5 Jun 2026 12:39:08 -0500 Subject: [PATCH 2/6] fix(clock): render clock on start instead of waiting for next second Call tick() at the top of start() so the clock displays immediately on initial load rather than after the first setTimeout fires at the next second boundary. --- src/components/clock.ts | 1 + 1 file changed, 1 insertion(+) 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(); From 516d0cfeb0976df3f9d585c96051f26e2c0f02c2 Mon Sep 17 00:00:00 2001 From: Hector Alvarez Date: Fri, 5 Jun 2026 12:39:28 -0500 Subject: [PATCH 3/6] fix(timer): skip redundant closePanel() call on init The timer panel starts hidden via the HTML 'hidden' attribute, so calling closePanel() at the end of init() was needlessly triggering the closing CSS animation on every page load. --- src/components/timer.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/components/timer.ts b/src/components/timer.ts index 41d5ff3..1405734 100644 --- a/src/components/timer.ts +++ b/src/components/timer.ts @@ -423,8 +423,6 @@ export function initTimer(_callbacks: TimerCallbacks = {}) { dom.presetAddBtn.addEventListener('click', onAddPreset); document.addEventListener('click', onDocumentClick); document.addEventListener('keydown', onDocumentKeydown); - - closePanel(); } function destroy() { From c47862ed07b575c06906969e8ea864d058f7e01d Mon Sep 17 00:00:00 2001 From: Hector Alvarez Date: Fri, 5 Jun 2026 12:39:47 -0500 Subject: [PATCH 4/6] refactor(css): move timer panel into document flow - Drop position: absolute / top / left / transform: translateX(-50%) / z-index from #timer-panel so it stacks naturally below the toggle button inside the centered #clock-container. - Remove the matching translateX(-50%) terms from @keyframes timer-panel-open and @keyframes timer-panel-close. - Switch body overflow from 'hidden' to 'overflow-y: auto; overflow-x: hidden' so short viewports can still scroll to see the full panel. --- src/styles/animations.css | 8 ++++---- src/styles/base.css | 3 ++- src/styles/timer.css | 5 ----- 3 files changed, 6 insertions(+), 10 deletions(-) diff --git a/src/styles/animations.css b/src/styles/animations.css index b5dd81c..9a0629e 100644 --- a/src/styles/animations.css +++ b/src/styles/animations.css @@ -18,11 +18,11 @@ @keyframes timer-panel-open { from { opacity: 0; - transform: translateX(-50%) translateY(-8px) scale(0.95); + transform: translateY(-8px) scale(0.95); } to { opacity: 1; - transform: translateX(-50%) translateY(0) scale(1); + transform: translateY(0) scale(1); } } @@ -30,11 +30,11 @@ @keyframes timer-panel-close { from { opacity: 1; - transform: translateX(-50%) translateY(0) scale(1); + transform: translateY(0) scale(1); } to { opacity: 0; - transform: translateX(-50%) translateY(-8px) scale(0.95); + 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/timer.css b/src/styles/timer.css index 5cd4655..207c9fa 100644 --- a/src/styles/timer.css +++ b/src/styles/timer.css @@ -56,11 +56,6 @@ box-sizing: border-box; width: var(--panel-width); box-shadow: var(--glow-subtle); - position: absolute; - top: calc(100% + var(--space-2xl)); - left: 50%; - transform: translateX(-50%); - z-index: 100; animation: timer-panel-open 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) both; } From 4a30bcbf3eb47ca99ba81b6d0012666426f41401 Mon Sep 17 00:00:00 2001 From: Hector Alvarez Date: Fri, 5 Jun 2026 12:41:20 -0500 Subject: [PATCH 5/6] feat(timer): animate clock position with FLIP on panel toggle Add a flipAnimateClockContainer helper that wraps the FLIP technique to smoothly slide #clock-container when the timer panel is added or removed (which re-centers the container in the body): - First: capture the container's current bounding rect. - Run the panel open/close mutation. - Last: capture the new rect, compute the delta, apply an inverted transform to keep the container visually pinned, then animate the transform back to identity in the next animation frame. - Lock body overflow during the transition so the page cannot scroll while the height changes (prevents a flash of the vertical scrollbar). - Clean up inline transition/transform/overflow on transitionend with a 400ms fallback. openPanel() and closePanel() now both delegate to the helper, so the clock slides down on open and up on close, making the toggle feel symmetric. The separate CSS timer-panel-close animation on the panel itself is no longer used (it was causing close to lag behind open). --- src/components/timer.ts | 87 ++++++++++++++++++++++++++++++++--------- 1 file changed, 68 insertions(+), 19 deletions(-) diff --git a/src/components/timer.ts b/src/components/timer.ts index 1405734..4fe15ef 100644 --- a/src/components/timer.ts +++ b/src/components/timer.ts @@ -351,31 +351,80 @@ export function initTimer(_callbacks: TimerCallbacks = {}) { // ---------- Panel toggle ---------- - function openPanel() { - isPanelOpen = true; - dom.panel.removeAttribute('hidden'); - dom.toggleBtn.classList.add('active'); - renderToggle(); - } + // 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; + } - function closePanel() { - isPanelOpen = false; - dom.toggleBtn.classList.remove('active'); - renderToggle(); + // Lock body overflow so the page can't scroll while the height changes + const prevBodyOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; - // Play close animation before hiding - dom.panel.classList.add('closing'); - dom.panel.removeAttribute('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 = () => { - dom.panel.removeEventListener('animationend', onEnd); - dom.panel.classList.remove('closing'); - dom.panel.setAttribute('hidden', ''); + container.removeEventListener('transitionend', onEnd); + container.style.transition = ''; + container.style.transform = ''; + document.body.style.overflow = prevBodyOverflow; }; - dom.panel.addEventListener('animationend', onEnd); + container.addEventListener('transitionend', onEnd); - // Fallback in case animationend doesn't fire - setTimeout(onEnd, 300); + // Fallback in case transitionend doesn't fire + setTimeout(onEnd, 400); + } + + function openPanel() { + flipAnimateClockContainer(() => { + isPanelOpen = true; + dom.panel.removeAttribute('hidden'); + dom.toggleBtn.classList.add('active'); + renderToggle(); + }); + } + + function closePanel() { + // 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', ''); + renderToggle(); + }); } function togglePanel() { From ff2f135a9608c8e9e7ab1bb674672281b009c3fa Mon Sep 17 00:00:00 2001 From: Hector Alvarez Date: Fri, 5 Jun 2026 14:07:51 -0500 Subject: [PATCH 6/6] feat(timer): auto-reset timer on panel close when finished --- src/components/timer.ts | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/src/components/timer.ts b/src/components/timer.ts index 4fe15ef..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'); @@ -423,7 +431,14 @@ export function initTimer(_callbacks: TimerCallbacks = {}) { dom.toggleBtn.classList.remove('active'); dom.panel.classList.remove('closing'); dom.panel.setAttribute('hidden', ''); - renderToggle(); + // 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(); + } }); } @@ -479,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); }