From 2f3e5540c8f6536e579fdbe5463af05b2aaa70d8 Mon Sep 17 00:00:00 2001 From: wmj Date: Mon, 24 Aug 2026 11:30:36 +0800 Subject: [PATCH] fix: eliminate React re-renders from 250ms audio interval MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove currentTime from useState entirely - Progress bars updated via direct DOM ref (ref.current.style.width) - Timestamps updated via direct DOM ref (ref.current.textContent) - setInterval now does 0 setState calls — no re-renders during playback - Play/Pause/Skip buttons stay completely stable in DOM at all times - Fixes Playwright 'element not stable' failures for Pause + Skip Next --- src/App.jsx | 424 +++++++++++++++++++--------------------------------- 1 file changed, 156 insertions(+), 268 deletions(-) diff --git a/src/App.jsx b/src/App.jsx index cb8edad..f769528 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -43,47 +43,25 @@ Rules: MOCK DATA ========================================================================== */ -// Public domain / freely licensed audio samples used as briefing placeholders const MOCK_AUDIO_TRACKS = [ { - id: 1, - title: 'The 100-Day Turnaround', - duration: '14:20', - tag: 'Strategy', - author: 'Senior Partner A', - // Web Speech API script (real TTS audio) + id: 1, title: 'The 100-Day Turnaround', duration: '14:20', tag: 'Strategy', author: 'Senior Partner A', ttsScript: `Welcome to the 100-Day Turnaround briefing. In a private equity turnaround, the first 100 days are existential. You must identify the three core value levers: cost, revenue, and working capital. In the first two weeks, conduct a rapid diagnostic: interview 20 key stakeholders, map the P&L to operational drivers, and identify the top 5 EBITDA improvement opportunities. By day 30, present a fully costed transformation roadmap with clear accountability. Prioritise quick wins — at least 3 million in annualised savings within 60 days — to build credibility with the board. The cardinal rule: never surprise your sponsor. Weekly steering updates, always leading with financial metrics. Thank you for listening.`, }, { - id: 2, - title: 'Negotiating with Hostile Boards', - duration: '22:15', - tag: 'Leadership', - author: 'Principal B', - ttsScript: `This briefing covers negotiating with hostile boards. A hostile board is not an obstacle — it is a data source. Their aggression tells you exactly where the credibility gap is. Rule one: never walk into a board session without pre-reads reviewed by at least 2 board members in advance. Rule two: lead with the number they care most about — usually free cash flow or EBITDA margin. Rule three: when challenged, do not defend your analysis. Reframe. Ask: "What evidence would change your view?" This turns a confrontation into a collaborative hypothesis. Always close with a decision matrix, not a recommendation. Give them the power to choose — between your option and an option you've already stress-tested as inferior. That's the Falah method for hostile board navigation.`, + id: 2, title: 'Negotiating with Hostile Boards', duration: '22:15', tag: 'Leadership', author: 'Principal B', + ttsScript: `This briefing covers negotiating with hostile boards. A hostile board is not an obstacle — it is a data source. Their aggression tells you exactly where the credibility gap is. Rule one: never walk into a board session without pre-reads reviewed by at least 2 board members in advance. Rule two: lead with the number they care most about — usually free cash flow or EBITDA margin. Rule three: when challenged, do not defend your analysis. Reframe. Ask: What evidence would change your view? This turns a confrontation into a collaborative hypothesis. Always close with a decision matrix, not a recommendation. Give them the power to choose — between your option and an option you've already stress-tested as inferior. That's the Falah method for hostile board navigation.`, }, { - id: 3, - title: 'M&A Synergy Identification', - duration: '18:45', - tag: 'Finance', - author: 'Director C', - ttsScript: `M&A synergy identification is where most deals destroy value. Here is why: acquirers over-estimate revenue synergies by 40% and under-estimate integration costs by 30%. Our Falah framework starts with a synergy taxonomy: cost synergies, revenue synergies, and financial synergies. Cost synergies — headcount, procurement, and real estate — are the only ones you should bank on in year one. Revenue synergies require market overlap, sales force alignment, and at least 18 months of integration. The critical discipline is the synergy validation session: every synergy claim must be signed off by the business unit owner, not the deal team. If they won't sign, cut it from the model. Underpromise and overdeliver. That is the path to a successful integration.`, + id: 3, title: 'M&A Synergy Identification', duration: '18:45', tag: 'Finance', author: 'Director C', + ttsScript: `M&A synergy identification is where most deals destroy value. Acquirers over-estimate revenue synergies by 40% and under-estimate integration costs by 30%. Our Falah framework starts with a synergy taxonomy: cost synergies, revenue synergies, and financial synergies. Cost synergies — headcount, procurement, and real estate — are the only ones you should bank on in year one. Revenue synergies require market overlap, sales force alignment, and at least 18 months of integration. The critical discipline is the synergy validation session: every synergy claim must be signed off by the business unit owner, not the deal team. If they won't sign, cut it from the model. Underpromise and overdeliver. That is the path to a successful integration.`, }, { - id: 4, - title: 'Digital Transformation Pitfalls', - duration: '31:10', - tag: 'Technology', - author: 'Principal D', - ttsScript: `Digital transformation fails 70% of the time. The cause is almost never technology. It is governance, change management, and unclear ROI definition. Pitfall one: starting with technology before defining the business problem. Always begin with the customer journey or the operational bottleneck. Pitfall two: underinvesting in change management. Allocate at least 20% of your transformation budget to people and process. Pitfall three: measuring outputs instead of outcomes. Replace "number of APIs deployed" with "reduction in order-to-cash cycle time." The Falah digital transformation framework has three phases: diagnose, design, and deploy. In diagnose, map every process that touches the customer. In design, identify the minimum viable change to deliver maximum impact. In deploy, run parallel operations until the new system proves reliability. Thank you.`, + id: 4, title: 'Digital Transformation Pitfalls', duration: '31:10', tag: 'Technology', author: 'Principal D', + ttsScript: `Digital transformation fails 70% of the time. The cause is almost never technology. It is governance, change management, and unclear ROI definition. Pitfall one: starting with technology before defining the business problem. Always begin with the customer journey or the operational bottleneck. Pitfall two: underinvesting in change management. Allocate at least 20% of your transformation budget to people and process. Pitfall three: measuring outputs instead of outcomes. Replace number of APIs deployed with reduction in order-to-cash cycle time. The Falah digital transformation framework has three phases: diagnose, design, and deploy. In diagnose, map every process that touches the customer. In design, identify the minimum viable change to deliver maximum impact. In deploy, run parallel operations until the new system proves reliability.`, }, { - id: 5, - title: 'Pricing Strategies in Inflation', - duration: '12:55', - tag: 'Economics', - author: 'Partner E', + id: 5, title: 'Pricing Strategies in Inflation', duration: '12:55', tag: 'Economics', author: 'Partner E', ttsScript: `Pricing in an inflationary environment requires a fundamental shift in mindset. Reactive pricing — simply passing on cost increases — destroys customer relationships. Proactive pricing — anticipating inflation and embedding flexibility in contracts — preserves margin and trust. There are three strategies we deploy at Falah. First, value-based pricing: anchor your price to the economic value you deliver, not your cost base. Second, dynamic pricing tiers: create good, better, best options so customers self-select, protecting your premium tier. Third, contract architecture: build in annual CPI-linked adjustments so price increases feel automatic, not adversarial. The bottom line: every 1% improvement in price realisation drops directly to EBITDA. Pricing is the highest-leverage tool in your margin toolkit.`, }, ]; @@ -106,18 +84,12 @@ function formatTime(secs) { } /* ========================================================================== - AI CO-PILOT VIEW — Gemini 2.0 Flash + AI CO-PILOT — Gemini 2.5 Flash ========================================================================== */ const INITIAL_MESSAGES = [ - { - role: 'system', - text: 'Falah Simulator initialized. Persona: Hostile Telco CIO. Objective: Justify £2M Agile Transformation.', - }, - { - role: 'assistant', - text: "I don't have time for buzzwords today. You want £2M of my budget to \"transform\" teams that are already delivering. Where is the hard financial justification? I need EBITDA impact, not velocity charts.", - }, + { role: 'system', text: 'Falah Simulator initialized. Persona: Hostile Telco CIO. Objective: Justify £2M Agile Transformation.' }, + { role: 'assistant', text: "I don't have time for buzzwords today. You want £2M of my budget to \"transform\" teams that are already delivering. Where is the hard financial justification? I need EBITDA impact, not velocity charts." }, ]; const AICoPilotView = () => { @@ -131,48 +103,36 @@ const AICoPilotView = () => { chatEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages, loading]); - const buildGeminiPayload = (msgs) => { - // Convert our message history into Gemini format - const contents = msgs + const buildPayload = (msgs) => ({ + systemInstruction: { parts: [{ text: AI_SYSTEM_PROMPT }] }, + contents: msgs .filter((m) => m.role !== 'system') - .map((m) => ({ - role: m.role === 'assistant' ? 'model' : 'user', - parts: [{ text: m.text }], - })); - return { - systemInstruction: { parts: [{ text: AI_SYSTEM_PROMPT }] }, - contents, - generationConfig: { temperature: 0.85, maxOutputTokens: 256 }, - }; - }; + .map((m) => ({ role: m.role === 'assistant' ? 'model' : 'user', parts: [{ text: m.text }] })), + generationConfig: { temperature: 0.85, maxOutputTokens: 256 }, + }); const handleSend = async (e) => { e?.preventDefault(); const trimmed = input.trim(); if (!trimmed || loading) return; - - const newMessages = [...messages, { role: 'user', text: trimmed }]; - setMessages(newMessages); + const next = [...messages, { role: 'user', text: trimmed }]; + setMessages(next); setInput(''); setLoading(true); setError(''); - try { const res = await fetch(GEMINI_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(buildGeminiPayload(newMessages)), + body: JSON.stringify(buildPayload(next)), }); - if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err?.error?.message || `HTTP ${res.status}`); } - const data = await res.json(); const reply = data?.candidates?.[0]?.content?.parts?.[0]?.text?.trim(); if (!reply) throw new Error('Empty response from Gemini'); - setMessages((prev) => [...prev, { role: 'assistant', text: reply }]); } catch (err) { setError(`AI error: ${err.message}`); @@ -181,17 +141,12 @@ const AICoPilotView = () => { } }; - const handleChip = (cmd) => { - setInput(cmd); - }; - return (

