<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Timer</title>
<style>
:root {
/* Colors */
--color-primary: #4285f4;
--color-primary-hover: #3367d6;
--color-secondary: #34a853;
--color-secondary-hover: #2d9c47;
--color-danger: #ea4335;
--color-background: #ffffff;
--color-surface: #ffffff;
--color-surface-variant: #f0f2f5;
--color-surface-hover: #e0e4e8;
--color-outline: #e8eaed;
--color-text-primary: #333;
--color-text-secondary: #666;
/* Typography */
--font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
--font-size-xs: 0.875rem;
--font-size-sm: 1rem;
--font-size-md: 1.125rem;
--font-size-lg: 2.25rem;
--font-weight-normal: 300;
--font-weight-medium: 500;
/* Spacing */
--space-xs: 0.5rem;
--space-sm: 0.75rem;
--space-md: 1rem;
--space-lg: 1.25rem;
--space-xl: 2rem;
/* Border radius */
--radius-sm: 0.5rem;
--radius-md: 0.75rem;
--radius-lg: 1.375rem;
/* Component sizes */
--timer-circle-size: 12.5rem;
--timer-circle-stroke: 0.5rem;
--timer-circle-radius: 5.625rem;
--control-btn-size: 2.5rem;
--main-btn-width: 8.75rem;
--main-btn-height: 2.8125rem;
--time-input-width: 3.75rem;
--time-input-height: 2.5rem;
/* Transitions */
--transition-fast: 0.1s ease;
--transition-normal: 0.2s ease;
}
* {
margin: 0;
padding: 0;
}
body {
font-family: var(--font-family);
background: var(--color-background);
min-height: 100vh;
}
.timer-container {
display: flex;
align-items: center;
gap: var(--space-xl);
padding: var(--space-xl);
}
.timer-left {
display: flex;
flex-direction: column;
align-items: center;
}
.timer-right {
display: flex;
flex-direction: column;
gap: var(--space-lg);
min-width: 12.5rem;
}
.timer-header {
position: absolute;
top: var(--space-xs);
left: var(--space-xs);
justify-content: flex-start;
align-items: center;
margin-bottom: var(--space-md);
}
.controls-top {
display: flex;
gap: var(--space-sm);
}
.control-btn {
width: var(--control-btn-size);
height: var(--control-btn-size);
border: none;
background: var(--color-surface-variant);
border-radius: var(--radius-md);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: var(--font-size-md);
transition: all var(--transition-normal);
}
.control-btn:hover {
background: var(--color-surface-hover);
}
.control-btn.muted {
background: var(--color-danger);
color: var(--color-surface);
}
.mute-icon {
color: var(--color-text-secondary);
transition: color var(--transition-normal);
}
.control-btn:hover .mute-icon {
color: var(--color-text-primary);
}
.control-btn.muted .mute-icon {
color: var(--color-surface);
}
.timer-circle {
overflow: visible;
position: relative;
width: var(--timer-circle-size);
height: var(--timer-circle-size);
margin: 0;
}
.progress-ring {
transform: rotate(-90deg);
width: 100%;
height: 100%;
overflow: visible;
}
.progress-ring-bg {
fill: none;
stroke: var(--color-outline);
stroke-width: var(--timer-circle-stroke);
}
.progress-ring-progress {
fill: none;
stroke: var(--color-primary);
stroke-width: var(--timer-circle-stroke);
stroke-linecap: round;
stroke-dasharray: 0 879.646;
transition: stroke-dasharray var(--transition-fast);
}
.timer-display {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: var(--font-size-lg);
font-weight: var(--font-weight-normal);
color: var(--color-text-primary);
letter-spacing: 0.125rem;
background: transparent;
border: none;
text-align: center;
width: 9.375rem;
outline: none;
cursor: pointer;
}
.timer-display:not([readonly]) {
border: 0.125rem dashed var(--color-primary);
border-radius: var(--radius-sm);
cursor: text;
}
.timer-controls {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.main-control-btn {
width: var(--main-btn-width);
height: var(--main-btn-height);
border: none;
border-radius: var(--radius-lg);
cursor: pointer;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
transition: all var(--transition-normal);
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-xs);
}
.play-pause-btn {
background: var(--color-primary);
color: var(--color-surface);
}
.play-pause-btn:hover {
background: var(--color-primary-hover);
}
.reset-btn {
background: var(--color-surface-variant);
color: var(--color-text-primary);
}
.reset-btn:hover {
background: var(--color-surface-hover);
}
.time-config {
display: flex;
gap: var(--space-sm);
justify-content: flex-start;
align-items: center;
flex-wrap: wrap;
}
.time-input {
width: var(--time-input-width);
height: var(--time-input-height);
border: 0.125rem solid var(--color-outline);
border-radius: var(--radius-sm);
text-align: center;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
}
.time-input:focus {
outline: none;
border-color: var(--color-primary);
}
.time-label {
font-size: var(--font-size-xs);
color: var(--color-text-secondary);
}
.set-btn {
padding: var(--space-xs) var(--space-md);
border: none;
background: var(--color-secondary);
color: var(--color-surface);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
}
.set-btn:hover {
background: var(--color-secondary-hover);
}
.feedback-text {
margin-top: var(--space-lg);
color: var(--color-text-secondary);
font-size: var(--font-size-xs);
cursor: pointer;
text-decoration: none;
}
.feedback-text:hover {
color: var(--color-primary);
}
</style>
</head>
<body>
<script>
const ro = new ResizeObserver( ( entries ) => {
for ( const entry of entries ) {
window.parent.postMessage(
{ type: "ui-size-change", payload: { height: entry.contentRect.height } },
"*"
);
}
} );
ro.observe( document.documentElement );
</script>
<div class="timer-container">
<div class="timer-left">
<div class="timer-circle">
<svg class="progress-ring" viewBox="0 0 200 200">
<circle class="progress-ring-bg" cx="100" cy="100" r="90"></circle>
<circle class="progress-ring-progress" cx="100" cy="100" r="90"></circle>
</svg>
<input type="text" class="timer-display" value="" readonly>
</div>
</div>
<div class="timer-right">
<div class="timer-header">
<div class="controls-top">
<button class="control-btn mute-btn" title="Toggle sound">
<svg class="mute-icon unmuted" width="20" height="20" viewBox="0 0 24 24" fill="none"
xmlns="http://www.w3.org/2000/svg">
<path d="M11 5L6 9H2V15H6L11 19V5Z" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round" />
<path
d="M19.07 4.93C20.9437 6.80369 21.9991 9.34785 21.9991 12C21.9991 14.6522 20.9437 17.1963 19.07 19.07M15.54 8.46C16.4774 9.39764 17.0039 10.6692 17.0039 12C17.0039 13.3308 16.4774 14.6024 15.54 15.54"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<svg class="mute-icon muted" width="20" height="20" viewBox="0 0 24 24" fill="none"
xmlns="http://www.w3.org/2000/svg" style="display: none;">
<path d="M11 5L6 9H2V15H6L11 19V5Z" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round" />
<line x1="23" y1="9" x2="17" y2="15" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
<line x1="17" y1="9" x2="23" y2="15" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
</div>
</div>
<div class="timer-controls">
<button class="main-control-btn play-pause-btn">
<span class="btn-icon">▶</span>
<span class="btn-text">Start</span>
</button>
<button class="main-control-btn reset-btn">
<span class="btn-icon">↻</span>
<span class="btn-text">Reset</span>
</button>
</div>
</div>
</div>
<script>
const TEMPLATE_CONFIG = { "duration": 20, "mode": "timer" };
const TIMER_CONFIG = {
defaultInitialTime: TEMPLATE_CONFIG.duration || 20,
soundEnabled: true,
progressRingRadius: 90,
mode: TEMPLATE_CONFIG.mode || "timer"
};
class Timer {
constructor() {
this.mode = TIMER_CONFIG.mode;
this.initialTime = this.mode === 'stopwatch' ? 0 : TIMER_CONFIG.defaultInitialTime;
this.totalTime = this.initialTime;
this.currentTime = this.initialTime;
this.currentMilliseconds = 0;
this.lastUpdateTime = null;
this.isRunning = false;
this.isMuted = false;
this.interval = null;
this.initializeElements();
this.bindEvents();
this.updateDisplay();
this.updateMuteButtonVisibility();
}
initializeElements() {
this.display = document.querySelector( '.timer-display' );
this.playPauseBtn = document.querySelector( '.play-pause-btn' );
this.resetBtn = document.querySelector( '.reset-btn' );
this.muteBtn = document.querySelector( '.mute-btn' );
this.minutesInput = document.getElementById( 'minutes' );
this.secondsInput = document.getElementById( 'seconds' );
this.progressRing = document.querySelector( '.progress-ring-progress' );
this.circumference = 2 * Math.PI * TIMER_CONFIG.progressRingRadius;
this.progressRing.style.strokeDasharray = `0 ${this.circumference}`;
}
bindEvents() {
this.playPauseBtn.addEventListener( 'click', () => this.toggleTimer() );
this.resetBtn.addEventListener( 'click', () => this.resetTimer() );
this.muteBtn.addEventListener( 'click', () => this.toggleMute() );
this.display.addEventListener( 'click', () => this.makeEditable() );
this.display.addEventListener( 'blur', () => this.finishEditing() );
this.display.addEventListener( 'keydown', ( e ) => this.handleTimerInput( e ) );
this.display.addEventListener( 'input', ( e ) => this.handleTimerInputChange( e ) );
document.addEventListener( 'keydown', ( e ) => {
if ( e.code === 'Space' ) {
e.preventDefault();
this.toggleTimer();
} else if ( e.code === 'KeyR' ) {
this.resetTimer();
} else if ( e.code === 'KeyM' ) {
this.toggleMute();
}
} );
}
setTime( minutes, seconds ) {
if ( this.mode === 'stopwatch' ) {
return;
}
this.initialTime = minutes * 60 + seconds;
this.totalTime = this.initialTime;
this.currentTime = this.initialTime;
this.currentMilliseconds = 0;
this.updateDisplay();
this.updateProgress();
}
setCustomTime() {
const minutes = parseInt( this.minutesInput.value ) || 0;
const seconds = parseInt( this.secondsInput.value ) || 0;
this.setTime( minutes, seconds );
}
toggleTimer() {
if ( this.isRunning ) {
this.pauseTimer();
} else {
this.startTimer();
}
}
startTimer() {
this.isRunning = true;
this.lastUpdateTime = Date.now();
this.updatePlayPauseButton();
this.interval = setInterval( () => {
const now = Date.now();
const deltaTime = now - this.lastUpdateTime;
this.lastUpdateTime = now;
this.currentMilliseconds += deltaTime;
if ( this.currentMilliseconds >= 1000 ) {
const secondsElapsed = Math.floor( this.currentMilliseconds / 1000 );
this.currentMilliseconds = this.currentMilliseconds % 1000;
if ( this.mode === 'stopwatch' ) {
this.currentTime += secondsElapsed;
} else {
this.currentTime -= secondsElapsed;
if ( this.currentTime <= 0 ) {
this.currentTime = 0;
this.currentMilliseconds = 0;
this.updateDisplay();
this.updateProgress();
this.completeTimer();
return;
}
}
this.updateDisplay();
}
this.updateProgress();
}, 50 );
}
pauseTimer() {
this.isRunning = false;
clearInterval( this.interval );
this.updatePlayPauseButton();
}
resetTimer() {
this.pauseTimer();
this.currentTime = this.initialTime;
this.currentMilliseconds = 0;
this.updateDisplay();
this.updateProgress();
}
async completeTimer() {
if ( this.mode !== 'timer' ) return;
this.pauseTimer();
for ( let i = 0; i < 4; i++ ) {
await this.playSound();
this.progressRing.style.stroke = '#ea4335';
await new Promise( resolve => setTimeout( resolve, 1000 ) );
this.progressRing.style.stroke = '#4285f4';
}
}
updateDisplay() {
const minutes = Math.floor( Math.abs( this.currentTime ) / 60 );
const seconds = Math.abs( this.currentTime ) % 60;
this.display.value = `${minutes.toString().padStart( 2, '0' )}:${seconds.toString().padStart( 2, '0' )}`;
console.log( this.display.value );
}
updateProgress() {
let progress = 0;
if ( this.mode === 'stopwatch' ) {
const fractionalTime = this.currentTime + ( this.currentMilliseconds / 1000 );
const secondsInMinute = fractionalTime % 60;
progress = secondsInMinute / 60;
} else {
const fractionalTimeRemaining = Math.max( 0, this.currentTime - ( this.currentMilliseconds / 1000 ) );
progress = this.initialTime > 0 ? ( this.initialTime - fractionalTimeRemaining ) / this.initialTime : 0;
progress = Math.max( 0, Math.min( 1, progress ) );
}
const offset = this.circumference * progress;
this.progressRing.style.strokeDasharray = `${offset} ${this.circumference}`;
}
updatePlayPauseButton() {
const icon = this.playPauseBtn.querySelector( '.btn-icon' );
const text = this.playPauseBtn.querySelector( '.btn-text' );
if ( this.isRunning ) {
icon.textContent = '⏸';
text.textContent = 'Pause';
} else {
icon.textContent = '▶';
text.textContent = 'Start';
}
}
toggleMute() {
this.isMuted = !this.isMuted;
const unmutedIcon = this.muteBtn.querySelector( '.mute-icon.unmuted' );
const mutedIcon = this.muteBtn.querySelector( '.mute-icon.muted' );
if ( this.isMuted ) {
unmutedIcon.style.display = 'none';
mutedIcon.style.display = 'block';
} else {
unmutedIcon.style.display = 'block';
mutedIcon.style.display = 'none';
}
this.muteBtn.classList.toggle( 'muted', this.isMuted );
}
updateMuteButtonVisibility() {
if ( this.mode === 'stopwatch' ) {
this.muteBtn.style.display = 'none';
} else {
this.muteBtn.style.display = 'flex';
}
}
makeEditable() {
if ( this.mode === 'stopwatch' ) {
return;
}
this.wasRunningBeforeEdit = this.isRunning;
if ( this.isRunning ) {
this.pauseTimer();
}
this.display.removeAttribute( 'readonly' );
this.display.focus();
this.display.select();
this.editingDigits = this.display.value.replace( ':', '' ).padStart( 4, '0' );
}
handleTimerInput( e ) {
if ( this.display.hasAttribute( 'readonly' ) ) return;
if ( e.key === 'Enter' ) {
this.display.blur();
return;
}
if ( !/^\d$/.test( e.key ) &&
!['Backspace', 'Delete', 'ArrowLeft', 'ArrowRight', 'Tab', 'Escape'].includes( e.key ) ) {
e.preventDefault();
return;
}
if ( /^\d$/.test( e.key ) ) {
e.preventDefault();
this.addDigit( e.key );
} else if ( e.key === 'Backspace' ) {
e.preventDefault();
this.removeDigit();
} else if ( e.key === 'Escape' ) {
this.updateDisplay();
this.display.blur();
}
}
handleTimerInputChange( e ) {
e.preventDefault();
}
addDigit( digit ) {
this.editingDigits = this.editingDigits.slice( 1 ) + digit;
this.updateEditingDisplay();
}
removeDigit() {
this.editingDigits = '0' + this.editingDigits.slice( 0, 3 );
this.updateEditingDisplay();
}
updateEditingDisplay() {
const minutes = this.editingDigits.slice( 0, 2 );
const seconds = this.editingDigits.slice( 2, 4 );
this.display.value = `${minutes}:${seconds}`;
}
finishEditing() {
this.display.setAttribute( 'readonly', 'true' );
// Parse the entered time
const timeStr = this.display.value;
const timeRegex = /^(\d{1,2}):(\d{2})$/;
const match = timeStr.match( timeRegex );
if ( match ) {
const minutes = parseInt( match[1] );
const seconds = parseInt( match[2] );
if ( minutes >= 0 && minutes <= 99 && seconds >= 0 && seconds <= 59 ) {
this.setTime( minutes, seconds );
// Resume timer if it was running before editing
if ( this.wasRunningBeforeEdit && this.currentTime > 0 ) {
this.startTimer();
}
return;
}
}
// Invalid format, revert to current time
this.updateDisplay();
// Resume timer if it was running before editing
if ( this.wasRunningBeforeEdit && this.currentTime > 0 ) {
this.startTimer();
}
}
async playSound() {
if ( this.isMuted || this.mode === 'stopwatch' ) return;
const AudioCtx = window.AudioContext || window.webkitAudioContext;
const audioContext = new AudioCtx();
if ( audioContext.state === "suspended" ) {
try { await audioContext.resume(); } catch { }
}
const now = audioContext.currentTime;
const duration = 0.5; // total beep length
// --- Nodes ---
const envGain = audioContext.createGain(); // envelope gain (pre-compressor)
const compressor = audioContext.createDynamicsCompressor();
const postGain = audioContext.createGain(); // small makeup gain (post-compressor)
// Gentler compression (allows a touch more loudness)
compressor.threshold.setValueAtTime( -20, now ); // was -24
compressor.knee.setValueAtTime( 30, now );
compressor.ratio.setValueAtTime( 8, now ); // was 12
compressor.attack.setValueAtTime( 0.003, now );
compressor.release.setValueAtTime( 0.25, now );
// Routing: oscillators -> envGain -> compressor -> postGain -> destination
envGain.connect( compressor );
compressor.connect( postGain );
postGain.connect( audioContext.destination );
// Small overall boost (~+1.2 dB)
postGain.gain.setValueAtTime( 1.15, now );
// Envelope: slightly higher peak, same shape
envGain.gain.setValueAtTime( 0.0001, now );
envGain.gain.linearRampToValueAtTime( 1.1, now + 0.015 ); // was 1.0
envGain.gain.setValueAtTime( 1.1, now + 0.015 );
envGain.gain.linearRampToValueAtTime( 0.001, now + duration );
// Layered oscillators for fullness
const detunesInCents = [0, +6, -6];
const oscillators = detunesInCents.map( ( cents ) => {
const osc = audioContext.createOscillator();
osc.type = "sine";
osc.frequency.setValueAtTime( 800, now );
osc.frequency.setValueAtTime( 600, now + 0.1 );
osc.frequency.setValueAtTime( 800, now + 0.2 );
osc.detune.setValueAtTime( cents, now );
osc.connect( envGain );
return osc;
} );
oscillators.forEach( ( o ) => {
o.start( now );
o.stop( now + duration );
} );
}
}
// Initialize timer when page loads
document.addEventListener( 'DOMContentLoaded', () => {
new Timer();
} );
</script>
</body>
</html>