diff --git a/shared/version.js b/shared/version.js index cf37af8..bac821e 100644 --- a/shared/version.js +++ b/shared/version.js @@ -1,51 +1,51 @@ -export const APP_VERSION = "0.0.5"; - -export const getVersionLabel = (options = {}) => { - const prefix = typeof options.prefix === "string" ? options.prefix : "v"; - return `${prefix}${APP_VERSION}`; -}; - -const sharedVersionApi = { - APP_VERSION, - getVersionLabel -}; - -if (typeof globalThis !== "undefined") { - const existingInfo = globalThis.WorkoutTimeAppInfo ?? {}; - globalThis.WorkoutTimeAppInfo = { - ...existingInfo, - version: APP_VERSION, - getVersionLabel - }; -} - -if ( - typeof document !== "undefined" && - typeof document.dispatchEvent === "function" -) { - const eventDetail = { - version: APP_VERSION, - label: getVersionLabel({ prefix: "v" }) - }; - - try { - document.dispatchEvent( - new CustomEvent("workouttime:version-ready", { detail: eventDetail }) - ); - } catch (error) { - try { - const fallbackEvent = document.createEvent("CustomEvent"); - fallbackEvent.initCustomEvent( - "workouttime:version-ready", - false, - false, - eventDetail - ); - document.dispatchEvent(fallbackEvent); - } catch (fallbackError) { - document.dispatchEvent(new Event("workouttime:version-ready")); - } - } -} - -export default sharedVersionApi; +export const APP_VERSION = "0.0.6"; + +export const getVersionLabel = (options = {}) => { + const prefix = typeof options.prefix === "string" ? options.prefix : "v"; + return `${prefix}${APP_VERSION}`; +}; + +const sharedVersionApi = { + APP_VERSION, + getVersionLabel +}; + +if (typeof globalThis !== "undefined") { + const existingInfo = globalThis.WorkoutTimeAppInfo ?? {}; + globalThis.WorkoutTimeAppInfo = { + ...existingInfo, + version: APP_VERSION, + getVersionLabel + }; +} + +if ( + typeof document !== "undefined" && + typeof document.dispatchEvent === "function" +) { + const eventDetail = { + version: APP_VERSION, + label: getVersionLabel({ prefix: "v" }) + }; + + try { + document.dispatchEvent( + new CustomEvent("workouttime:version-ready", { detail: eventDetail }) + ); + } catch (error) { + try { + const fallbackEvent = document.createEvent("CustomEvent"); + fallbackEvent.initCustomEvent( + "workouttime:version-ready", + false, + false, + eventDetail + ); + document.dispatchEvent(fallbackEvent); + } catch (fallbackError) { + document.dispatchEvent(new Event("workouttime:version-ready")); + } + } +} + +export default sharedVersionApi; diff --git a/workout-time/AudioCue/01_repcount.mp3 b/workout-time/AudioCue/01_repcount.mp3 new file mode 100644 index 0000000..f5fe71c Binary files /dev/null and b/workout-time/AudioCue/01_repcount.mp3 differ diff --git a/workout-time/AudioCue/02_repcount.mp3 b/workout-time/AudioCue/02_repcount.mp3 new file mode 100644 index 0000000..69cb67c Binary files /dev/null and b/workout-time/AudioCue/02_repcount.mp3 differ diff --git a/workout-time/AudioCue/03_repcount.mp3 b/workout-time/AudioCue/03_repcount.mp3 new file mode 100644 index 0000000..581be02 Binary files /dev/null and b/workout-time/AudioCue/03_repcount.mp3 differ diff --git a/workout-time/AudioCue/04_repcount.mp3 b/workout-time/AudioCue/04_repcount.mp3 new file mode 100644 index 0000000..a95f763 Binary files /dev/null and b/workout-time/AudioCue/04_repcount.mp3 differ diff --git a/workout-time/AudioCue/05_repcount.mp3 b/workout-time/AudioCue/05_repcount.mp3 new file mode 100644 index 0000000..cf1f669 Binary files /dev/null and b/workout-time/AudioCue/05_repcount.mp3 differ diff --git a/workout-time/AudioCue/06_repcount.mp3 b/workout-time/AudioCue/06_repcount.mp3 new file mode 100644 index 0000000..6b61a80 Binary files /dev/null and b/workout-time/AudioCue/06_repcount.mp3 differ diff --git a/workout-time/AudioCue/07_repcount.mp3 b/workout-time/AudioCue/07_repcount.mp3 new file mode 100644 index 0000000..4a39d03 Binary files /dev/null and b/workout-time/AudioCue/07_repcount.mp3 differ diff --git a/workout-time/AudioCue/08_repcount.mp3 b/workout-time/AudioCue/08_repcount.mp3 new file mode 100644 index 0000000..dad9855 Binary files /dev/null and b/workout-time/AudioCue/08_repcount.mp3 differ diff --git a/workout-time/AudioCue/10_repcount.mp3 b/workout-time/AudioCue/10_repcount.mp3 new file mode 100644 index 0000000..7980659 Binary files /dev/null and b/workout-time/AudioCue/10_repcount.mp3 differ diff --git a/workout-time/AudioCue/11_repcount.mp3 b/workout-time/AudioCue/11_repcount.mp3 new file mode 100644 index 0000000..007ff3f Binary files /dev/null and b/workout-time/AudioCue/11_repcount.mp3 differ diff --git a/workout-time/AudioCue/12_repcount.mp3 b/workout-time/AudioCue/12_repcount.mp3 new file mode 100644 index 0000000..3803224 Binary files /dev/null and b/workout-time/AudioCue/12_repcount.mp3 differ diff --git a/workout-time/AudioCue/13_repcount.mp3 b/workout-time/AudioCue/13_repcount.mp3 new file mode 100644 index 0000000..3435e5c Binary files /dev/null and b/workout-time/AudioCue/13_repcount.mp3 differ diff --git a/workout-time/AudioCue/14_repcount.mp3 b/workout-time/AudioCue/14_repcount.mp3 new file mode 100644 index 0000000..eef8ff8 Binary files /dev/null and b/workout-time/AudioCue/14_repcount.mp3 differ diff --git a/workout-time/AudioCue/15_repcount.mp3 b/workout-time/AudioCue/15_repcount.mp3 new file mode 100644 index 0000000..495c7da Binary files /dev/null and b/workout-time/AudioCue/15_repcount.mp3 differ diff --git a/workout-time/AudioCue/16_repcount.mp3 b/workout-time/AudioCue/16_repcount.mp3 new file mode 100644 index 0000000..7a9f105 Binary files /dev/null and b/workout-time/AudioCue/16_repcount.mp3 differ diff --git a/workout-time/AudioCue/17_repcount.mp3 b/workout-time/AudioCue/17_repcount.mp3 new file mode 100644 index 0000000..41caf44 Binary files /dev/null and b/workout-time/AudioCue/17_repcount.mp3 differ diff --git a/workout-time/AudioCue/18_repcount.mp3 b/workout-time/AudioCue/18_repcount.mp3 new file mode 100644 index 0000000..0e40c04 Binary files /dev/null and b/workout-time/AudioCue/18_repcount.mp3 differ diff --git a/workout-time/AudioCue/19_repcount.mp3 b/workout-time/AudioCue/19_repcount.mp3 new file mode 100644 index 0000000..dd8e2f7 Binary files /dev/null and b/workout-time/AudioCue/19_repcount.mp3 differ diff --git a/workout-time/AudioCue/20_repcount.mp3 b/workout-time/AudioCue/20_repcount.mp3 new file mode 100644 index 0000000..155730e Binary files /dev/null and b/workout-time/AudioCue/20_repcount.mp3 differ diff --git a/workout-time/AudioCue/21_repcount.mp3 b/workout-time/AudioCue/21_repcount.mp3 new file mode 100644 index 0000000..2e4e725 Binary files /dev/null and b/workout-time/AudioCue/21_repcount.mp3 differ diff --git a/workout-time/AudioCue/22_repcount.mp3 b/workout-time/AudioCue/22_repcount.mp3 new file mode 100644 index 0000000..27cd635 Binary files /dev/null and b/workout-time/AudioCue/22_repcount.mp3 differ diff --git a/workout-time/AudioCue/23_repcount.mp3 b/workout-time/AudioCue/23_repcount.mp3 new file mode 100644 index 0000000..983677e Binary files /dev/null and b/workout-time/AudioCue/23_repcount.mp3 differ diff --git a/workout-time/AudioCue/24_repcount.mp3 b/workout-time/AudioCue/24_repcount.mp3 new file mode 100644 index 0000000..39bfcb9 Binary files /dev/null and b/workout-time/AudioCue/24_repcount.mp3 differ diff --git a/workout-time/AudioCue/25_repcount.mp3 b/workout-time/AudioCue/25_repcount.mp3 new file mode 100644 index 0000000..3abb88f Binary files /dev/null and b/workout-time/AudioCue/25_repcount.mp3 differ diff --git a/workout-time/AudioCue/Beast Mode.mp3 b/workout-time/AudioCue/Beast Mode.mp3 new file mode 100644 index 0000000..a2ca1ad Binary files /dev/null and b/workout-time/AudioCue/Beast Mode.mp3 differ diff --git a/workout-time/AudioCue/Calibrate your LIft.mp3 b/workout-time/AudioCue/Calibrate your LIft.mp3 new file mode 100644 index 0000000..5b95f0a Binary files /dev/null and b/workout-time/AudioCue/Calibrate your LIft.mp3 differ diff --git a/workout-time/AudioCue/Maxed Out.mp3 b/workout-time/AudioCue/Maxed Out.mp3 new file mode 100644 index 0000000..a68a821 Binary files /dev/null and b/workout-time/AudioCue/Maxed Out.mp3 differ diff --git a/workout-time/AudioCue/New Personal Record.mp3 b/workout-time/AudioCue/New Personal Record.mp3 new file mode 100644 index 0000000..5dbacf5 Binary files /dev/null and b/workout-time/AudioCue/New Personal Record.mp3 differ diff --git a/workout-time/AudioCue/Start Lifting.mp3 b/workout-time/AudioCue/Start Lifting.mp3 new file mode 100644 index 0000000..f8167cf Binary files /dev/null and b/workout-time/AudioCue/Start Lifting.mp3 differ diff --git a/workout-time/AudioCue/Strength Unlocked.mp3 b/workout-time/AudioCue/Strength Unlocked.mp3 new file mode 100644 index 0000000..82343be Binary files /dev/null and b/workout-time/AudioCue/Strength Unlocked.mp3 differ diff --git a/workout-time/AudioCue/The Grind Continues.mp3 b/workout-time/AudioCue/The Grind Continues.mp3 new file mode 100644 index 0000000..26e7ba5 Binary files /dev/null and b/workout-time/AudioCue/The Grind Continues.mp3 differ diff --git a/workout-time/AudioCue/You just BROKE your LIMITS.mp3 b/workout-time/AudioCue/You just BROKE your LIMITS.mp3 new file mode 100644 index 0000000..749b432 Binary files /dev/null and b/workout-time/AudioCue/You just BROKE your LIMITS.mp3 differ diff --git a/workout-time/AudioCue/crowd cheering.mp3 b/workout-time/AudioCue/crowd cheering.mp3 new file mode 100644 index 0000000..89dbccb Binary files /dev/null and b/workout-time/AudioCue/crowd cheering.mp3 differ diff --git a/workout-time/app.js b/workout-time/app.js index 214b871..fa91964 100644 --- a/workout-time/app.js +++ b/workout-time/app.js @@ -167,6 +167,7 @@ class VitruvianApp { this.registerDeviceListeners(); this.setupChart(); this.setupUnitControls(); + this.initializeAudioToggle(); this.planItems = []; // array of {type: 'exercise'|'echo', fields...} this.planActive = false; // true when plan runner is active this.planCursor = { index: 0, set: 1 }; // current item & set counter @@ -939,6 +940,11 @@ class VitruvianApp { ) { this.currentWorkout.startTime = new Date(); this.addLogEntry("Workout timer started at first warmup rep", "info"); + try { + if (this.isAudioTriggersEnabled()) { + this.playAudio("calibrateLift").catch(() => {}); + } + } catch (e) {} } } @@ -1914,6 +1920,11 @@ class VitruvianApp { }.`, "success", ); + try { + if (this.isAudioTriggersEnabled()) { + this.playAudio("strengthUnlocked").catch(() => {}); + } + } catch (e) {} } return updated; @@ -1955,6 +1966,11 @@ class VitruvianApp { }.`, "success", ); + try { + if (this.isAudioTriggersEnabled()) { + this.playAudio("strengthUnlocked").catch(() => {}); + } + } catch (e) {} } return updated; @@ -2178,6 +2194,12 @@ class VitruvianApp { this._planSummaryOverlay.classList.add("is-visible"); this._planSummaryOverlay.setAttribute("aria-hidden", "false"); + try { + if (this.isAudioTriggersEnabled()) { + this.playAudio("crowdCheer").catch(() => {}); + } + } catch (e) {} + const closeBtn = document.getElementById("planSummaryCloseBtn"); closeBtn?.focus({ preventScroll: true }); this.preparePlanSummaryAdjustments(this._planSummaryDisplayUnit); @@ -6604,6 +6626,11 @@ class VitruvianApp { let prInfo = null; if (storedWorkout) { prInfo = this.displayTotalLoadPR(storedWorkout); + try { + if (this.isAudioTriggersEnabled() && prInfo?.status === "new") { + this.playAudio("newPersonalRecord").catch(() => {}); + } + } catch (e) {} } else { this.hidePRBanner(); } @@ -6648,6 +6675,18 @@ class VitruvianApp { .catch((error) => { this.addLogEntry(`Failed to auto-save to Dropbox: ${error.message}`, "error"); }); + + // If enabled, play a 'maxed out' audio cue when a single-cable peak is very high + try { + if (this.isAudioTriggersEnabled() && storedWorkout) { + const peakKg = Number(storedWorkout.cablePeakKg || 0) || Number(this.calculateTotalLoadPeakKg(storedWorkout) || 0); + if (Number.isFinite(peakKg) && peakKg >= 95) { + this.playAudio("maxedOut").catch(() => {}); + } + } + } catch (e) { + // ignore + } } if (!isSkipped && this.planActive && completedPlanEntry) { @@ -7172,6 +7211,214 @@ class VitruvianApp { } } + /* Audio triggers manager + * - Toggle persisted in localStorage `vitruvian.audioTriggersEnabled` + * - `playAudio(key)` attempts to play a mapped file from `AudioCue/` + * - Falls back to existing oscillator beep behavior when file playback fails. + */ + isAudioTriggersEnabled() { + try { + const raw = localStorage.getItem("vitruvian.audioTriggersEnabled"); + return raw === "true"; + } catch { + return false; + } + } + + setAudioTriggersEnabled(enabled) { + try { + localStorage.setItem("vitruvian.audioTriggersEnabled", enabled ? "true" : "false"); + } catch {} + const btn = document.getElementById("audioTriggersToggle"); + if (btn) { + btn.setAttribute("aria-pressed", enabled ? "true" : "false"); + // reflect persistent visual state + if (enabled) { + btn.classList.add("is-active"); + } else { + btn.classList.remove("is-active"); + } + } + } + + initializeAudioToggle() { + const btn = document.getElementById("audioTriggersToggle"); + if (!btn) return; + const enabled = this.isAudioTriggersEnabled(); + btn.setAttribute("aria-pressed", enabled ? "true" : "false"); + if (enabled) btn.classList.add("is-active"); + else btn.classList.remove("is-active"); + btn.addEventListener("click", () => { + const newVal = !this.isAudioTriggersEnabled(); + this.setAudioTriggersEnabled(newVal); + if (newVal) { + this.tryResumeAudioContext(); + // Small async preload after a user gesture to warm up assets and reduce latency. + setTimeout(() => { + try { + this.preloadAudioCueAssets(); + } catch (e) { + // ignore preload failures + } + }, 50); + } + }); + } + + // Simple audio cache / player for AudioCue assets. + _audioCache = new Map(); + + async playAudio(key, options = {}) { + try { + if (!this.isAudioTriggersEnabled()) return false; + + const mapping = { + newPersonalRecord: "New Personal Record.mp3", + maxedOut: "Maxed Out.mp3", + beastMode: "Beast Mode.mp3", + calibrateLift: "Calibrate your Lift.mp3", + strengthUnlocked: "Strength Unlocked.mp3", + startLifting: "Start Lifting.mp3", + crowdCheer: "crowd cheering.mp3", + grindContinues: "The Grind Continues.mp3", + // repcount files: e.g. `1_repcount.mp3`, `01_repcount.mp3` + repcount: (rep) => `${rep}_repcount.mp3`, + }; + + let filename = null; + if (Object.prototype.hasOwnProperty.call(mapping, key)) { + const val = mapping[key]; + filename = typeof val === "function" ? val(options.rep || 0) : val; + } else { + // allow caller to pass direct filename + filename = key; + } + + if (!filename) return false; + + // For repcount files try zero-padded and non-padded .mp3 variants (e.g. '01_repcount.mp3', '1_repcount.mp3') + const candidates = []; + if (key === "repcount") { + const repNum = Number(options.rep || 0) || 0; + const pad2 = repNum.toString().padStart(2, "0"); + candidates.push(`${pad2}_repcount.mp3`); + candidates.push(`${repNum}_repcount.mp3`); + } else { + candidates.push(filename); + } + + // Try candidates sequentially until one plays + this.tryResumeAudioContext(); + for (const candidate of candidates) { + const src = `AudioCue/${candidate}`; + try { + let audio = this._audioCache.get(src); + if (!audio) { + audio = new Audio(src); + audio.preload = "auto"; + this._audioCache.set(src, audio); + } + // Reset currentTime to 0 to allow rapid replays of the same audio element + audio.currentTime = 0; + await audio.play(); + return true; + } catch (err) { + // Try next candidate + continue; + } + } + + return false; + } catch (err) { + // Silent failure: audio not found or blocked. Return false so caller can fallback. + return false; + } + } + + // Preload a list of audio filenames into the audio cache to reduce latency. + // Accepts filenames relative to the `AudioCue/` folder. + preloadAudioAssets(filenames = []) { + if (!Array.isArray(filenames) || filenames.length === 0) { + return Promise.resolve([]); + } + + const tasks = []; + for (const filename of filenames) { + if (!filename) continue; + const src = `AudioCue/${filename}`; + if (this._audioCache.has(src)) { + continue; // already cached + } + + try { + const audio = new Audio(src); + audio.preload = "auto"; + this._audioCache.set(src, audio); + + const p = new Promise((resolve) => { + const onReady = () => { + cleanup(); + resolve({ src, status: "ok" }); + }; + const onError = () => { + cleanup(); + resolve({ src, status: "error" }); + }; + const cleanup = () => { + audio.removeEventListener("canplaythrough", onReady); + audio.removeEventListener("loadedmetadata", onReady); + audio.removeEventListener("error", onError); + }; + + audio.addEventListener("canplaythrough", onReady, { once: true }); + audio.addEventListener("loadedmetadata", onReady, { once: true }); + audio.addEventListener("error", onError, { once: true }); + // Kick off loading + try { + audio.load(); + } catch (e) { + // ignore + resolve({ src, status: "error" }); + } + }); + + tasks.push(p); + } catch (err) { + // ignore single failures + } + } + + return Promise.allSettled(tasks).then((results) => results.map((r) => (r.status === "fulfilled" ? r.value : { status: "error" }))); + } + + // Preload the commonly used cue files and a range of repcount files. + preloadAudioCueAssets() { + if (this._preloadInFlight) return this._preloadInFlight; + const baseFiles = [ + "New Personal Record.mp3", + "Maxed Out.mp3", + "Beast Mode.mp3", + "Calibrate your Lift.mp3", + "Strength Unlocked.mp3", + "Start Lifting.mp3", + "crowd cheering.mp3", + "The Grind Continues.mp3", + ]; + + // Preload repcount files for 1..25 (mp3, padded and non-padded) + const repCandidates = new Set(); + for (let i = 1; i <= 25; i++) { + repCandidates.add(`${i}_repcount.mp3`); + repCandidates.add(`${i.toString().padStart(2, "0")}_repcount.mp3`); + } + + const all = baseFiles.concat(Array.from(repCandidates)); + this._preloadInFlight = this.preloadAudioAssets(all).finally(() => { + this._preloadInFlight = null; + }); + return this._preloadInFlight; + } + getAudioContext() { try { const AudioContextClass = @@ -7445,21 +7692,59 @@ class VitruvianApp { } this._lastRepTopBeep = now; - const oscillator = context.createOscillator(); - const gain = context.createGain(); + // Determine if we're in warmup or working reps. + // During warmup reps, always use oscillator beep. + // During working reps, try repcount audio file first, then fallback to oscillator. + const currentWarmupReps = Number(this.warmupReps) || 0; + const currentWorkingReps = Number(this.workingReps) || 0; + const totalSoFar = currentWarmupReps + currentWorkingReps; + const nextRepOverall = totalSoFar + 1; + const isWarmupRep = Number.isFinite(this.warmupTarget) && nextRepOverall <= (Number(this.warmupTarget) || 0); + const nextRepCount = currentWorkingReps + 1; + + // Helper to play oscillator beep + const playOscillatorBeep = () => { + try { + const oscillator = context.createOscillator(); + const gain = context.createGain(); + + oscillator.type = "triangle"; + oscillator.frequency.setValueAtTime(880, now); - oscillator.type = "triangle"; - oscillator.frequency.setValueAtTime(880, now); + gain.gain.setValueAtTime(0.0001, now); + gain.gain.exponentialRampToValueAtTime(0.25, now + 0.01); + gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.25); - gain.gain.setValueAtTime(0.0001, now); - gain.gain.exponentialRampToValueAtTime(0.25, now + 0.01); - gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.25); + oscillator.connect(gain); + gain.connect(context.destination); - oscillator.connect(gain); - gain.connect(context.destination); + oscillator.start(now); + oscillator.stop(now + 0.3); + } catch (err) { + // ignore + } + }; - oscillator.start(now); - oscillator.stop(now + 0.3); + // If warmup, always use beep. If working, try repcount audio first. + if (isWarmupRep) { + playOscillatorBeep(); + } else { + // Working reps: try repcount audio file (1-25), fallback to beep + if (nextRepCount >= 1 && nextRepCount <= 25 && this.isAudioTriggersEnabled()) { + this.playAudio("repcount", { rep: nextRepCount }) + .then((played) => { + if (!played) { + playOscillatorBeep(); + } + }) + .catch(() => { + playOscillatorBeep(); + }); + } else { + // Audio disabled, out of range, or warmup: use beep + playOscillatorBeep(); + } + } } catch (error) { // Silently ignore audio failures to avoid spamming logs } @@ -7613,6 +7898,11 @@ class VitruvianApp { // Record when warmup ends (last warmup rep complete) if (this.warmupReps === this.warmupTarget && this.currentWorkout && !this.currentWorkout.warmupEndTime) { this.currentWorkout.warmupEndTime = new Date(); + try { + if (this.isAudioTriggersEnabled()) { + this.playAudio("startLifting").catch(() => {}); + } + } catch (e) {} } } else { // Working reps @@ -7871,6 +8161,18 @@ class VitruvianApp { await this.device.startProgram(params); + // If audio triggers enabled, play beast-mode announcement for TUT Beast + try { + if (this.isAudioTriggersEnabled() && typeof ProgramMode !== "undefined") { + if (Number(params.baseMode) === ProgramMode.TUT_BEAST) { + // best-effort: play beast audio, ignore failures + this.playAudio("beastMode").catch(() => {}); + } + } + } catch (e) { + // ignore + } + // Update stop button state this.updateStopButtonState(); diff --git a/workout-time/index.html b/workout-time/index.html index 0e1a09d..1ed4f13 100644 --- a/workout-time/index.html +++ b/workout-time/index.html @@ -491,13 +491,15 @@ .plan-section-header { position: relative; display: grid; - grid-template-columns: 1fr auto; + grid-template-columns: 1fr auto auto; + grid-template-rows: auto auto; grid-template-areas: - "spacer theme" - "start units"; - gap: 12px 24px; + "spacer theme controls" + "start units units"; + gap: 12px 12px; align-items: start; margin-bottom: 15px; + min-width: 0; } .plan-section-header__spacer { @@ -509,22 +511,25 @@ .plan-section-header__start { grid-area: start; display: flex; - flex-direction: column; + flex-direction: row; gap: 6px; - align-items: flex-start; - justify-self: stretch; - justify-content: flex-end; + align-items: center; + justify-self: start; + min-width: 0; } .plan-section-header__start .button-pulse { width: auto; + white-space: nowrap; + flex-shrink: 0; } .plan-section-header__control { display: flex; - flex-direction: column; + flex-direction: row; gap: 6px; - align-items: flex-end; + align-items: center; + min-width: 0; } .plan-section-header__control-label { @@ -536,11 +541,18 @@ .plan-section-header__control--theme { grid-area: theme; + flex-direction: row; + align-items: center; + gap: 0; + justify-self: start; } .plan-section-header__control--units { grid-area: units; justify-self: end; + flex-direction: row; + align-items: center; + flex-shrink: 0; } .plan-section-header__control--units .unit-toggle-hint { @@ -765,6 +777,7 @@ transparent 40% ); animation: buttonPulse 1.8s ease-in-out infinite; + white-space: nowrap; } .button-pulse:disabled { @@ -1170,6 +1183,7 @@ border: 2px solid var(--divider-color); border-radius: 999px; box-shadow: none; + white-space: nowrap; } .theme-toggle-btn:hover:not(:disabled) { @@ -1187,6 +1201,14 @@ background: rgba(143, 162, 255, 0.12); } + /* Persistent active state for audio toggle */ + .theme-toggle-btn.is-active { + border-color: var(--accent-color); + color: var(--accent-color); + background: rgba(143, 162, 255, 0.12); + box-shadow: 0 6px 20px rgba(102, 126, 234, 0.22); + } + .unit-toggle-button { display: flex; align-items: center; @@ -3320,6 +3342,16 @@