fix: eliminate React re-renders from 250ms audio interval
Deploy to Netlify / build-and-deploy (push) Successful in 1m11s

- 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
This commit is contained in:
wmj
2026-08-24 11:30:36 +08:00
parent f8aacf3a69
commit 2f3e5540c8
+156 -268
View File
@@ -43,47 +43,25 @@ Rules:
MOCK DATA MOCK DATA
========================================================================== */ ========================================================================== */
// Public domain / freely licensed audio samples used as briefing placeholders
const MOCK_AUDIO_TRACKS = [ const MOCK_AUDIO_TRACKS = [
{ {
id: 1, id: 1, title: 'The 100-Day Turnaround', duration: '14:20', tag: 'Strategy', author: 'Senior Partner A',
title: 'The 100-Day Turnaround',
duration: '14:20',
tag: 'Strategy',
author: 'Senior Partner A',
// Web Speech API script (real TTS audio)
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.`, 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, id: 2, title: 'Negotiating with Hostile Boards', duration: '22:15', tag: 'Leadership', author: 'Principal B',
title: 'Negotiating with Hostile Boards', 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.`,
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, id: 3, title: 'M&A Synergy Identification', duration: '18:45', tag: 'Finance', author: 'Director C',
title: 'M&A Synergy Identification', 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.`,
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: 4, id: 4, title: 'Digital Transformation Pitfalls', duration: '31:10', tag: 'Technology', author: 'Principal D',
title: 'Digital Transformation Pitfalls', 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.`,
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: 5, id: 5, title: 'Pricing Strategies in Inflation', duration: '12:55', tag: 'Economics', author: 'Partner E',
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.`, 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 = [ const INITIAL_MESSAGES = [
{ { role: 'system', text: 'Falah Simulator initialized. Persona: Hostile Telco CIO. Objective: Justify £2M Agile Transformation.' },
role: 'system', { 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." },
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 = () => { const AICoPilotView = () => {
@@ -131,48 +103,36 @@ const AICoPilotView = () => {
chatEndRef.current?.scrollIntoView({ behavior: 'smooth' }); chatEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages, loading]); }, [messages, loading]);
const buildGeminiPayload = (msgs) => { const buildPayload = (msgs) => ({
// Convert our message history into Gemini format systemInstruction: { parts: [{ text: AI_SYSTEM_PROMPT }] },
const contents = msgs contents: msgs
.filter((m) => m.role !== 'system') .filter((m) => m.role !== 'system')
.map((m) => ({ .map((m) => ({ role: m.role === 'assistant' ? 'model' : 'user', parts: [{ text: m.text }] })),
role: m.role === 'assistant' ? 'model' : 'user', generationConfig: { temperature: 0.85, maxOutputTokens: 256 },
parts: [{ text: m.text }], });
}));
return {
systemInstruction: { parts: [{ text: AI_SYSTEM_PROMPT }] },
contents,
generationConfig: { temperature: 0.85, maxOutputTokens: 256 },
};
};
const handleSend = async (e) => { const handleSend = async (e) => {
e?.preventDefault(); e?.preventDefault();
const trimmed = input.trim(); const trimmed = input.trim();
if (!trimmed || loading) return; if (!trimmed || loading) return;
const next = [...messages, { role: 'user', text: trimmed }];
const newMessages = [...messages, { role: 'user', text: trimmed }]; setMessages(next);
setMessages(newMessages);
setInput(''); setInput('');
setLoading(true); setLoading(true);
setError(''); setError('');
try { try {
const res = await fetch(GEMINI_URL, { const res = await fetch(GEMINI_URL, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(buildGeminiPayload(newMessages)), body: JSON.stringify(buildPayload(next)),
}); });
if (!res.ok) { if (!res.ok) {
const err = await res.json().catch(() => ({})); const err = await res.json().catch(() => ({}));
throw new Error(err?.error?.message || `HTTP ${res.status}`); throw new Error(err?.error?.message || `HTTP ${res.status}`);
} }
const data = await res.json(); const data = await res.json();
const reply = data?.candidates?.[0]?.content?.parts?.[0]?.text?.trim(); const reply = data?.candidates?.[0]?.content?.parts?.[0]?.text?.trim();
if (!reply) throw new Error('Empty response from Gemini'); if (!reply) throw new Error('Empty response from Gemini');
setMessages((prev) => [...prev, { role: 'assistant', text: reply }]); setMessages((prev) => [...prev, { role: 'assistant', text: reply }]);
} catch (err) { } catch (err) {
setError(`AI error: ${err.message}`); setError(`AI error: ${err.message}`);
@@ -181,17 +141,12 @@ const AICoPilotView = () => {
} }
}; };
const handleChip = (cmd) => {
setInput(cmd);
};
return ( return (
<div className="h-full flex flex-col pb-4 animate-fade-in pr-0 md:pr-6"> <div className="h-full flex flex-col pb-4 animate-fade-in pr-0 md:pr-6">
<header className="mb-4 md:mb-6"> <header className="mb-4 md:mb-6">
<h1 className="text-2xl md:text-3xl font-bold tracking-tight text-white mb-1">AI Co-Pilot &amp; Simulator</h1> <h1 className="text-2xl md:text-3xl font-bold tracking-tight text-white mb-1">AI Co-Pilot &amp; Simulator</h1>
<p className="text-slate-400 text-sm">Engage in high-pressure executive roleplay to refine your delivery.</p> <p className="text-slate-400 text-sm">Engage in high-pressure executive roleplay to refine your delivery.</p>
</header> </header>
<div className="flex-1 bg-slate-900 border border-slate-800 rounded-2xl flex flex-col overflow-hidden min-h-0"> <div className="flex-1 bg-slate-900 border border-slate-800 rounded-2xl flex flex-col overflow-hidden min-h-0">
<div className="flex-1 p-4 md:p-6 overflow-y-auto space-y-4 md:space-y-6"> <div className="flex-1 p-4 md:p-6 overflow-y-auto space-y-4 md:space-y-6">
{messages.map((msg, i) => ( {messages.map((msg, i) => (
@@ -202,11 +157,9 @@ const AICoPilotView = () => {
</div> </div>
)} )}
<div className={`max-w-[80%] md:max-w-[70%] p-3 md:p-4 rounded-2xl text-sm md:text-base ${ <div className={`max-w-[80%] md:max-w-[70%] p-3 md:p-4 rounded-2xl text-sm md:text-base ${
msg.role === 'user' msg.role === 'user' ? 'bg-blue-600 text-white rounded-tr-sm'
? 'bg-blue-600 text-white rounded-tr-sm' : msg.role === 'system' ? 'bg-slate-800 text-slate-300 font-mono text-xs md:text-sm border border-slate-700'
: msg.role === 'system' : 'bg-slate-800 text-slate-200 border border-slate-700 rounded-tl-sm'
? 'bg-slate-800 text-slate-300 font-mono text-xs md:text-sm border border-slate-700'
: 'bg-slate-800 text-slate-200 border border-slate-700 rounded-tl-sm'
}`}> }`}>
{msg.text} {msg.text}
</div> </div>
@@ -217,54 +170,33 @@ const AICoPilotView = () => {
)} )}
</div> </div>
))} ))}
{loading && ( {loading && (
<div className="flex gap-3 justify-start"> <div className="flex gap-3 justify-start">
<div className="w-8 h-8 rounded-full bg-blue-500/20 text-blue-400 flex items-center justify-center shrink-0"> <div className="w-8 h-8 rounded-full bg-blue-500/20 text-blue-400 flex items-center justify-center shrink-0"><Bot size={16} /></div>
<Bot size={16} />
</div>
<div className="bg-slate-800 border border-slate-700 rounded-2xl rounded-tl-sm p-4 flex items-center gap-2"> <div className="bg-slate-800 border border-slate-700 rounded-2xl rounded-tl-sm p-4 flex items-center gap-2">
<Loader2 size={16} className="animate-spin text-blue-400" /> <Loader2 size={16} className="animate-spin text-blue-400" />
<span className="text-slate-400 text-sm">Thinking...</span> <span className="text-slate-400 text-sm">Thinking...</span>
</div> </div>
</div> </div>
)} )}
{error && <div className="text-red-400 text-xs bg-red-500/10 border border-red-500/20 rounded-lg p-3 text-center">{error}</div>}
{error && (
<div className="text-red-400 text-xs bg-red-500/10 border border-red-500/20 rounded-lg p-3 text-center">
{error}
</div>
)}
<div ref={chatEndRef} /> <div ref={chatEndRef} />
</div> </div>
<div className="p-3 md:p-4 border-t border-slate-800 bg-slate-900/50"> <div className="p-3 md:p-4 border-t border-slate-800 bg-slate-900/50">
<div className="flex gap-2 mb-3 overflow-x-auto pb-1 scrollbar-none"> <div className="flex gap-2 mb-3 overflow-x-auto pb-1 scrollbar-none">
{['/simulate Telco CIO', '/mentor DevSecOps', '/grade'].map((cmd) => ( {['/simulate Telco CIO', '/mentor DevSecOps', '/grade'].map((cmd) => (
<button <button key={cmd} onClick={() => setInput(cmd)}
key={cmd} className="whitespace-nowrap px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-blue-400 text-xs font-mono rounded-md border border-slate-700 transition-colors">
onClick={() => handleChip(cmd)}
className="whitespace-nowrap px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-blue-400 text-xs font-mono rounded-md border border-slate-700 transition-colors"
>
{cmd} {cmd}
</button> </button>
))} ))}
</div> </div>
<form onSubmit={handleSend} className="relative"> <form onSubmit={handleSend} className="relative">
<input <input type="text" value={input} onChange={(e) => setInput(e.target.value)}
type="text" placeholder="Deploy a response or command..." disabled={loading}
value={input} 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" />
onChange={(e) => setInput(e.target.value)} <button type="submit" disabled={loading || !input.trim()}
placeholder="Deploy a response or command..." className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-blue-400 p-2 disabled:opacity-30 transition-colors">
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"
/>
<button
type="submit"
disabled={loading || !input.trim()}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-blue-400 p-2 disabled:opacity-30 transition-colors"
>
{loading ? <Loader2 size={18} className="animate-spin" /> : <Zap size={18} />} {loading ? <Loader2 size={18} className="animate-spin" /> : <Zap size={18} />}
</button> </button>
</form> </form>
@@ -286,16 +218,11 @@ const AudioLibraryView = ({ setActiveTrack }) => (
</header> </header>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 md:gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 md:gap-6">
{MOCK_AUDIO_TRACKS.map((track) => ( {MOCK_AUDIO_TRACKS.map((track) => (
<div <div key={track.id} onClick={() => setActiveTrack(track)}
key={track.id} 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">
onClick={() => 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"
>
<div className="flex justify-between items-start mb-10 md:mb-12"> <div className="flex justify-between items-start mb-10 md:mb-12">
<span className="px-2.5 py-1 bg-slate-800 text-slate-300 text-xs font-semibold rounded-md uppercase tracking-wider"> <span className="px-2.5 py-1 bg-slate-800 text-slate-300 text-xs font-semibold rounded-md uppercase tracking-wider">{track.tag}</span>
{track.tag} <div className="w-10 h-10 rounded-full bg-emerald-500/20 text-emerald-400 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
</span>
<div className="w-10 h-10 rounded-full bg-emerald-500/20 text-emerald-400 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity scale-75 group-hover:scale-100">
<PlayCircle size={24} /> <PlayCircle size={24} />
</div> </div>
</div> </div>
@@ -323,39 +250,30 @@ const MissionControlView = ({ setActiveView, setActiveTrack }) => (
<p className="text-slate-400 text-sm">Welcome back. Your Falah Coach portal is ready.</p> <p className="text-slate-400 text-sm">Welcome back. Your Falah Coach portal is ready.</p>
</header> </header>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 md:gap-6"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-4 md:gap-6">
{/* Featured briefing */}
<div className="lg:col-span-2 bg-gradient-to-br from-slate-900 to-slate-900/80 border border-slate-800 rounded-2xl p-6 md:p-8 flex flex-col justify-between min-h-[240px] md:min-h-[280px] relative overflow-hidden"> <div className="lg:col-span-2 bg-gradient-to-br from-slate-900 to-slate-900/80 border border-slate-800 rounded-2xl p-6 md:p-8 flex flex-col justify-between min-h-[240px] md:min-h-[280px] relative overflow-hidden">
<div className="absolute -right-8 -top-8 w-40 h-40 bg-emerald-500/5 rounded-full blur-3xl pointer-events-none" /> <div className="absolute -right-8 -top-8 w-40 h-40 bg-emerald-500/5 rounded-full blur-3xl pointer-events-none" />
<div> <div>
<div className="flex items-center gap-2 text-emerald-400 text-xs font-bold uppercase tracking-widest mb-4"> <div className="flex items-center gap-2 text-emerald-400 text-xs font-bold uppercase tracking-widest mb-4">
<Zap size={14} className="fill-emerald-400" /> <Zap size={14} className="fill-emerald-400" /> Featured Briefing
Featured Briefing
</div> </div>
<h2 className="text-2xl md:text-3xl font-bold text-white mb-3">{MOCK_AUDIO_TRACKS[0].title}</h2> <h2 className="text-2xl md:text-3xl font-bold text-white mb-3">{MOCK_AUDIO_TRACKS[0].title}</h2>
<p className="text-slate-400 text-sm md:text-base leading-relaxed mb-6"> <p className="text-slate-400 text-sm md:text-base leading-relaxed mb-6">
Master the critical first 100 days of a private equity backed turnaround. Tactical insights from our top Senior Partners. Master the critical first 100 days of a private equity backed turnaround. Tactical insights from our top Senior Partners.
</p> </p>
</div> </div>
<button <button onClick={() => setActiveTrack(MOCK_AUDIO_TRACKS[0])}
onClick={() => setActiveTrack(MOCK_AUDIO_TRACKS[0])} className="flex items-center gap-2 bg-emerald-500 hover:bg-emerald-400 text-slate-950 font-semibold py-3 px-6 rounded-lg transition-colors w-max text-sm md:text-base">
className="flex items-center gap-2 bg-emerald-500 hover:bg-emerald-400 text-slate-950 font-semibold py-3 px-6 rounded-lg transition-colors w-max text-sm md:text-base"
>
<PlayCircle size={20} className="fill-slate-950 text-slate-950" /> <PlayCircle size={20} className="fill-slate-950 text-slate-950" />
Play Briefing ({MOCK_AUDIO_TRACKS[0].duration}) Play Briefing ({MOCK_AUDIO_TRACKS[0].duration})
</button> </button>
</div> </div>
{/* Quick-start cards */}
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{[ {[
{ label: 'AI Simulator', desc: 'Practice hostile boardroom scenarios.', view: 'simulator', icon: MessageSquareQuote }, { 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 }, { label: 'Loop Wiki', desc: 'Review the Falah MECE Guidelines.', view: 'wiki', icon: BookOpen },
].map(({ label, desc, view, icon: Icon }) => ( ].map(({ label, desc, view, icon: Icon }) => (
<div <div key={view} onClick={() => setActiveView(view)}
key={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">
onClick={() => 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"
>
<div className="w-10 h-10 rounded-xl bg-blue-500/10 flex items-center justify-center mb-4"> <div className="w-10 h-10 rounded-xl bg-blue-500/10 flex items-center justify-center mb-4">
<Icon size={22} className="text-blue-400" /> <Icon size={22} className="text-blue-400" />
</div> </div>
@@ -393,13 +311,9 @@ const MSLoopWikiView = () => {
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
{dir.items.map((item) => ( {dir.items.map((item) => (
<button <button key={item} onClick={() => setActivePage(item)}
key={item} className={`w-full flex items-center gap-2 px-2 py-1.5 text-sm rounded-md transition-colors text-left ${activePage === item ? 'bg-blue-500/10 text-blue-400 font-medium' : 'text-slate-400 hover:text-slate-200 hover:bg-slate-800'}`}>
onClick={() => setActivePage(item)} <FileText size={14} /><span className="truncate">{item}</span>
className={`w-full flex items-center gap-2 px-2 py-1.5 text-sm rounded-md transition-colors text-left ${activePage === item ? 'bg-blue-500/10 text-blue-400 font-medium' : 'text-slate-400 hover:text-slate-200 hover:bg-slate-800'}`}
>
<FileText size={14} />
<span className="truncate">{item}</span>
</button> </button>
))} ))}
</div> </div>
@@ -410,9 +324,7 @@ const MSLoopWikiView = () => {
<div className="flex-1 bg-white rounded-2xl overflow-hidden flex flex-col shadow-2xl min-h-[300px] md:min-h-0"> <div className="flex-1 bg-white rounded-2xl overflow-hidden flex flex-col shadow-2xl min-h-[300px] md:min-h-0">
<div className="bg-slate-100 border-b border-slate-200 p-3 flex items-center gap-4"> <div className="bg-slate-100 border-b border-slate-200 p-3 flex items-center gap-4">
<div className="flex gap-1.5"> <div className="flex gap-1.5">
<div className="w-3 h-3 rounded-full bg-slate-300" /> <div className="w-3 h-3 rounded-full bg-slate-300" /><div className="w-3 h-3 rounded-full bg-slate-300" /><div className="w-3 h-3 rounded-full bg-slate-300" />
<div className="w-3 h-3 rounded-full bg-slate-300" />
<div className="w-3 h-3 rounded-full bg-slate-300" />
</div> </div>
<div className="bg-white border border-slate-200 text-slate-400 text-xs px-3 py-1.5 rounded flex-1 flex items-center shadow-sm max-w-md"> <div className="bg-white border border-slate-200 text-slate-400 text-xs px-3 py-1.5 rounded flex-1 flex items-center shadow-sm max-w-md">
<span className="text-slate-400 font-mono text-[11px] truncate">loop.microsoft.com/falah/{activePage.toLowerCase().replace(/ /g, '-')}</span> <span className="text-slate-400 font-mono text-[11px] truncate">loop.microsoft.com/falah/{activePage.toLowerCase().replace(/ /g, '-')}</span>
@@ -421,26 +333,24 @@ const MSLoopWikiView = () => {
<div className="flex-1 p-6 md:p-12 overflow-y-auto text-slate-800 bg-[#fbfbfb]"> <div className="flex-1 p-6 md:p-12 overflow-y-auto text-slate-800 bg-[#fbfbfb]">
<div className="max-w-2xl mx-auto"> <div className="max-w-2xl mx-auto">
<h1 className="text-3xl md:text-4xl font-bold mb-8 text-slate-900 border-b border-slate-200 pb-4">{activePage}</h1> <h1 className="text-3xl md:text-4xl font-bold mb-8 text-slate-900 border-b border-slate-200 pb-4">{activePage}</h1>
<div className="prose prose-slate max-w-none"> <p className="text-lg leading-relaxed mb-6 text-slate-600">This document serves as the canonical reference for exactly how our consultants should approach problem structuring in this domain.</p>
<p className="text-lg leading-relaxed mb-6 text-slate-600">This document serves as the canonical reference for exactly how our consultants should approach problem structuring in this domain.</p> <div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-8">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-8"> <div className="p-4 bg-blue-50 border border-blue-100 rounded-lg">
<div className="p-4 bg-blue-50 border border-blue-100 rounded-lg"> <h4 className="font-semibold text-blue-900 mb-2">Key Objective</h4>
<h4 className="font-semibold text-blue-900 mb-2">Key Objective</h4> <p className="text-sm text-blue-800">Drive unambiguous clarity to executive stakeholders within 3 minutes of engagement.</p>
<p className="text-sm text-blue-800">Drive unambiguous clarity to executive stakeholders within 3 minutes of engagement.</p> </div>
</div> <div className="p-4 bg-emerald-50 border border-emerald-100 rounded-lg">
<div className="p-4 bg-emerald-50 border border-emerald-100 rounded-lg"> <h4 className="font-semibold text-emerald-900 mb-2">Success Metric</h4>
<h4 className="font-semibold text-emerald-900 mb-2">Success Metric</h4> <p className="text-sm text-emerald-800">Achieve client alignment before the first break in the agenda.</p>
<p className="text-sm text-emerald-800">Achieve client alignment before the first break in the agenda.</p>
</div>
</div> </div>
<h3 className="text-2xl font-semibold mb-4 mt-8 text-slate-800">1. Executive Summary</h3>
<p className="text-slate-600 leading-relaxed mb-4">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.</p>
<ul className="space-y-2 list-disc pl-5 text-slate-600 mb-8">
<li>Identify the core constraint (Capital, Talent, or Regulatory).</li>
<li>Map the adjacent capabilities required.</li>
<li>Draft the 100-day execution roadmap.</li>
</ul>
</div> </div>
<h3 className="text-2xl font-semibold mb-4 mt-8 text-slate-800">1. Executive Summary</h3>
<p className="text-slate-600 leading-relaxed mb-4">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.</p>
<ul className="space-y-2 list-disc pl-5 text-slate-600 mb-8">
<li>Identify the core constraint (Capital, Talent, or Regulatory).</li>
<li>Map the adjacent capabilities required.</li>
<li>Draft the 100-day execution roadmap.</li>
</ul>
</div> </div>
</div> </div>
</div> </div>
@@ -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() { export default function App() {
const [activeView, setActiveView] = useState('missionControl'); // ── React state — only what MUST cause re-renders ────────────────────────
const [activeTrack, setActiveTrack] = useState(null); // currently loaded track object const [activeView, setActiveView] = useState('missionControl');
const [trackIndex, setTrackIndex] = useState(0); // index into MOCK_AUDIO_TRACKS const [activeTrack, setActiveTrack] = useState(null); // track object | null
const [isPlaying, setIsPlaying] = useState(false); const [trackIndex, setTrackIndex] = useState(0);
const [currentTime, setCurrentTime] = useState(0); const [isPlaying, setIsPlaying] = useState(false);
const [duration, setDuration] = useState(0); const [sidebarOpen, setSidebarOpen] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState(false);
const [ttsLoading, setTtsLoading] = useState(false);
// We use Web Speech API for TTS // ── Refs — updated without triggering re-renders ─────────────────────────
const utteranceRef = useRef(null); const utteranceRef = useRef(null);
const ttsIntervalRef = useRef(null); const ttsIntervalRef = useRef(null);
const ttsStartTimeRef = useRef(0); // Date.now() when speech started const ttsStartTimeRef = useRef(0);
const ttsElapsedRef = useRef(0); // seconds elapsed before last pause 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 ────────────────────────────────────────────────────────── // ── TTS helpers ──────────────────────────────────────────────────────────
const stopTTS = useCallback(() => { const stopTTS = useCallback(() => {
window.speechSynthesis.cancel(); window.speechSynthesis.cancel();
clearInterval(ttsIntervalRef.current); clearInterval(ttsIntervalRef.current);
@@ -481,89 +409,63 @@ export default function App() {
if (!track?.ttsScript) return; if (!track?.ttsScript) return;
stopTTS(); stopTTS();
// Estimate total duration from char count (~150 WPM, ~5 chars/word)
const estimatedDuration = track.ttsScript.length / 12.5; 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 scriptSlice = track.ttsScript.slice(charOffset);
const utt = new SpeechSynthesisUtterance(scriptSlice); const utt = new SpeechSynthesisUtterance(scriptSlice);
utt.rate = 0.92; utt.rate = 0.92; utt.pitch = 1.0; utt.volume = 1.0;
utt.pitch = 1.0;
utt.volume = 1.0;
// Try to pick a natural English voice
const voices = window.speechSynthesis.getVoices(); const voices = window.speechSynthesis.getVoices();
const preferred = voices.find((v) => v.lang.startsWith('en') && v.name.toLowerCase().includes('google')) const preferred = voices.find((v) => v.lang.startsWith('en') && v.name.toLowerCase().includes('google'))
|| voices.find((v) => v.lang.startsWith('en')) || voices.find((v) => v.lang.startsWith('en')) || null;
|| null;
if (preferred) utt.voice = preferred; if (preferred) utt.voice = preferred;
ttsStartTimeRef.current = Date.now(); ttsStartTimeRef.current = Date.now();
ttsElapsedRef.current = fromSeconds; ttsElapsedRef.current = fromSeconds;
utt.onend = () => { utt.onend = () => { setIsPlaying(false); ttsElapsedRef.current = 0; clearInterval(ttsIntervalRef.current); updateProgressDOM(0); };
setIsPlaying(false); utt.onerror = () => { setIsPlaying(false); clearInterval(ttsIntervalRef.current); };
setCurrentTime(0);
ttsElapsedRef.current = 0;
clearInterval(ttsIntervalRef.current);
};
utt.onerror = () => {
setIsPlaying(false);
clearInterval(ttsIntervalRef.current);
};
utteranceRef.current = utt; utteranceRef.current = utt;
window.speechSynthesis.speak(utt); window.speechSynthesis.speak(utt);
setIsPlaying(true); setIsPlaying(true);
// Poll current time // Interval only mutates DOM — NO setState, NO re-render
ttsIntervalRef.current = setInterval(() => { ttsIntervalRef.current = setInterval(() => {
const elapsed = ttsElapsedRef.current + (Date.now() - ttsStartTimeRef.current) / 1000; const elapsed = ttsElapsedRef.current + (Date.now() - ttsStartTimeRef.current) / 1000;
setCurrentTime(Math.min(elapsed, estimatedDuration)); updateProgressDOM(Math.min(elapsed, estimatedDuration));
}, 250); }, 250);
}, [stopTTS]); }, [stopTTS, updateProgressDOM]);
// ── Load a track ───────────────────────────────────────────────────────── // ── Load a track ─────────────────────────────────────────────────────────
const loadTrack = useCallback((track) => { const loadTrack = useCallback((track) => {
const idx = MOCK_AUDIO_TRACKS.findIndex((t) => t.id === track.id); const idx = MOCK_AUDIO_TRACKS.findIndex((t) => t.id === track.id);
stopTTS(); stopTTS();
setActiveTrack(track); setActiveTrack(track);
setTrackIndex(idx >= 0 ? idx : 0); setTrackIndex(idx >= 0 ? idx : 0);
setCurrentTime(0);
setDuration(0);
setIsPlaying(false); setIsPlaying(false);
ttsElapsedRef.current = 0; ttsElapsedRef.current = 0;
// Auto-play durationRef.current = 0;
updateProgressDOM(0);
setTimeout(() => startTTS(track, 0), 200); setTimeout(() => startTTS(track, 0), 200);
}, [stopTTS, startTTS]); }, [stopTTS, startTTS, updateProgressDOM]);
// Keep setActiveTrack in sync with loadTrack for child components
const handleSetActiveTrack = useCallback((track) => {
loadTrack(track);
}, [loadTrack]);
// ── Play / Pause ────────────────────────────────────────────────────────── // ── Play / Pause ──────────────────────────────────────────────────────────
const togglePlay = useCallback(() => { const togglePlay = useCallback(() => {
if (!activeTrack) return; if (!activeTrackRef.current) return;
if (isPlaying) { if (isPlaying) {
// Pause: capture elapsed time and cancel ttsElapsedRef.current = ttsElapsedRef.current + (Date.now() - ttsStartTimeRef.current) / 1000;
const elapsed = ttsElapsedRef.current + (Date.now() - ttsStartTimeRef.current) / 1000;
ttsElapsedRef.current = elapsed;
stopTTS(); stopTTS();
setIsPlaying(false); setIsPlaying(false);
} else { } else {
// Resume from elapsed position startTTS(activeTrackRef.current, ttsElapsedRef.current);
startTTS(activeTrack, ttsElapsedRef.current);
} }
}, [activeTrack, isPlaying, stopTTS, startTTS]); }, [isPlaying, stopTTS, startTTS]);
// ── Skip next / previous ──────────────────────────────────────────────────
// ── Skip ─────────────────────────────────────────────────────────────────
const skipNext = useCallback(() => { const skipNext = useCallback(() => {
const next = (trackIndex + 1) % MOCK_AUDIO_TRACKS.length; const next = (trackIndex + 1) % MOCK_AUDIO_TRACKS.length;
loadTrack(MOCK_AUDIO_TRACKS[next]); loadTrack(MOCK_AUDIO_TRACKS[next]);
@@ -575,24 +477,21 @@ export default function App() {
}, [trackIndex, loadTrack]); }, [trackIndex, loadTrack]);
// ── Seek ────────────────────────────────────────────────────────────────── // ── Seek ──────────────────────────────────────────────────────────────────
const seek = useCallback((e) => { const seek = useCallback((e) => {
if (!activeTrack || !duration) return; const dur = durationRef.current;
const rect = e.currentTarget.getBoundingClientRect(); if (!activeTrackRef.current || !dur) return;
const frac = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); const rect = e.currentTarget.getBoundingClientRect();
const target = frac * duration; const frac = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
const target = frac * dur;
ttsElapsedRef.current = target; ttsElapsedRef.current = target;
setCurrentTime(target); updateProgressDOM(target);
if (isPlaying) { if (isPlaying) { stopTTS(); setTimeout(() => startTTS(activeTrackRef.current, target), 100); }
stopTTS(); }, [isPlaying, stopTTS, startTTS, updateProgressDOM]);
setTimeout(() => startTTS(activeTrack, target), 100);
}
}, [activeTrack, duration, isPlaying, stopTTS, startTTS]);
// Cleanup on unmount // Cleanup
useEffect(() => () => { stopTTS(); }, [stopTTS]); useEffect(() => () => { stopTTS(); }, [stopTTS]);
// ── Nav items ───────────────────────────────────────────────────────────── const handleNavClick = (id) => { setActiveView(id); setSidebarOpen(false); };
const navItems = [ const navItems = [
{ id: 'missionControl', label: 'Mission Control', icon: LayoutDashboard }, { id: 'missionControl', label: 'Mission Control', icon: LayoutDashboard },
@@ -601,30 +500,17 @@ export default function App() {
{ id: 'wiki', label: 'Loop Wiki', icon: BookOpen }, { id: 'wiki', label: 'Loop Wiki', icon: BookOpen },
]; ];
const handleNavClick = (id) => {
setActiveView(id);
setSidebarOpen(false);
};
const progressPct = duration > 0 ? (currentTime / duration) * 100 : 0;
// ── Render ──────────────────────────────────────────────────────────────── // ── Render ────────────────────────────────────────────────────────────────
return ( return (
<div className="flex h-screen bg-slate-950 text-slate-100 font-sans overflow-hidden selection:bg-blue-500/30"> <div className="flex h-screen bg-slate-950 text-slate-100 font-sans overflow-hidden selection:bg-blue-500/30">
{/* Mobile overlay backdrop */} {/* Mobile backdrop */}
{sidebarOpen && ( {sidebarOpen && (
<div className="fixed inset-0 z-30 bg-black/60 md:hidden" onClick={() => setSidebarOpen(false)} /> <div className="fixed inset-0 z-30 bg-black/60 md:hidden" onClick={() => setSidebarOpen(false)} />
)} )}
{/* Sidebar */} {/* Sidebar */}
<div className={` <div className={`fixed inset-y-0 left-0 z-40 w-72 border-r border-slate-800 flex flex-col bg-slate-900 shadow-2xl transform transition-transform duration-300 ease-in-out md:relative md:translate-x-0 ${sidebarOpen ? 'translate-x-0' : '-translate-x-full md:translate-x-0'}`}>
fixed inset-y-0 left-0 z-40 w-72 border-r border-slate-800 flex flex-col bg-slate-900 shadow-2xl
transform transition-transform duration-300 ease-in-out
md:relative md:translate-x-0
${sidebarOpen ? 'translate-x-0' : '-translate-x-full md:translate-x-0'}
`}>
<div className="p-6 flex items-start justify-between"> <div className="p-6 flex items-start justify-between">
<div> <div>
<div className="flex items-center gap-3 mb-1"> <div className="flex items-center gap-3 mb-1">
@@ -645,11 +531,8 @@ export default function App() {
{navItems.map(({ id, label, icon: Icon }) => { {navItems.map(({ id, label, icon: Icon }) => {
const isActive = activeView === id; const isActive = activeView === id;
return ( return (
<button <button key={id} onClick={() => handleNavClick(id)}
key={id} className={`w-full flex items-center justify-between px-3 py-2.5 rounded-lg transition-all duration-200 group ${isActive ? 'bg-blue-600 shadow-md shadow-blue-500/20' : 'hover:bg-slate-800 text-slate-400 hover:text-slate-100'}`}>
onClick={() => handleNavClick(id)}
className={`w-full flex items-center justify-between px-3 py-2.5 rounded-lg transition-all duration-200 group ${isActive ? 'bg-blue-600 shadow-md shadow-blue-500/20' : 'hover:bg-slate-800 text-slate-400 hover:text-slate-100'}`}
>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Icon size={18} className={isActive ? 'text-white' : 'text-slate-500 group-hover:text-blue-400 transition-colors'} /> <Icon size={18} className={isActive ? 'text-white' : 'text-slate-500 group-hover:text-blue-400 transition-colors'} />
<span className={`font-medium ${isActive ? 'text-white' : ''}`}>{label}</span> <span className={`font-medium ${isActive ? 'text-white' : ''}`}>{label}</span>
@@ -660,6 +543,7 @@ export default function App() {
})} })}
</nav> </nav>
{/* Sidebar audio player — inlined, refs for live progress (no re-render) */}
<div className="mt-auto border-t border-slate-800 p-4 bg-slate-950/50 backdrop-blur-md"> <div className="mt-auto border-t border-slate-800 p-4 bg-slate-950/50 backdrop-blur-md">
{activeTrack ? ( {activeTrack ? (
<div> <div>
@@ -667,19 +551,22 @@ export default function App() {
<span className="text-[10px] font-bold text-emerald-400 uppercase tracking-wider flex items-center gap-1"> <span className="text-[10px] font-bold text-emerald-400 uppercase tracking-wider flex items-center gap-1">
<Volume2 size={10} /> Now Playing <Volume2 size={10} /> Now Playing
</span> </span>
<span className="text-xs text-slate-500 font-mono">{formatTime(currentTime)} / {activeTrack.duration}</span> {/* timestamp updated directly via ref — no re-render */}
<span ref={desktopTimestampRef} className="text-xs text-slate-500 font-mono">00:00 / {activeTrack.duration}</span>
</div> </div>
<p className="font-semibold text-sm text-white truncate mb-0.5" title={activeTrack.title}>{activeTrack.title}</p> <p className="font-semibold text-sm text-white truncate mb-0.5">{activeTrack.title}</p>
<p className="text-xs text-slate-400 mb-3 truncate">{activeTrack.author}</p> <p className="text-xs text-slate-400 mb-3 truncate">{activeTrack.author}</p>
{/* Seekable progress bar — fill updated via ref */}
<div className="w-full bg-slate-800 h-1.5 rounded-full overflow-hidden cursor-pointer mb-3" onClick={seek} title="Click to seek"> <div className="w-full bg-slate-800 h-1.5 rounded-full overflow-hidden cursor-pointer mb-3" onClick={seek} title="Click to seek">
<div className="bg-emerald-500 h-full rounded-full transition-all duration-300" style={{ width: `${progressPct}%` }} /> <div ref={desktopProgressRef} className="bg-emerald-500 h-full rounded-full" style={{ width: '0%' }} />
</div> </div>
<div className="flex items-center justify-between px-2"> <div className="flex items-center justify-between px-2">
<button aria-label="Previous track" onClick={skipPrev} className="text-slate-400 hover:text-white transition-colors"><SkipBack size={18} /></button> <button aria-label="Previous track" onClick={skipPrev} className="text-slate-400 hover:text-white transition-colors p-1"><SkipBack size={18} /></button>
<button onClick={togglePlay} aria-label={isPlaying ? 'Pause' : 'Play'} className="text-white bg-emerald-500 hover:bg-emerald-400 rounded-full w-10 h-10 flex items-center justify-center transition-all shadow-lg shadow-emerald-500/20"> <button onClick={togglePlay} aria-label={isPlaying ? 'Pause' : 'Play'}
className="text-white bg-emerald-500 hover:bg-emerald-400 rounded-full w-10 h-10 flex items-center justify-center transition-all shadow-lg shadow-emerald-500/20">
{isPlaying ? <PauseCircle size={24} className="text-slate-950" /> : <PlayCircle size={24} className="text-slate-950" />} {isPlaying ? <PauseCircle size={24} className="text-slate-950" /> : <PlayCircle size={24} className="text-slate-950" />}
</button> </button>
<button aria-label="Next track" onClick={skipNext} className="text-slate-400 hover:text-white transition-colors"><SkipForward size={18} /></button> <button aria-label="Next track" onClick={skipNext} className="text-slate-400 hover:text-white transition-colors p-1"><SkipForward size={18} /></button>
</div> </div>
</div> </div>
) : ( ) : (
@@ -696,9 +583,7 @@ export default function App() {
{/* Mobile top bar */} {/* Mobile top bar */}
<header className="md:hidden flex items-center justify-between px-4 py-3 bg-slate-900 border-b border-slate-800 shrink-0 z-20"> <header className="md:hidden flex items-center justify-between px-4 py-3 bg-slate-900 border-b border-slate-800 shrink-0 z-20">
<button onClick={() => setSidebarOpen(true)} aria-label="Open menu" className="text-slate-400 hover:text-white p-1"> <button onClick={() => setSidebarOpen(true)} aria-label="Open menu" className="text-slate-400 hover:text-white p-1"><Menu size={22} /></button>
<Menu size={22} />
</button>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-6 h-6 bg-white rounded-md flex items-center justify-center"> <div className="w-6 h-6 bg-white rounded-md flex items-center justify-center">
<TrendingUp className="text-slate-950" size={13} strokeWidth={2.5} /> <TrendingUp className="text-slate-950" size={13} strokeWidth={2.5} />
@@ -707,7 +592,8 @@ export default function App() {
<span className="text-[9px] uppercase font-bold tracking-widest text-slate-500">Coach Portal</span> <span className="text-[9px] uppercase font-bold tracking-widest text-slate-500">Coach Portal</span>
</div> </div>
{activeTrack ? ( {activeTrack ? (
<button onClick={togglePlay} aria-label={isPlaying ? 'Pause' : 'Play'} className="w-8 h-8 bg-emerald-500 hover:bg-emerald-400 rounded-full flex items-center justify-center transition-all"> <button onClick={togglePlay} aria-label={isPlaying ? 'Pause' : 'Play'}
className="w-8 h-8 bg-emerald-500 hover:bg-emerald-400 rounded-full flex items-center justify-center transition-all">
{isPlaying ? <PauseCircle size={18} className="text-slate-950" /> : <PlayCircle size={18} className="text-slate-950" />} {isPlaying ? <PauseCircle size={18} className="text-slate-950" /> : <PlayCircle size={18} className="text-slate-950" />}
</button> </button>
) : <div className="w-8" />} ) : <div className="w-8" />}
@@ -717,33 +603,34 @@ export default function App() {
<main className="flex-1 overflow-y-auto p-4 md:p-8 relative scroll-smooth overflow-x-hidden"> <main className="flex-1 overflow-y-auto p-4 md:p-8 relative scroll-smooth overflow-x-hidden">
<div className="absolute top-[-20%] left-[-10%] w-[50%] h-[50%] bg-blue-600/5 rounded-full blur-[120px] pointer-events-none" /> <div className="absolute top-[-20%] left-[-10%] w-[50%] h-[50%] bg-blue-600/5 rounded-full blur-[120px] pointer-events-none" />
<div className="max-w-6xl mx-auto h-full relative z-10"> <div className="max-w-6xl mx-auto h-full relative z-10">
{activeView === 'missionControl' && <MissionControlView setActiveView={setActiveView} setActiveTrack={handleSetActiveTrack} />} {activeView === 'missionControl' && <MissionControlView setActiveView={setActiveView} setActiveTrack={loadTrack} />}
{activeView === 'simulator' && <AICoPilotView />} {activeView === 'simulator' && <AICoPilotView />}
{activeView === 'audio' && <AudioLibraryView setActiveTrack={handleSetActiveTrack} />} {activeView === 'audio' && <AudioLibraryView setActiveTrack={loadTrack} />}
{activeView === 'wiki' && <MSLoopWikiView />} {activeView === 'wiki' && <MSLoopWikiView />}
</div> </div>
</main> </main>
{/* Mobile mini audio strip */} {/* Mobile mini audio strip (only shown when track active) */}
{activeTrack && ( {activeTrack && (
<div className="md:hidden shrink-0 bg-slate-900 border-t border-emerald-500/30 px-4 py-2 z-20 animate-fade-in-up"> <div className="md:hidden shrink-0 bg-slate-900 border-t border-emerald-500/30 px-4 py-2 z-20">
<div {/* Mobile progress bar fill updated via ref */}
className="w-full bg-slate-800 h-1 rounded-full overflow-hidden cursor-pointer mb-2" <div className="w-full bg-slate-800 h-1 rounded-full overflow-hidden cursor-pointer mb-2" onClick={seek}>
onClick={seek} <div ref={mobileProgressRef} className="bg-emerald-500 h-full rounded-full" style={{ width: '0%' }} />
>
<div className="bg-emerald-500 h-full rounded-full transition-all duration-300" style={{ width: `${progressPct}%` }} />
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-xs font-semibold text-white truncate">{activeTrack.title}</p> <p className="text-xs font-semibold text-white truncate">{activeTrack.title}</p>
<p className="text-[10px] text-emerald-400 font-bold uppercase tracking-wider">{isPlaying ? 'Now Playing' : 'Paused'} · {formatTime(currentTime)}</p> <p className="text-[10px] text-emerald-400 font-bold uppercase tracking-wider">
{isPlaying ? 'Now Playing' : 'Paused'} · <span ref={mobileTimestampRef}>00:00</span>
</p>
</div> </div>
<div className="flex items-center gap-2 shrink-0"> <div className="flex items-center gap-2 shrink-0">
<button aria-label="Previous" onClick={skipPrev} className="text-slate-400"><SkipBack size={16} /></button> <button aria-label="Previous" onClick={skipPrev} className="text-slate-400 p-1"><SkipBack size={16} /></button>
<button onClick={togglePlay} aria-label={isPlaying ? 'Pause' : 'Play'} className="w-8 h-8 bg-emerald-500 rounded-full flex items-center justify-center"> <button onClick={togglePlay} aria-label={isPlaying ? 'Pause' : 'Play'}
className="w-8 h-8 bg-emerald-500 rounded-full flex items-center justify-center">
{isPlaying ? <PauseCircle size={18} className="text-slate-950" /> : <PlayCircle size={18} className="text-slate-950" />} {isPlaying ? <PauseCircle size={18} className="text-slate-950" /> : <PlayCircle size={18} className="text-slate-950" />}
</button> </button>
<button aria-label="Next" onClick={skipNext} className="text-slate-400"><SkipForward size={16} /></button> <button aria-label="Next" onClick={skipNext} className="text-slate-400 p-1"><SkipForward size={16} /></button>
</div> </div>
</div> </div>
</div> </div>
@@ -754,7 +641,8 @@ export default function App() {
{navItems.map(({ id, label, icon: Icon }) => { {navItems.map(({ id, label, icon: Icon }) => {
const isActive = activeView === id; const isActive = activeView === id;
return ( return (
<button key={id} onClick={() => handleNavClick(id)} aria-label={label} className={`flex flex-col items-center gap-1 px-3 py-1.5 rounded-lg transition-colors ${isActive ? 'text-blue-400' : 'text-slate-500 hover:text-slate-300'}`}> <button key={id} onClick={() => handleNavClick(id)} aria-label={label}
className={`flex flex-col items-center gap-1 px-3 py-1.5 rounded-lg transition-colors ${isActive ? 'text-blue-400' : 'text-slate-500 hover:text-slate-300'}`}>
<Icon size={20} /> <Icon size={20} />
<span className="text-[10px] font-medium leading-none">{label.split(' ')[0]}</span> <span className="text-[10px] font-medium leading-none">{label.split(' ')[0]}</span>
</button> </button>