AI Co-Pilot & Simulator

Engage in high-pressure executive roleplay to refine your delivery.

-
{messages.map((msg, i) => ( @@ -202,11 +157,9 @@ const AICoPilotView = () => {
)}
{msg.text}
@@ -217,54 +170,33 @@ const AICoPilotView = () => { )}
))} - {loading && (
-
- -
+
Thinking...
)} - - {error && ( -
- {error} -
- )} - + {error &&
{error}
}
-
{['/simulate Telco CIO', '/mentor DevSecOps', '/grade'].map((cmd) => ( - ))}
- setInput(e.target.value)} - placeholder="Deploy a response or command..." - disabled={loading} - className="w-full bg-slate-950 border border-slate-700 rounded-xl px-4 py-3 md:py-4 pr-12 text-slate-200 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 transition-all placeholder:text-slate-500 disabled:opacity-50 text-sm md:text-base" - /> -
@@ -286,16 +218,11 @@ const AudioLibraryView = ({ setActiveTrack }) => (
{MOCK_AUDIO_TRACKS.map((track) => ( -
setActiveTrack(track)} - className="group bg-slate-900 border border-slate-800 hover:border-emerald-500/50 rounded-2xl p-5 md:p-6 cursor-pointer transition-all hover:shadow-lg hover:shadow-emerald-500/5 relative overflow-hidden" - > +
setActiveTrack(track)} + className="group bg-slate-900 border border-slate-800 hover:border-emerald-500/50 rounded-2xl p-5 md:p-6 cursor-pointer transition-all hover:shadow-lg hover:shadow-emerald-500/5">
- - {track.tag} - -
+ {track.tag} +
@@ -323,39 +250,30 @@ const MissionControlView = ({ setActiveView, setActiveTrack }) => (

Welcome back. Your Falah Coach portal is ready.

- {/* Featured briefing */}
- - Featured Briefing + Featured Briefing

{MOCK_AUDIO_TRACKS[0].title}

Master the critical first 100 days of a private equity backed turnaround. Tactical insights from our top Senior Partners.

-
- - {/* Quick-start cards */}
{[ { label: 'AI Simulator', desc: 'Practice hostile boardroom scenarios.', view: 'simulator', icon: MessageSquareQuote }, { label: 'Loop Wiki', desc: 'Review the Falah MECE Guidelines.', view: 'wiki', icon: BookOpen }, ].map(({ label, desc, view, icon: Icon }) => ( -
setActiveView(view)} - className="bg-slate-900 border border-slate-800 hover:border-blue-500/40 rounded-2xl p-5 md:p-6 cursor-pointer transition-all hover:shadow-lg hover:shadow-blue-500/5 flex-1 flex flex-col justify-between" - > +
setActiveView(view)} + className="bg-slate-900 border border-slate-800 hover:border-blue-500/40 rounded-2xl p-5 md:p-6 cursor-pointer transition-all hover:shadow-lg hover:shadow-blue-500/5 flex-1 flex flex-col justify-between">
@@ -393,13 +311,9 @@ const MSLoopWikiView = () => {
{dir.items.map((item) => ( - ))}
@@ -410,9 +324,7 @@ const MSLoopWikiView = () => {
-
-
-
+
loop.microsoft.com/falah/{activePage.toLowerCase().replace(/ /g, '-')} @@ -421,26 +333,24 @@ const MSLoopWikiView = () => {

{activePage}

-
-

This document serves as the canonical reference for exactly how our consultants should approach problem structuring in this domain.

-
-
-

Key Objective

-

Drive unambiguous clarity to executive stakeholders within 3 minutes of engagement.

-
-
-

Success Metric

-

Achieve client alignment before the first break in the agenda.

-
+

This document serves as the canonical reference for exactly how our consultants should approach problem structuring in this domain.

+
+
+

Key Objective

+

Drive unambiguous clarity to executive stakeholders within 3 minutes of engagement.

+
+
+

Success Metric

+

Achieve client alignment before the first break in the agenda.

-

1. Executive Summary

-

The imperative here is not just structural elegance, but actionable intelligence. When framing the issue, prioritise the financial delta and the organisational capacity required to capture it.

-
    -
  • Identify the core constraint (Capital, Talent, or Regulatory).
  • -
  • Map the adjacent capabilities required.
  • -
  • Draft the 100-day execution roadmap.
  • -
+

1. Executive Summary

+

The imperative here is not just structural elegance, but actionable intelligence. When framing the issue, prioritise the financial delta and the organisational capacity required to capture it.

+
    +
  • Identify the core constraint (Capital, Talent, or Regulatory).
  • +
  • Map the adjacent capabilities required.
  • +
  • Draft the 100-day execution roadmap.
  • +
@@ -450,27 +360,45 @@ const MSLoopWikiView = () => { }; /* ========================================================================== - MAIN APP SHELL — with real HTML5 audio player + MAIN APP — Audio state managed with refs to avoid constant re-renders ========================================================================== */ export default function App() { - const [activeView, setActiveView] = useState('missionControl'); - const [activeTrack, setActiveTrack] = useState(null); // currently loaded track object - const [trackIndex, setTrackIndex] = useState(0); // index into MOCK_AUDIO_TRACKS - const [isPlaying, setIsPlaying] = useState(false); - const [currentTime, setCurrentTime] = useState(0); - const [duration, setDuration] = useState(0); - const [sidebarOpen, setSidebarOpen] = useState(false); - const [ttsLoading, setTtsLoading] = useState(false); + // ── React state — only what MUST cause re-renders ──────────────────────── + const [activeView, setActiveView] = useState('missionControl'); + const [activeTrack, setActiveTrack] = useState(null); // track object | null + const [trackIndex, setTrackIndex] = useState(0); + const [isPlaying, setIsPlaying] = useState(false); + const [sidebarOpen, setSidebarOpen] = useState(false); - // We use Web Speech API for TTS - const utteranceRef = useRef(null); - const ttsIntervalRef = useRef(null); - const ttsStartTimeRef = useRef(0); // Date.now() when speech started - const ttsElapsedRef = useRef(0); // seconds elapsed before last pause + // ── Refs — updated without triggering re-renders ───────────────────────── + const utteranceRef = useRef(null); + const ttsIntervalRef = useRef(null); + const ttsStartTimeRef = useRef(0); + const ttsElapsedRef = useRef(0); + const durationRef = useRef(0); // estimated total duration (seconds) + const activeTrackRef = useRef(null); // mirrors activeTrack + + // DOM refs for live progress updates (direct DOM writes — no re-render) + const desktopProgressRef = useRef(null); // sidebar progress bar fill + const mobileProgressRef = useRef(null); // mobile strip progress bar fill + const desktopTimestampRef = useRef(null); // "00:25 / 14:20" + const mobileTimestampRef = useRef(null); // mobile "Now Playing · 00:25" + + // ── Direct DOM update helper ───────────────────────────────────────────── + const updateProgressDOM = useCallback((elapsed) => { + const dur = durationRef.current; + const pct = dur > 0 ? Math.min((elapsed / dur) * 100, 100) : 0; + const tStr = formatTime(elapsed); + const track = activeTrackRef.current; + + if (desktopProgressRef.current) desktopProgressRef.current.style.width = `${pct}%`; + if (mobileProgressRef.current) mobileProgressRef.current.style.width = `${pct}%`; + if (desktopTimestampRef.current) desktopTimestampRef.current.textContent = `${tStr} / ${track?.duration ?? ''}`; + if (mobileTimestampRef.current) mobileTimestampRef.current.textContent = tStr; + }, []); // ── TTS helpers ────────────────────────────────────────────────────────── - const stopTTS = useCallback(() => { window.speechSynthesis.cancel(); clearInterval(ttsIntervalRef.current); @@ -481,89 +409,63 @@ export default function App() { if (!track?.ttsScript) return; stopTTS(); - // Estimate total duration from char count (~150 WPM, ~5 chars/word) const estimatedDuration = track.ttsScript.length / 12.5; - setDuration(estimatedDuration); + durationRef.current = estimatedDuration; + activeTrackRef.current = track; - // Trim script to approximate position - const charOffset = Math.floor((fromSeconds / estimatedDuration) * track.ttsScript.length); + const charOffset = Math.floor((fromSeconds / estimatedDuration) * track.ttsScript.length); const scriptSlice = track.ttsScript.slice(charOffset); const utt = new SpeechSynthesisUtterance(scriptSlice); - utt.rate = 0.92; - utt.pitch = 1.0; - utt.volume = 1.0; - // Try to pick a natural English voice + utt.rate = 0.92; utt.pitch = 1.0; utt.volume = 1.0; const voices = window.speechSynthesis.getVoices(); const preferred = voices.find((v) => v.lang.startsWith('en') && v.name.toLowerCase().includes('google')) - || voices.find((v) => v.lang.startsWith('en')) - || null; + || voices.find((v) => v.lang.startsWith('en')) || null; if (preferred) utt.voice = preferred; ttsStartTimeRef.current = Date.now(); - ttsElapsedRef.current = fromSeconds; + ttsElapsedRef.current = fromSeconds; - utt.onend = () => { - setIsPlaying(false); - setCurrentTime(0); - ttsElapsedRef.current = 0; - clearInterval(ttsIntervalRef.current); - }; - - utt.onerror = () => { - setIsPlaying(false); - clearInterval(ttsIntervalRef.current); - }; + utt.onend = () => { setIsPlaying(false); ttsElapsedRef.current = 0; clearInterval(ttsIntervalRef.current); updateProgressDOM(0); }; + utt.onerror = () => { setIsPlaying(false); clearInterval(ttsIntervalRef.current); }; utteranceRef.current = utt; window.speechSynthesis.speak(utt); setIsPlaying(true); - // Poll current time + // Interval only mutates DOM — NO setState, NO re-render ttsIntervalRef.current = setInterval(() => { const elapsed = ttsElapsedRef.current + (Date.now() - ttsStartTimeRef.current) / 1000; - setCurrentTime(Math.min(elapsed, estimatedDuration)); + updateProgressDOM(Math.min(elapsed, estimatedDuration)); }, 250); - }, [stopTTS]); + }, [stopTTS, updateProgressDOM]); // ── Load a track ───────────────────────────────────────────────────────── - const loadTrack = useCallback((track) => { const idx = MOCK_AUDIO_TRACKS.findIndex((t) => t.id === track.id); stopTTS(); setActiveTrack(track); setTrackIndex(idx >= 0 ? idx : 0); - setCurrentTime(0); - setDuration(0); setIsPlaying(false); ttsElapsedRef.current = 0; - // Auto-play + durationRef.current = 0; + updateProgressDOM(0); setTimeout(() => startTTS(track, 0), 200); - }, [stopTTS, startTTS]); - - // Keep setActiveTrack in sync with loadTrack for child components - const handleSetActiveTrack = useCallback((track) => { - loadTrack(track); - }, [loadTrack]); + }, [stopTTS, startTTS, updateProgressDOM]); // ── Play / Pause ────────────────────────────────────────────────────────── - const togglePlay = useCallback(() => { - if (!activeTrack) return; + if (!activeTrackRef.current) return; if (isPlaying) { - // Pause: capture elapsed time and cancel - const elapsed = ttsElapsedRef.current + (Date.now() - ttsStartTimeRef.current) / 1000; - ttsElapsedRef.current = elapsed; + ttsElapsedRef.current = ttsElapsedRef.current + (Date.now() - ttsStartTimeRef.current) / 1000; stopTTS(); setIsPlaying(false); } else { - // Resume from elapsed position - startTTS(activeTrack, ttsElapsedRef.current); + startTTS(activeTrackRef.current, ttsElapsedRef.current); } - }, [activeTrack, isPlaying, stopTTS, startTTS]); - - // ── Skip next / previous ────────────────────────────────────────────────── + }, [isPlaying, stopTTS, startTTS]); + // ── Skip ───────────────────────────────────────────────────────────────── const skipNext = useCallback(() => { const next = (trackIndex + 1) % MOCK_AUDIO_TRACKS.length; loadTrack(MOCK_AUDIO_TRACKS[next]); @@ -575,24 +477,21 @@ export default function App() { }, [trackIndex, loadTrack]); // ── Seek ────────────────────────────────────────────────────────────────── - const seek = useCallback((e) => { - if (!activeTrack || !duration) return; - const rect = e.currentTarget.getBoundingClientRect(); - const frac = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); - const target = frac * duration; + const dur = durationRef.current; + if (!activeTrackRef.current || !dur) return; + const rect = e.currentTarget.getBoundingClientRect(); + const frac = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); + const target = frac * dur; ttsElapsedRef.current = target; - setCurrentTime(target); - if (isPlaying) { - stopTTS(); - setTimeout(() => startTTS(activeTrack, target), 100); - } - }, [activeTrack, duration, isPlaying, stopTTS, startTTS]); + updateProgressDOM(target); + if (isPlaying) { stopTTS(); setTimeout(() => startTTS(activeTrackRef.current, target), 100); } + }, [isPlaying, stopTTS, startTTS, updateProgressDOM]); - // Cleanup on unmount + // Cleanup useEffect(() => () => { stopTTS(); }, [stopTTS]); - // ── Nav items ───────────────────────────────────────────────────────────── + const handleNavClick = (id) => { setActiveView(id); setSidebarOpen(false); }; const navItems = [ { id: 'missionControl', label: 'Mission Control', icon: LayoutDashboard }, @@ -601,30 +500,17 @@ export default function App() { { id: 'wiki', label: 'Loop Wiki', icon: BookOpen }, ]; - const handleNavClick = (id) => { - setActiveView(id); - setSidebarOpen(false); - }; - - const progressPct = duration > 0 ? (currentTime / duration) * 100 : 0; - // ── Render ──────────────────────────────────────────────────────────────── - return (
- {/* Mobile overlay backdrop */} + {/* Mobile backdrop */} {sidebarOpen && (
setSidebarOpen(false)} /> )} {/* Sidebar */} -
+
@@ -645,11 +531,8 @@ export default function App() { {navItems.map(({ id, label, icon: Icon }) => { const isActive = activeView === id; return ( - - + - +
) : ( @@ -696,9 +583,7 @@ export default function App() { {/* Mobile top bar */}
- +
@@ -707,7 +592,8 @@ export default function App() { Coach Portal
{activeTrack ? ( - ) :
} @@ -717,33 +603,34 @@ export default function App() {
- {activeView === 'missionControl' && } + {activeView === 'missionControl' && } {activeView === 'simulator' && } - {activeView === 'audio' && } + {activeView === 'audio' && } {activeView === 'wiki' && }
- {/* Mobile mini audio strip */} + {/* Mobile mini audio strip (only shown when track active) */} {activeTrack && ( -
-
-
+
+ {/* Mobile progress bar fill updated via ref */} +
+

{activeTrack.title}

-

{isPlaying ? 'Now Playing' : 'Paused'} · {formatTime(currentTime)}

+

+ {isPlaying ? 'Now Playing' : 'Paused'} · 00:00 +

- - + - +
@@ -754,7 +641,8 @@ export default function App() { {navItems.map(({ id, label, icon: Icon }) => { const isActive = activeView === id; return ( -