Game/Idle/UI/IdleHud.razor
@using Sandbox;
@using Sandbox.UI;
@using System;
@using System.Linq;
@using System.Collections.Generic;
@inherits PanelComponent
@namespace Sandbox

@*
	IMPORTANT — s&box text rendering quirk.

	Razor compiles a literal containing non-ASCII characters (emoji, ×, ·, —)
	into AddMarkupContent, and plain-ASCII literals into AddContent. The panel
	renderer hoists every AddMarkupContent node ahead of every AddContent node,
	so "@A × @B" comes out as "×AB".

	The rule this file follows: an element holds EITHER pure literal text OR a
	single @expression. Anything composed goes through a helper below.
*@

<root>
@if ( Visible )
{
	<div class="idle-root">

		@* ══════════════════════ AMBIENT BACKDROP ══════════════════════ *@
		<div class="idle-bg"></div>
		<div class="idle-bg-glow @GlowClass()"></div>

		@for ( int i = 0; i < 12; i++ )
		{
			float pLeft = (i * 8.3f) % 100f;
			float pDur  = 10f + (i * 0.9f) % 8f;
			float pDel  = (i * 1.4f) % 12f;
			int   pSz   = (i % 4 == 0) ? 4 : 2;
			string pCol = (i % 3 == 0) ? "rgba(94,234,212,0.45)" : "rgba(96,165,250,0.30)";
			<div class="idle-particle"
				 style="left:@pLeft.ToString("F1")%;width:@(pSz)px;height:@(pSz)px;background-color:@pCol;animation-duration:@pDur.ToString("F1")s;animation-delay:@pDel.ToString("F1")s;"></div>
		}

		@* ══════════════════════════ TOP BAR ══════════════════════════ *@
		<div class="idle-top @TutDim("top")">
			<button class="idle-back" onclick=@OnBack>⌂</button>
			<button class="idle-help @HelpClass()" onclick=@OnStartTutorial>?</button>

			<div class="idle-brand">
				<div class="idle-brand-title">THE REACTOR</div>
				<div class="idle-brand-sub">chain reactions, forever</div>
			</div>

			<div class="idle-currencies">
				<div class="idle-cur cores">
					<span class="idle-cur-icon">⚛️</span>
					<span class="idle-cur-val">@CoresLabel()</span>
					<span class="idle-cur-lbl">Cores</span>
				</div>
				<div class="idle-cur rate">
					<span class="idle-cur-icon">📈</span>
					<span class="idle-cur-val">@RateLabel()</span>
					<span class="idle-cur-lbl">per sec</span>
				</div>
				<div class="idle-cur flux">
					<span class="idle-cur-icon">🔷</span>
					<span class="idle-cur-val">@FluxLabel()</span>
					<span class="idle-cur-lbl">Flux</span>
				</div>
				<div class="idle-cur frag">
					<span class="idle-cur-icon">💠</span>
					<span class="idle-cur-val">@FragLabel()</span>
					<span class="idle-cur-lbl">Frags</span>
				</div>
			</div>
		</div>

		@* ═══════════════════════════ BODY ════════════════════════════ *@
		@* Hidden behind a modal — the panel renderer draws emoji glyphs above
		   overlay panels, so the board has to actually leave the tree. *@
		@if ( !ModalOpen )
		{
		<div class="idle-body">

			@* ─────────────────────── LEFT COLUMN ─────────────────────── *@
			<div class="idle-col idle-left @TutDim("left")">

				@* ── Daily contracts ──
				   First card in the column on purpose: it is the only part of
				   the mode with a deadline, so it has to be the first thing a
				   returning player's eye lands on. *@
				<div class="idle-card idle-card-contracts @ContractsCardClass()">
					<div class="idle-card-hd row">
						<span class="idle-card-hd-txt">DAILY CONTRACTS</span>
						<span class="idle-card-hd-side">@ContractResetLabel()</span>
					</div>

					@for ( int ci = 0; ci < IdleContracts.DailyCount; ci++ )
					{
						int slot = ci;
						var contract = IdleContracts.At( S, slot );
						if ( contract != null )
						{
							<div class="idle-contract @ContractClass( slot )" onclick=@(() => OnClaimContract( slot ))>
								<span class="idle-contract-icon">@ContractIcon( slot )</span>
								<div class="idle-contract-body">
									<div class="idle-contract-top">
										<span class="idle-contract-name">@contract.Name</span>
										<span class="idle-contract-reward">@ContractRewardLabel( slot )</span>
									</div>
									<div class="idle-contract-desc">@ContractDescLabel( slot )</div>
									<div class="idle-contract-track">
										<div class="idle-contract-fill" style="width:@Pct( IdleContracts.Fraction( S, slot ) )%;"></div>
									</div>
									<div class="idle-contract-prog">@ContractProgLabel( slot )</div>
								</div>
							</div>
						}
					}

					<div class="idle-card-note">@ContractFootLabel()</div>
				</div>

				<div class="idle-card idle-card-output">
					<div class="idle-card-hd">OUTPUT</div>
					<div class="idle-stat-row">
						<span class="idle-stat-lbl">Cores / sec</span>
						<span class="idle-stat-val hot">@RateLabel()</span>
					</div>
					<div class="idle-stat-row">
						<span class="idle-stat-lbl">Global multiplier</span>
						<span class="idle-stat-val">@GlobalMultLabel()</span>
					</div>
					<div class="idle-stat-row">
						<span class="idle-stat-lbl">Chain exponent</span>
						<span class="idle-stat-val">@ExponentLabel()</span>
					</div>
					<div class="idle-stat-row">
						<span class="idle-stat-lbl">Last chain</span>
						<span class="idle-stat-val">@LastChainLabel()</span>
					</div>
					<div class="idle-stat-row">
						<span class="idle-stat-lbl">Best chain</span>
						<span class="idle-stat-val">@BestChainLabel()</span>
					</div>
					<div class="idle-stat-row">
						<span class="idle-stat-lbl">Board</span>
						<span class="idle-stat-val">@BoardLabel()</span>
					</div>
					<div class="idle-stat-row">
						<span class="idle-stat-lbl">Feed line</span>
						<span class="idle-stat-val @FeedClass()">@FeedLabel()</span>
					</div>
				</div>

				@* ── Next goal ──
				   The Reactor Log is four clicks away in the right panel, which
				   means the long-tail goals are invisible exactly when they are
				   supposed to be pulling. This pins the closest one. *@
				@if ( NextGoal != null )
				{
					<div class="idle-card idle-card-goal">
						<div class="idle-card-hd">NEXT GOAL</div>
						<div class="idle-goal-row">
							<span class="idle-goal-icon">@NextGoal.Icon</span>
							<div class="idle-goal-body">
								<div class="idle-goal-name">@NextGoal.Name</div>
								<div class="idle-goal-desc">@NextGoal.Desc</div>
							</div>
						</div>
						<div class="idle-bar-track">
							<div class="idle-bar-fill goal" style="width:@Pct( NextGoal.Fraction( S ) )%;"></div>
						</div>
						<div class="idle-goal-foot">
							<span class="idle-goal-prog">@NextGoalProgLabel()</span>
							<span class="idle-goal-reward">@NextGoalRewardLabel()</span>
						</div>
					</div>
				}

				@if ( ShowBoosts )
				{
					<div class="idle-card idle-card-active">
						<div class="idle-card-hd">ACTIVE</div>
						@if ( R.SurgeActive )
						{
							<div class="idle-boost surge">
								<span class="idle-boost-icon">⚡</span>
								<span class="idle-boost-lbl">@SurgeBoostLabel()</span>
								<span class="idle-boost-time">@SecondsLabel( R.SurgeTimeLeft )</span>
							</div>
						}
						@foreach ( var boost in R.Boosts )
						{
							var b = boost;
							<div class="idle-boost">
								<span class="idle-boost-icon">@b.Icon</span>
								<span class="idle-boost-lbl">@BoostLabel( b )</span>
								<span class="idle-boost-time">@SecondsLabel( b.SecondsLeft )</span>
							</div>
						}
					</div>
				}

				<div class="idle-card idle-card-condenser">
					<div class="idle-card-hd">CONDENSER</div>
					<div class="idle-bar-track">
						<div class="idle-bar-fill frag" style="width:@Pct( R.FragmentProgressFraction )%;"></div>
					</div>
					<div class="idle-card-note">@CondenserLabel()</div>
					<div class="idle-stat-row">
						<span class="idle-stat-lbl">Today</span>
						<span class="idle-stat-val @CapClass()">@FragTodayLabel()</span>
					</div>
				</div>

				<div class="idle-card">
					<div class="idle-card-hd">MANIFEST</div>
					@for ( int ti = 0; ti < IdleBalance.Tiers.Count; ti++ )
					{
						var tier = IdleBalance.Tiers[ti];
						bool got = R.TierUnlocked( ti );
						<div class="idle-tier @(got ? "on" : "")">
							<span class="idle-tier-icon">@tier.Icon</span>
							<div class="idle-tier-body">
								<div class="idle-tier-name">@tier.Name</div>
								<div class="idle-tier-blurb">@TierBlurb( ti, got )</div>
							</div>
						</div>
					}
				</div>

				@* Rank card — neighbours, not a top-N. See RebuildRankRows(). *@
				<div class="idle-card">
					<div class="idle-card-hd">REACTOR RANK</div>
					@if ( _rankRows.Count == 0 )
					{
						<div class="idle-card-note">@RankEmptyLabel()</div>
					}
					else
					{
						@foreach ( var row in _rankRows )
						{
							var rw = row;
							<div class="idle-rank-row @RankRowClass( rw )">
								<span class="idle-rank-pos">@rw.RankLabel</span>
								<span class="idle-rank-name">@rw.Name</span>
								<span class="idle-rank-val">@rw.ValueLabel</span>
							</div>
						}
						<div class="idle-card-note">@GapLabel()</div>
						@if ( _rankClimb > 0 )
						{
							<div class="idle-card-note up">@ClimbLabel()</div>
						}
					}
				</div>

			</div>

			@* ────────────────────── CENTER COLUMN ────────────────────── *@
			<div class="idle-col idle-center">

				<div class="idle-board-wrap @TutDim("board")">
					<div class="idle-board @SurgeClass() @CascadeClass() @BoardShake()">
						@for ( int r = 0; r < R.Size; r++ )
						{
							<div class="idle-row">
								@for ( int c = 0; c < R.Size; c++ )
								{
									int row = r;
									int col = c;
									var cell = R.Board[row, col];
									bool golden = R.GoldenActive && R.GoldenRow == row && R.GoldenCol == col;

									<div class="idle-cell @CellClass( cell, golden )"
										 style="width:@(CellPx)px;height:@(CellPx)px;"
										 onclick=@(() => OnCellClick( row, col ))>

										@if ( golden )
										{
											<div class="idle-golden">🌟</div>
											<div class="idle-golden-timer">@GoldenTimerLabel()</div>
										}
										@if ( !golden && cell.HasChest )
										{
											<div class="idle-chest" style="@ChestStyle( cell )">@ChestGlyph( cell )</div>
										}
										@if ( !golden && cell.HasChest && cell.MaxHits > 1 )
										{
											<div class="idle-hits">@cell.HitsLeft</div>
										}
										@if ( cell.HasDrone )
										{
											<div class="idle-drone" style="@DroneStyle( cell )">💣</div>
										}
									</div>
								}
							</div>
						}
					</div>

					@foreach ( var floater in _floaters )
					{
						var f = floater;
						<div class="idle-float @f.Cls"
							 style="left:@f.X.ToString("F1")%;top:@f.Y.ToString("F1")%;opacity:@f.Alpha.ToString("F2");transform:translateY(@f.Rise.ToString("F0")px) scale(@f.Scale.ToString("F2"));">
							@f.Text
						</div>
					}

					@if ( _calloutLife > 0f )
					{
						<div class="idle-callout @_calloutCls" style="opacity:@_calloutLife.ToString("F2");">@_calloutText</div>
					}
				</div>

				<div class="idle-controls @TutDim("controls")">

					<button class="idle-overload @OverloadClass()" onclick=@OnOverload>
						<div class="idle-overload-fill" style="width:@OverloadFillPct()%;"></div>
						<span class="idle-overload-label">@OverloadLabel()</span>
						<span class="idle-overload-sub">@OverloadSubLabel()</span>
					</button>

					<div class="idle-surge-wrap">
						<div class="idle-surge-track @SurgeReadyClass()">
							<div class="idle-surge-fill" style="width:@Pct( R.Charge )%;"></div>
							<span class="idle-surge-lbl">@SurgeLabel()</span>
						</div>
						@if ( R.SurgeReady )
						{
							<button class="idle-surge-btn" onclick=@OnSurge>@UnleashLabel()</button>
						}
					</div>
				</div>

				<div class="idle-meltdown-strip @MeltdownStripClass() @TutDim("meltdown")">
					<span class="idle-melt-icon">☢️</span>
					<span class="idle-melt-text">@MeltdownLabel()</span>
					@if ( R.CanMeltdown )
					{
						<button class="idle-melt-btn" onclick=@OnOpenMeltdown>REVIEW</button>
					}
					@if ( !R.CanMeltdown )
					{
						<div class="idle-melt-bar">
							<div class="idle-melt-fill" style="width:@Pct( R.MeltdownProgress )%;"></div>
						</div>
					}
				</div>

				@* Only appears once the perk tree is most of the way bought — the
				   layer is meaningless before that and just clutters the column. *@
				@if ( R.SingularityVisible )
				{
					<div class="idle-sing-strip @SingStripClass() @TutDim("meltdown")">
						<span class="idle-melt-icon">✴️</span>
						<span class="idle-melt-text">@SingularityLabel()</span>
						@if ( R.CanSingularity )
						{
							<button class="idle-sing-btn" onclick=@OnOpenSingularity>COLLAPSE</button>
						}
						@if ( !R.CanSingularity )
						{
							<div class="idle-melt-bar">
								<div class="idle-sing-fill" style="width:@Pct( R.SingularityProgress )%;"></div>
							</div>
						}
					</div>
				}
			</div>

			@* ─────────────────────── RIGHT COLUMN ────────────────────── *@
			<div class="idle-col idle-right @TutDim("right")">

				<div class="idle-tabs">
					<button class="idle-tab @TabClass("reactor")" onclick=@(() => SetTab("reactor"))>⚙️</button>
					<button class="idle-tab @TabClass("chains")"  onclick=@(() => SetTab("chains"))>⛓️</button>
					<button class="idle-tab @TabClass("systems")" onclick=@(() => SetTab("systems"))>🛠️</button>
					<button class="idle-tab fluxtab @TabClass("flux")" onclick=@(() => SetTab("flux"))>🔷</button>
					@if ( R.SingularityVisible )
					{
						<button class="idle-tab singtab @TabClass("powers")" onclick=@(() => SetTab("powers"))>✴️</button>
					}
					<button class="idle-tab @TabClass("log")" onclick=@(() => SetTab("log"))>📜</button>
				</div>

				@if ( IsUpgradeTab )
				{
					<div class="idle-buyrow">
						<span class="idle-buyrow-lbl">BUY</span>
						@foreach ( var amount in BuyAmounts )
						{
							int amt = amount;
							<button class="idle-buy-amt @BuyAmtClass( amt )" onclick=@(() => SetBuyAmount( amt ))>@BuyAmountLabel( amt )</button>
						}
					</div>
				}

				<div class="idle-panel">

					@if ( IsUpgradeTab )
					{
						@foreach ( var upgrade in IdleUpgradeCatalog.InCategory( CurrentCategory ) )
						{
							var up = upgrade;
							bool revealed = R.IsRevealed( up );
							int  lvl      = S.Level( up.Id );
							bool maxed    = lvl >= up.MaxLevel;
							int  count    = revealed ? BuyCount( up.Id ) : 0;
							double cost   = (!revealed || maxed) ? 0 : R.CostFor( up.Id, count );
							bool afford   = revealed && !maxed && count > 0 && Cores >= cost;

							<div class="idle-up @UpgradeClass( revealed, maxed, afford )" onclick=@(() => OnBuy( up.Id ))>
								<div class="idle-up-icon">@UpgradeIcon( up, revealed )</div>
								<div class="idle-up-body">
									<div class="idle-up-top">
										<span class="idle-up-name">@UpgradeName( up, revealed )</span>
										<span class="idle-up-lvl">@LevelLabel( up, lvl, revealed )</span>
									</div>
									<div class="idle-up-desc">@UpgradeDesc( up, revealed )</div>
									@if ( revealed )
									{
										<div class="idle-up-bottom">
											<span class="idle-up-effect">@up.Effect( lvl )</span>
											<span class="idle-up-cost @MaxedClass( maxed )">@CostLabel( maxed, cost, count )</span>
										</div>
									}
								</div>
							</div>
						}
					}

					@if ( _tab == "flux" )
					{
						<div class="idle-flux-head">
							<div class="idle-flux-amount">@FluxHeadLabel()</div>
							<div class="idle-flux-sub">@FluxSubLabel()</div>
						</div>

						@foreach ( var perk in VisiblePerks() )
						{
							var p      = perk;
							int lvl    = S.PerkLevel( p.Id );
							bool maxed = lvl >= p.MaxLevel;
							int cost   = p.CostAt( lvl );
							bool afford = !maxed && S.Flux >= cost;

							<div class="idle-up perk @UpgradeClass( true, maxed, afford )" onclick=@(() => OnBuyPerk( p.Id ))>
								<div class="idle-up-icon">@p.Icon</div>
								<div class="idle-up-body">
									<div class="idle-up-top">
										<span class="idle-up-name">@p.Name</span>
										<span class="idle-up-lvl">@PerkLevelLabel( p, lvl )</span>
									</div>
									<div class="idle-up-desc">@p.Desc</div>
									<div class="idle-up-bottom">
										<span class="idle-up-effect">@p.Effect( lvl )</span>
										<span class="idle-up-cost flux @MaxedClass( maxed )">@PerkCostLabel( maxed, cost )</span>
									</div>
								</div>
							</div>
						}
					}

					@if ( _tab == "powers" )
					{
						<div class="idle-flux-head sing">
							<div class="idle-flux-amount">@ShardHeadLabel()</div>
							<div class="idle-flux-sub">@ShardSubLabel()</div>
						</div>

						@foreach ( var power in IdleUpgradeCatalog.Powers )
						{
							var pw     = power;
							int lvl    = S.PowerLevel( pw.Id );
							bool maxed = lvl >= pw.MaxLevel;
							int cost   = pw.CostAt( lvl );
							bool afford = !maxed && S.Shards >= cost;

							<div class="idle-up power @UpgradeClass( true, maxed, afford )" onclick=@(() => OnBuyPower( pw.Id ))>
								<div class="idle-up-icon">@pw.Icon</div>
								<div class="idle-up-body">
									<div class="idle-up-top">
										<span class="idle-up-name">@pw.Name</span>
										<span class="idle-up-lvl">@PowerLevelLabel( pw, lvl )</span>
									</div>
									<div class="idle-up-desc">@pw.Desc</div>
									<div class="idle-up-bottom">
										<span class="idle-up-effect">@pw.Effect( lvl )</span>
										<span class="idle-up-cost shard @MaxedClass( maxed )">@PowerCostLabel( maxed, cost )</span>
									</div>
								</div>
							</div>
						}
					}

					@if ( _tab == "log" )
					{
						<div class="idle-flux-head">
							<div class="idle-flux-amount">@LogHeadLabel()</div>
							<div class="idle-flux-sub">Each one pays out once.</div>
						</div>

						@foreach ( var achievement in IdleAchievements.All )
						{
							var a = achievement;
							bool done = S.HasAchievement( a.Id );

							<div class="idle-up ach @(done ? "done" : "")">
								<div class="idle-up-icon">@AchIcon( a, done )</div>
								<div class="idle-up-body">
									<div class="idle-up-top">
										<span class="idle-up-name">@a.Name</span>
										<span class="idle-ach-reward">@RewardLabel( a )</span>
									</div>
									<div class="idle-up-desc">@a.Desc</div>
									@if ( !done )
									{
										<div class="idle-ach-bar">
											<div class="idle-ach-fill" style="width:@Pct( a.Fraction( S ) )%;"></div>
										</div>
										<div class="idle-ach-prog">@ProgressLabel( a )</div>
									}
								</div>
							</div>
						}
					}
				</div>
			</div>
		</div>
		}

		@* ═════════════════════════ TOASTS ═════════════════════════ *@
		<div class="idle-toasts">
			@foreach ( var toast in _toasts )
			{
				var t = toast;
				<div class="idle-toast @t.Cls" style="opacity:@t.Alpha.ToString("F2");">
					<span class="idle-toast-icon">@t.Icon</span>
					<span class="idle-toast-text">@t.Text</span>
				</div>
			}
		</div>

		@* ═══════════════════ WELCOME BACK OVERLAY ═══════════════════ *@
		@if ( R.OfflinePending )
		{
			<div class="idle-modal-bg"></div>
			<div class="idle-modal offline">
				<div class="idle-modal-eyebrow">REACTOR REPORT</div>
				<div class="idle-modal-title">Welcome back</div>
				<div class="idle-modal-sub">@OfflineSubLabel()</div>

				<div class="idle-offline-haul">
					<div class="idle-offline-amount">@OfflineAmountLabel()</div>
					<div class="idle-offline-lbl">CORES</div>
				</div>

				@if ( R.PendingOfflineFrags > 0 )
				{
					<div class="idle-offline-extra">@OfflineFragLabel()</div>
				}

				@if ( OfflineTankFull )
				{
					<div class="idle-offline-warn">@OfflineWarnLabel()</div>
				}

				<button class="idle-modal-btn" onclick=@OnClaimOffline>COLLECT</button>
			</div>
		}

		@* ═══════════════════ DAILY STREAK OVERLAY ═══════════════════ *@
		@if ( !R.OfflinePending && !_showIntro && R.DailyPending )
		{
			<div class="idle-modal-bg"></div>
			<div class="idle-modal daily">
				<div class="idle-modal-eyebrow">DAILY CYCLE</div>
				<div class="idle-modal-title">@DailyTitleLabel()</div>
				<div class="idle-modal-sub">Come back tomorrow to keep the streak climbing. Day 7 pays eight times.</div>

				<div class="idle-streak-row">
					@for ( int d = 1; d <= 7; d++ )
					{
						int day = d;
						<div class="idle-streak-pip @StreakPipClass( day )">@day</div>
					}
				</div>

				<div class="idle-daily-reward">
					<div class="idle-daily-mult">@DailyMultLabel()</div>
					<div class="idle-daily-lbl">@DailyRewardLabel()</div>
				</div>

				<button class="idle-modal-btn" onclick=@OnClaimDaily>CLAIM</button>
			</div>
		}

		@* ══════════════════════ MELTDOWN MODAL ══════════════════════ *@
		@if ( !R.OfflinePending && !_showIntro && !R.DailyPending && _showMeltdown )
		{
			<div class="idle-modal-bg"></div>
			<div class="idle-modal meltdown">
				<div class="idle-modal-eyebrow">PRESTIGE</div>
				<div class="idle-modal-title">MELTDOWN</div>
				<div class="idle-modal-sub">Collapse this cycle into permanent Flux. Every Flux you have ever earned adds income forever, and Flux buys perks that survive every reset.</div>

				<div class="idle-melt-payout">
					<div class="idle-melt-payout-val">@MeltPayoutLabel()</div>
					<div class="idle-melt-payout-lbl">FLUX</div>
				</div>

				@* The Flux number alone does not answer the only question that
				   matters at this button — "am I actually going to be faster
				   afterwards". This does. *@
				<div class="idle-melt-gain">
					<span class="idle-melt-gain-from">@MeltMultBeforeLabel()</span>
					<span class="idle-melt-gain-arrow">→</span>
					<span class="idle-melt-gain-to">@MeltMultAfterLabel()</span>
					<span class="idle-melt-gain-lbl">permanent income</span>
				</div>

				<div class="idle-melt-cols">
					<div class="idle-melt-col keep">
						<div class="idle-melt-col-hd">KEPT</div>
						<div class="idle-melt-line">Flux and every perk</div>
						<div class="idle-melt-line">Reactor Log entries</div>
						<div class="idle-melt-line">Records and Fragments</div>
						<div class="idle-melt-line">Kickstart head start</div>
					</div>
					<div class="idle-melt-col lose">
						<div class="idle-melt-col-hd">BURNED</div>
						<div class="idle-melt-line">@BurnedCoresLabel()</div>
						<div class="idle-melt-line">All Core upgrades</div>
						<div class="idle-melt-line">Reactor Frame size</div>
						<div class="idle-melt-line">Unlocked chest tiers</div>
					</div>
				</div>

				<div class="idle-modal-btns">
					<button class="idle-modal-btn ghost" onclick=@OnCloseMeltdown>Not yet</button>
					<button class="idle-modal-btn danger" onclick=@OnMeltdown>MELT DOWN</button>
				</div>
			</div>
		}

		@* ════════════════════ SINGULARITY MODAL ════════════════════ *@
		@if ( !R.OfflinePending && !_showIntro && !R.DailyPending && !_showMeltdown && _showSingularity )
		{
			<div class="idle-modal-bg"></div>
			<div class="idle-modal singularity">
				<div class="idle-modal-eyebrow">SECOND PRESTIGE</div>
				<div class="idle-modal-title">SINGULARITY</div>
				<div class="idle-modal-sub">Collapse your entire Flux career into Shards. Shards buy Powers — and Powers change how a cycle runs, not just how much it pays.</div>

				<div class="idle-melt-payout">
					<div class="idle-sing-payout-val">@SingPayoutLabel()</div>
					<div class="idle-melt-payout-lbl">SHARDS</div>
				</div>

				<div class="idle-melt-cols">
					<div class="idle-melt-col keep">
						<div class="idle-melt-col-hd">KEPT</div>
						<div class="idle-melt-line">Shards and every Power</div>
						<div class="idle-melt-line">Reactor Log entries</div>
						<div class="idle-melt-line">Records, Fragments, pet</div>
						<div class="idle-melt-line">@ShardKeepLabel()</div>
					</div>
					<div class="idle-melt-col lose">
						<div class="idle-melt-col-hd">COLLAPSED</div>
						<div class="idle-melt-line">@BurnedFluxLabel()</div>
						<div class="idle-melt-line">Every Flux perk</div>
						<div class="idle-melt-line">All Core upgrades</div>
						<div class="idle-melt-line">This cycle's Cores</div>
					</div>
				</div>

				<div class="idle-modal-btns">
					<button class="idle-modal-btn ghost" onclick=@OnCloseSingularity>Not yet</button>
					<button class="idle-modal-btn sing" onclick=@OnSingularity>COLLAPSE</button>
				</div>
			</div>
		}

		@* ════════════════════════ INTRO MODAL ═══════════════════════ *@
		@if ( !R.OfflinePending && _showIntro )
		{
			<div class="idle-modal-bg"></div>
			<div class="idle-modal intro">
				<div class="idle-modal-eyebrow">HOW IT WORKS</div>
				<div class="idle-modal-title">THE REACTOR</div>
				<div class="idle-modal-sub">A board that plays itself. Chests drop, drones bomb them, chain reactions pay Cores. You never lose here — you only grow.</div>

				<div class="idle-intro-steps">
					<div class="idle-intro-step">
						<span class="idle-intro-num">1</span>
						<span class="idle-intro-txt">Spend Cores on the right. Longer chains are worth far more than richer chests.</span>
					</div>
					<div class="idle-intro-step">
						<span class="idle-intro-num">2</span>
						<span class="idle-intro-txt">Hit OVERLOAD, or press SPACE, for a burst. It charges the Surge meter — ten times output when you unleash it.</span>
					</div>
					<div class="idle-intro-step">
						<span class="idle-intro-num">3</span>
						<span class="idle-intro-txt">It keeps running while you are on the menu or another screen. Close the game and it stops.</span>
					</div>
					<div class="idle-intro-step">
						<span class="idle-intro-num">4</span>
						<span class="idle-intro-txt">Cores condense into Fragments, which feed your companion for a permanent output bonus. Meltdown turns a whole cycle into Flux.</span>
					</div>
				</div>

				<div class="idle-modal-btns">
					<button class="idle-modal-btn ghost" onclick=@OnCloseIntro>I'll figure it out</button>
					<button class="idle-modal-btn" onclick=@OnIntroTutorial>SHOW ME HOW</button>
				</div>
			</div>
		}

		@* ═════════════════════ GUIDED WALKTHROUGH ═════════════════════
		   Coach marks over the live HUD. The board keeps running underneath —
		   an idle game that freezes for its own tutorial teaches the wrong
		   thing about what the game is. Everything here is measured from the
		   real panels at runtime, so it cannot drift out of sync with layout.
		*@
		@if ( TutorialActive )
		{
			<div class="rt-layer">

				@* Four quads around the spotlight, rather than one dim sheet with
				   a hole — the panel renderer has no masking, so this is how you
				   get a real cut-out that still swallows clicks everywhere else. *@
				<div class="rt-dim" style="@DimTopStyle()"></div>
				<div class="rt-dim" style="@DimBottomStyle()"></div>
				<div class="rt-dim" style="@DimLeftStyle()"></div>
				<div class="rt-dim" style="@DimRightStyle()"></div>

				@if ( TutHasSpot )
				{
					<div class="rt-ring" style="@SpotStyle()"></div>
					<div class="rt-spot" style="@SpotStyle()">
						<div class="rt-corner tl"></div>
						<div class="rt-corner tr"></div>
						<div class="rt-corner bl"></div>
						<div class="rt-corner br"></div>
					</div>
				}

				@* The class alternates per step purely so the entrance animation
				   replays — a stable class string would only ever play once. *@
				<div class="rt-card @TutCardAnimClass()" style="@CardStyle()">
					<div class="rt-card-top">
						<span class="rt-eyebrow">HOW TO PLAY</span>
						<span class="rt-count">@TutCountLabel()</span>
					</div>

					<div class="rt-title">@TutTitleLabel()</div>
					<div class="rt-text">@TutTextLabel()</div>

					<div class="rt-dots">
						@for ( int di = 0; di < IdleTutorial.Count; di++ )
						{
							<div class="rt-dot @DotClass( di )"></div>
						}
					</div>

					@if ( TutWaitingOnAction )
					{
						<div class="rt-hint">
							<span class="rt-hint-pip">👆</span>
							<span class="rt-hint-txt">@TutHintLabel()</span>
						</div>
					}
					@if ( !TutWaitingOnAction )
					{
						<button class="rt-next" onclick=@OnTutNext>@TutNextLabel()</button>
					}

					<div class="rt-foot">
						<button class="rt-back @TutBackClass()" onclick=@OnTutBack>Back</button>
						<button class="rt-skip" onclick=@OnTutSkip>Skip the tour</button>
					</div>
				</div>
			</div>
		}
	</div>
}
</root>

@code {
	public static IdleHud Instance { get; private set; }

	IdleReactor       R    => IdleReactor.Instance;
	ChainReactionGame Game => ChainReactionGame.Instance;
	IdleSave          S    => ChainReactionGame.Save?.Idle;

	bool Visible => Game?.GameStarted == true
				 && Game?.Mode == GameMode.Idle
				 && R != null && S != null;

	double Cores => S?.Cores ?? 0;

	bool ModalOpen  => R.OfflinePending || R.DailyPending || _showMeltdown || _showSingularity || _showIntro;
	bool ShowBoosts => R.Boosts.Count > 0 || R.SurgeActive;
	bool OfflineTankFull => R.PendingOfflineSeconds > R.OfflineCapHours * 3600f;

	// ── UI state ──────────────────────────────────────────────────
	string _tab       = "reactor";
	int    _buyAmount = 1;                      // -1 == MAX
	bool   _showMeltdown;
	bool   _showSingularity;
	bool   _showIntro;
	bool   _subscribed;
	bool   _wasVisible;

	static readonly int[] BuyAmounts = { 1, 10, 25, -1 };

	bool IsUpgradeTab => _tab == "reactor" || _tab == "chains" || _tab == "systems";

	IdleUpgradeCategory CurrentCategory => _tab switch
	{
		"reactor" => IdleUpgradeCategory.Reactor,
		"chains"  => IdleUpgradeCategory.Chains,
		_         => IdleUpgradeCategory.Systems,
	};

	// ── Floating numbers ──────────────────────────────────────────
	class Floater
	{
		public string Text;
		public string Cls;
		public float X, Y;
		public float Life, MaxLife;
		public float Alpha => MathF.Min( 1f, Life / 0.35f );
		public float Rise  => -(MaxLife - Life) * 42f;
		public float Scale => 0.85f + MathF.Min( 0.35f, (MaxLife - Life) * 1.2f );
	}
	readonly List<Floater> _floaters = new();

	class ToastMsg
	{
		public string Icon;
		public string Text;
		public string Cls;
		public float Life;
		public float Alpha => MathF.Min( 1f, Life / 0.4f );
	}
	readonly List<ToastMsg> _toasts = new();

	string _calloutText = "";
	string _calloutCls  = "";
	float  _calloutLife;

	// Board shake is kept local rather than going through ScreenShaker so the
	// class string stays stable between rebuilds — a changing class would
	// restart the animation on every repaint and read as a vibration.
	string _shakeCls = "";
	float  _shakeUntil;

	// ══════════════════════════════════════════════════════════════
	// Guided walkthrough
	//
	// Coach marks measured off the real panels every frame, so the spotlight
	// cannot drift when the board resizes, the window changes shape, or a card
	// appears above the one being pointed at. The previous tutorial in this
	// codebase hard-coded pixel rectangles and needed a debug nudge UI to keep
	// them honest; this one has nothing to keep honest.
	// ══════════════════════════════════════════════════════════════

	bool  _tutRunning;
	int   _tutStep;
	float _tutStepAt;          // Time.Now the step opened, for the action grace period

	// Spotlight rect and card position, in design pixels relative to the root.
	float _spotX, _spotY, _spotW, _spotH;
	float _cardX, _cardY;
	float _rootW = 1920f, _rootH = 1080f;
	bool  _spotFound;

	// Measured on the frame after the card first renders; the fallback only
	// ever shows for that single frame.
	float _cardW = 380f, _cardH = 240f;

	const float CardGap    = 26f;   // spotlight → card
	const float EdgePad    = 20f;   // card → screen edge
	const float ActionGrace = 0.45f; // the click that opened a step cannot also close it

	/// <summary>
	/// Set by the menu's "How to play" button. Consumed on the frame the HUD
	/// becomes visible, since the tour points at panels that do not exist until
	/// then.
	/// </summary>
	public static bool PendingTutorial { get; set; }

	public bool TutorialActive => _tutRunning && Visible && !ModalOpen;

	bool TutHasSpot => _spotFound && _spotW > 1f && _spotH > 1f;

	IdleTutStep CurrentTutStep
		=> _tutStep >= 0 && _tutStep < IdleTutorial.Count ? IdleTutorial.Steps[_tutStep] : null;

	bool TutWaitingOnAction
		=> CurrentTutStep != null && CurrentTutStep.Trigger != IdleTutTrigger.Button;

	public void StartTutorial()
	{
		_tutRunning = true;
		_tutStep    = 0;
		_tutStepAt  = Time.Now;
		_showIntro  = false;
		_spotFound  = false;
		MeasureTutorial();
		StateHasChanged();
	}

	void EndTutorial( bool completed )
	{
		_tutRunning = false;
		if ( S != null )
		{
			S.TutorialOffered = true;
			if ( completed ) S.TutorialDone = true;
			ChainReactionGame.Save?.Save();
		}
		StateHasChanged();
	}

	void OnTutNext()
	{
		Sound.Play( "UI_Button" );
		AdvanceTutorial();
	}

	void OnTutBack()
	{
		if ( _tutStep <= 0 ) return;
		Sound.Play( "UI_Button" );
		_tutStep--;
		_tutStepAt = Time.Now;
		MeasureTutorial();
		StateHasChanged();
	}

	void OnTutSkip()
	{
		Sound.Play( "UI_Button" );
		EndTutorial( completed: true );
	}

	void AdvanceTutorial()
	{
		_tutStep++;
		_tutStepAt = Time.Now;

		if ( _tutStep >= IdleTutorial.Count )
		{
			Sound.Play( "PlayButton" );
			EndTutorial( completed: true );
			return;
		}

		MeasureTutorial();
		StateHasChanged();
	}

	/// <summary>
	/// Satisfies a step that is waiting on the player doing something. The grace
	/// period stops the very click that advanced onto a step from also
	/// advancing off it — buying an upgrade lands on the "buy an upgrade" step
	/// otherwise, and the player never reads it.
	/// </summary>
	void TutSatisfy( IdleTutTrigger trigger )
	{
		if ( !_tutRunning ) return;

		var step = CurrentTutStep;
		if ( step == null || step.Trigger != trigger ) return;
		if ( Time.Now - _tutStepAt < ActionGrace ) return;

		Sound.Play( "Notification_1" );
		AdvanceTutorial();
	}

	/// <summary>Recursive class lookup rather than Descendants, so the first match
	/// found is the shallowest one — an inner panel sharing a class cannot win.</summary>
	static Sandbox.UI.Panel FindByClass( Sandbox.UI.Panel from, string cls )
	{
		if ( from == null || string.IsNullOrEmpty( cls ) ) return null;
		if ( from.HasClass( cls ) ) return from;

		foreach ( var child in from.Children )
		{
			var hit = FindByClass( child, cls );
			if ( hit != null ) return hit;
		}
		return null;
	}

	void MeasureTutorial()
	{
		var root = Panel;
		if ( root == null ) return;

		float scale = root.ScaleFromScreen;
		var   rr    = root.Box.Rect;

		_rootW = MathF.Max( 320f, rr.Width  * scale );
		_rootH = MathF.Max( 240f, rr.Height * scale );

		var step = CurrentTutStep;
		_spotFound = false;

		if ( step != null && !string.IsNullOrEmpty( step.Target ) )
		{
			var target = FindByClass( root, step.Target );
			if ( target != null )
			{
				var tr = target.Box.Rect;
				if ( tr.Width > 1f && tr.Height > 1f )
				{
					_spotX = (tr.Left - rr.Left) * scale - step.Pad;
					_spotY = (tr.Top  - rr.Top ) * scale - step.Pad;
					_spotW = tr.Width  * scale + step.Pad * 2f;
					_spotH = tr.Height * scale + step.Pad * 2f;

					// A column that runs off the bottom of the viewport reports a
					// rect taller than the screen; clamping keeps the dim quads
					// from collapsing to nothing.
					_spotX = Math.Clamp( _spotX, 0f, _rootW - 8f );
					_spotY = Math.Clamp( _spotY, 0f, _rootH - 8f );
					_spotW = Math.Clamp( _spotW, 8f, _rootW - _spotX );
					_spotH = Math.Clamp( _spotH, 8f, _rootH - _spotY );

					_spotFound = true;
				}
			}
		}

		// The card measures itself once it exists; until then the constructor
		// defaults are close enough that nothing visibly jumps.
		var card = FindByClass( root, "rt-card" );
		if ( card != null )
		{
			var cr = card.Box.Rect;
			if ( cr.Width > 1f && cr.Height > 1f )
			{
				_cardW = cr.Width  * scale;
				_cardH = cr.Height * scale;
			}
		}

		PlaceCard( step );
	}

	void PlaceCard( IdleTutStep step )
	{
		if ( step == null || !TutHasSpot )
		{
			_cardX = (_rootW - _cardW) * 0.5f;
			_cardY = (_rootH - _cardH) * 0.5f;
			return;
		}

		float cx = _spotX + _spotW * 0.5f;
		float cy = _spotY + _spotH * 0.5f;

		var place = step.Place;

		// Fall back to the other side when the requested one has no room. This
		// is what makes the script survive an ultrawide or a small window
		// without every step needing a per-aspect-ratio override.
		if ( place == IdleTutPlace.Right && _spotX + _spotW + CardGap + _cardW > _rootW - EdgePad )
			place = IdleTutPlace.Left;
		else if ( place == IdleTutPlace.Left && _spotX - CardGap - _cardW < EdgePad )
			place = IdleTutPlace.Right;
		else if ( place == IdleTutPlace.Above && _spotY - CardGap - _cardH < EdgePad )
			place = IdleTutPlace.Below;
		else if ( place == IdleTutPlace.Below && _spotY + _spotH + CardGap + _cardH > _rootH - EdgePad )
			place = IdleTutPlace.Above;

		switch ( place )
		{
			case IdleTutPlace.Left:
				_cardX = _spotX - CardGap - _cardW;
				_cardY = cy - _cardH * 0.5f;
				break;

			case IdleTutPlace.Right:
				_cardX = _spotX + _spotW + CardGap;
				_cardY = cy - _cardH * 0.5f;
				break;

			case IdleTutPlace.Above:
				_cardX = cx - _cardW * 0.5f;
				_cardY = _spotY - CardGap - _cardH;
				break;

			case IdleTutPlace.Below:
				_cardX = cx - _cardW * 0.5f;
				_cardY = _spotY + _spotH + CardGap;
				break;

			default:
				_cardX = (_rootW - _cardW) * 0.5f;
				_cardY = (_rootH - _cardH) * 0.5f;
				break;
		}

		_cardX = Math.Clamp( _cardX, EdgePad, MathF.Max( EdgePad, _rootW - _cardW - EdgePad ) );
		_cardY = Math.Clamp( _cardY, EdgePad, MathF.Max( EdgePad, _rootH - _cardH - EdgePad ) );
	}

	// ── Inline styles ─────────────────────────────────────────────
	// Ints only. A decimal separator from a French locale would be a comma and
	// would silently kill the whole declaration.

	static string Px( float v ) => ((int)MathF.Round( v )).ToString();

	string SpotStyle()
		=> $"left:{Px( _spotX )}px;top:{Px( _spotY )}px;width:{Px( _spotW )}px;height:{Px( _spotH )}px;";

	string CardStyle() => $"left:{Px( _cardX )}px;top:{Px( _cardY )}px;";

	// With no spotlight the top quad covers everything and the other three
	// collapse — which is exactly the plain dim sheet a centred step wants.
	string DimTopStyle()
		=> TutHasSpot
			? $"left:0px;top:0px;width:{Px( _rootW )}px;height:{Px( _spotY )}px;"
			: $"left:0px;top:0px;width:{Px( _rootW )}px;height:{Px( _rootH )}px;";

	string DimBottomStyle()
	{
		if ( !TutHasSpot ) return "width:0px;height:0px;";
		float top = _spotY + _spotH;
		return $"left:0px;top:{Px( top )}px;width:{Px( _rootW )}px;height:{Px( _rootH - top )}px;";
	}

	string DimLeftStyle()
		=> TutHasSpot
			? $"left:0px;top:{Px( _spotY )}px;width:{Px( _spotX )}px;height:{Px( _spotH )}px;"
			: "width:0px;height:0px;";

	string DimRightStyle()
	{
		if ( !TutHasSpot ) return "width:0px;height:0px;";
		float left = _spotX + _spotW;
		return $"left:{Px( left )}px;top:{Px( _spotY )}px;width:{Px( _rootW - left )}px;height:{Px( _spotH )}px;";
	}

	// ── Labels ────────────────────────────────────────────────────

	string TutTitleLabel() => CurrentTutStep?.Title ?? "";
	string TutTextLabel()  => CurrentTutStep?.Text  ?? "";
	string TutHintLabel()  => CurrentTutStep?.Hint  ?? "";
	string TutCountLabel() => $"{_tutStep + 1} / {IdleTutorial.Count}";

	string TutNextLabel()
		=> _tutStep >= IdleTutorial.Count - 1 ? "RUN THE REACTOR" : "GOT IT";

	string TutBackClass() => _tutStep <= 0 ? "hidden" : "";

	string TutCardAnimClass() => (_tutStep % 2) == 0 ? "in-a" : "in-b";

	/// <summary>
	/// Dims a HUD block that is not the subject of the current step.
	///
	/// The dim quads are click-blockers and a background scrim; they cannot do
	/// this job on their own, because this renderer draws emoji above overlay
	/// panels and every chest, icon and tab glyph would shine straight through
	/// the scrim at full brightness. Ancestor opacity takes the glyphs with it.
	/// </summary>
	string TutDim( string region )
	{
		if ( !TutorialActive ) return "";

		string want = CurrentTutStep?.Region ?? "";
		if ( string.IsNullOrEmpty( want ) ) return "";   // centred step dims nothing

		return region == want ? "" : "tut-dim";
	}

	string DotClass( int i )
	{
		if ( i == _tutStep ) return "on";
		return i < _tutStep ? "done" : "";
	}

	string HelpClass() => _tutRunning ? "on" : "";

	int CellPx => R == null ? 92 : R.Size switch
	{
		5 => 96,
		6 => 84,
		7 => 74,
		8 => 66,
		_ => 59,
	};

	// ══════════════════════════════════════════════════════════════
	// ══════════════════════════════════════════════════════════════
	// Reactor rank card
	//
	// Deliberately NOT a top-N board. A top-N motivates whoever is already in
	// it and is scenery for everyone else; the crossable goal is the gap to the
	// player directly above, so the card shows a window around the local player
	// (two up, you, one down).
	//
	// The board snapshot is stale between refreshes but S.AllTimeCores is live,
	// so the local row and the gap are always computed from the local value.
	// That makes the gap tick down while the player watches, which is the whole
	// point of the card.
	// ══════════════════════════════════════════════════════════════

	class RankRow
	{
		public string RankLabel;
		public string Name;
		public string ValueLabel;
		public bool   IsMe;
	}
	readonly List<RankRow> _rankRows = new();

	Sandbox.Services.Leaderboards.Board2 _rankBoard;
	bool  _rankLoading;
	bool  _rankFetched;
	float _rankNextFetch;
	int    _myRank;       // 1-based, 0 == not on the board
	double _nextUpValue;  // stat value of the player directly above, 0 == none above
	int   _rankClimb;     // places gained since the last session
	int   _rankTotalPlayers; // Board2.TotalEntries — real population, not window size

	const float  RankRefreshSeconds = 90f;
	// Same stat the main-menu leaderboard ranks on, so the two can never disagree.
	static string RankStat => CommunityGoal.CoresStat;

	/// <summary>
	/// Network call — only ever driven by the throttle in OnUpdate, never from
	/// the render path. BuildHash changes many times a second, so fetching from
	/// there would hammer the backend into rate-limiting us.
	/// </summary>
	async System.Threading.Tasks.Task LoadRank()
	{
		if ( _rankLoading ) return;
		_rankLoading = true;

		try
		{
			var b = Sandbox.Services.Leaderboards.GetFromStat( RankStat );
			b.SetAggregationMax();
			b.SetSortDescending();

			// CenterOnMe is exactly what this card wants: Board2 returns the
			// entries surrounding a target player, so asking for a small window
			// centred on us gives the neighbours directly. No manual slicing.
			b.CenterOnMe();
			b.MaxEntries = 5;

			await b.Refresh();
			_rankBoard = b;
			_rankTotalPlayers = (int)b.TotalEntries;

			Log.Info( $"Rank '{RankStat}' — window: {b.Entries?.Length ?? -1}, board total: {b.TotalEntries}" );
		}
		catch ( Exception e )
		{
			Log.Warning( $"Reactor rank: {e.Message}" );
		}

		_rankLoading = false;
		_rankFetched = true;
		RebuildRankRows();
		StateHasChanged();
	}

	void RebuildRankRows()
	{
		_rankRows.Clear();
		_myRank      = 0;
		_nextUpValue = 0;
		_rankClimb   = 0;

		var entries = _rankBoard?.Entries;
		if ( entries == null || entries.Length == 0 ) return;

		// CenterOnMe means these indices are window-relative, NOT board-relative,
		// so ranks must come from Entry.Rank. Using index+1 here would label the
		// window "#1..#5" no matter where the player actually sits.
		long myId  = Sandbox.Connection.Local?.SteamId ?? 0;
		int  meIdx = -1;
		for ( int i = 0; i < entries.Length; i++ )
		{
			if ( entries[i].SteamId == myId ) { meIdx = i; break; }
		}

		if ( meIdx < 0 )
		{
			// No entry for us yet — show the window we were given so there is
			// still a named target to chase rather than an empty card.
			for ( int i = 0; i < entries.Length; i++ )
				_rankRows.Add( MakeRankRow( entries[i], false ) );

			_nextUpValue = entries[entries.Length - 1].Value;
			return;
		}

		_myRank = (int)entries[meIdx].Rank;

		int start = Math.Max( 0, meIdx - 2 );
		int end   = Math.Min( entries.Length - 1, meIdx + 1 );
		for ( int i = start; i <= end; i++ )
			_rankRows.Add( MakeRankRow( entries[i], i == meIdx ) );

		if ( meIdx > 0 ) _nextUpValue = entries[meIdx - 1].Value;

		// Places gained since we last recorded a rank. Persisting this is what
		// turns offline production into "I moved up while I was away".
		int prev = S?.LastReactorRank ?? 0;
		if ( prev > 0 && _myRank > 0 && _myRank < prev ) _rankClimb = prev - _myRank;

		if ( S != null && _myRank > 0 && S.LastReactorRank != _myRank )
		{
			S.LastReactorRank = _myRank;
			ChainReactionGame.Save?.Save();
		}
	}

	RankRow MakeRankRow( Sandbox.Services.Leaderboards.Board2.Entry e, bool isMe )
	{
		// The snapshot lags our own earnings, so never show the player a number
		// lower than what they have actually banked.
		double val = isMe
			? Math.Max( e.Value, S?.AllTimeCores ?? 0 )
			: e.Value;

		return new RankRow
		{
			RankLabel  = "#" + e.Rank.ToString(),
			Name       = isMe ? "you" : RankName( e ),
			ValueLabel = IdleFormat.Short( val ),
			IsMe       = isMe,
		};
	}

	string RankName( Sandbox.Services.Leaderboards.Board2.Entry e )
	{
		if ( !string.IsNullOrWhiteSpace( e.DisplayName ) ) return e.DisplayName;
		try
		{
			var friend = new Sandbox.Friend( e.SteamId );
			if ( !string.IsNullOrWhiteSpace( friend.Name ) ) return friend.Name;
		}
		catch { }
		return "Anonymous";
	}

	string RankRowClass( RankRow r ) => r.IsMe ? "me" : "";

	string RankEmptyLabel()
	{
		if ( !_rankFetched ) return "Reading the board";
		return "Produce Cores to enter the rankings";
	}

	string GapLabel()
	{
		if ( _nextUpValue <= 0 ) return "Top of the board";

		double mine = S?.AllTimeCores ?? 0;
		double gap  = _nextUpValue - mine;

		if ( gap <= 0 ) return "Ahead — board updates shortly";
		return IdleFormat.Short( gap ) + " Cores to pass";
	}

	string ClimbLabel()
	{
		if ( _rankClimb == 1 ) return "Up 1 place since last time";
		return "Up " + _rankClimb.ToString() + " places since last time";
	}

	// Lifecycle
	// ══════════════════════════════════════════════════════════════

	protected override void OnStart()
	{
		Instance = this;
	}

	protected override void OnUpdate()
	{
		if ( !_subscribed && R != null ) Subscribe();

		bool vis = Visible;
		if ( vis != _wasVisible )
		{
			_wasVisible = vis;
			if ( vis )
			{
				_floaters.Clear();
				_toasts.Clear();
				_calloutLife  = 0f;
				_shakeUntil   = 0f;
				_showMeltdown    = false;
				_showSingularity = false;

				// Offered on the walkthrough flag rather than IntroShown, so a
				// save from before the walkthrough existed still gets asked
				// once instead of being silently skipped past it.
				_showIntro    = S != null && !S.TutorialDone && !S.TutorialOffered;
				_tutRunning   = false;
				_tab          = "reactor";
				_rankNextFetch = 0f;   // refetch on entering idle mode
				_nextGoalAt    = 0f;
				RefreshNextGoal();

				if ( PendingTutorial )
				{
					PendingTutorial = false;
					StartTutorial();
				}
			}
			StateHasChanged();
		}

		if ( !vis ) return;

		// Wall-clock throttle. Never move this into BuildHash or the render path.
		if ( RealTime.Now >= _rankNextFetch )
		{
			_rankNextFetch = RealTime.Now + RankRefreshSeconds;
			_ = LoadRank();
		}

		// SPACE is the detonate key everywhere else in the game, so it fires the
		// reactor's manual burst here too.
		if ( Input.Pressed( "Jump" ) && R.OverloadReady && !ModalOpen )
			R.Overload();

		if ( RealTime.Now >= _nextGoalAt )
		{
			_nextGoalAt = RealTime.Now + 0.5f;
			RefreshNextGoal();
		}

		// Re-measured every frame rather than only on step change: the board
		// resizes on a Reactor Frame purchase, cards appear and disappear in the
		// left column, and the window can be dragged to another size mid-tour.
		if ( TutorialActive ) MeasureTutorial();

		float dt = Time.Delta;

		for ( int i = _floaters.Count - 1; i >= 0; i-- )
		{
			_floaters[i].Life -= dt;
			if ( _floaters[i].Life <= 0f ) _floaters.RemoveAt( i );
		}

		for ( int i = _toasts.Count - 1; i >= 0; i-- )
		{
			_toasts[i].Life -= dt;
			if ( _toasts[i].Life <= 0f ) _toasts.RemoveAt( i );
		}

		if ( _calloutLife > 0f ) _calloutLife -= dt * 1.4f;
	}

	void Subscribe()
	{
		var r = R;
		if ( r == null || _subscribed ) return;

		r.OnPop        += OnPop;
		r.OnToast      += OnToastEvent;
		r.OnDetonation += OnDetonation;

		// Walkthrough gates — a step that says "press OVERLOAD" only closes when
		// the player actually presses OVERLOAD.
		r.OnManualOverload += () => TutSatisfy( IdleTutTrigger.Overload );
		r.OnUpgradeBought  += () => TutSatisfy( IdleTutTrigger.Buy );
		r.OnManualCrack    += () => TutSatisfy( IdleTutTrigger.CellClick );

		_subscribed = true;
	}

	/// <summary>
	/// Rebuilds only when something on screen would actually differ.
	///
	/// The first version forced a repaint 15 times a second, which meant
	/// rebuilding the board, both side columns and every upgrade row constantly
	/// — that was the source of the stutter. Between detonations almost nothing
	/// changes, so hashing real state instead of a clock cuts the repaint rate
	/// by roughly an order of magnitude.
	/// </summary>
	protected override int BuildHash()
	{
		if ( !Visible ) return 0;

		var hc = new HashCode();
		hc.Add( _tab );
		hc.Add( _buyAmount );
		hc.Add( _showMeltdown );
		hc.Add( _showSingularity );
		hc.Add( R?.CascadeActive ?? false );
		hc.Add( S?.Shards ?? 0 );
		hc.Add( S?.ShardsEarned ?? 0 );
		hc.Add( S?.Singularities ?? 0 );
		hc.Add( R?.SingularityVisible ?? false );
		hc.Add( _showIntro );
		hc.Add( ModalOpen );

		// Walkthrough — geometry is quantised to whole pixels, so a static
		// spotlight leaves the hash alone and the panel is not rebuilt.
		hc.Add( TutorialActive );
		hc.Add( _tutStep );
		hc.Add( TutHasSpot );
		hc.Add( (int)_spotX ); hc.Add( (int)_spotY );
		hc.Add( (int)_spotW ); hc.Add( (int)_spotH );
		hc.Add( (int)_cardX ); hc.Add( (int)_cardY );

		// Contracts — progress bars, claim state and the reset countdown
		for ( int i = 0; i < IdleContracts.DailyCount; i++ )
		{
			hc.Add( (int)(IdleContracts.Fraction( S, i ) * 200f) );
			hc.Add( IdleContracts.IsClaimed( S, i ) );
		}
		hc.Add( (int)(IdleContracts.SecondsUntilReset() / 60.0) );

		var goal = NextGoal;
		hc.Add( goal?.Id ?? "" );
		hc.Add( goal == null ? 0 : (int)(goal.Fraction( S ) * 200f) );

		// Currencies — quantised to what the labels actually show
		hc.Add( (long)Cores );
		hc.Add( (long)R.Cps );
		hc.Add( S.Flux );
		hc.Add( S.FluxEarned );
		hc.Add( _rankRows.Count );
		hc.Add( _myRank );
		hc.Add( _rankClimb );
		hc.Add( ChainReactionGame.Save?.TotalFragments ?? 0 );
		hc.Add( S.TodayReactorFragments );
		hc.Add( (int)(R.FragmentProgressFraction * 60f) );

		// Live controls
		hc.Add( (int)(R.OverloadCooldownLeft * 10f) );
		hc.Add( (int)(R.Charge * 100f) );
		hc.Add( R.SurgeActive );
		hc.Add( (int)R.SurgeTimeLeft );
		hc.Add( (int)R.GoldenTimeLeft );
		hc.Add( R.Boosts.Count );
		hc.Add( R.Boosts.Count > 0 ? (int)R.Boosts[0].SecondsLeft : 0 );
		hc.Add( R.FeedStarved );

		// Transient effects
		hc.Add( _floaters.Count );
		hc.Add( (int)(_calloutLife * 20f) );
		hc.Add( Time.Now < _shakeUntil ? _shakeCls : "" );
		hc.Add( _toasts.Count );

		// Board — chest layout, damage, drones and any live flash
		hc.Add( R.Size );
		float now = Time.Now;
		bool animating = _floaters.Count > 0 || _calloutLife > 0f || now < _shakeUntil;

		for ( int r = 0; r < R.Size; r++ )
			for ( int c = 0; c < R.Size; c++ )
			{
				var cell = R.Board[r, c];
				hc.Add( (int)cell.Chest );
				hc.Add( cell.HitsLeft );
				hc.Add( cell.HasDrone );
				hc.Add( cell.FlashUntil > now ? cell.FlashKind : 0 );

				if ( cell.FlashUntil > now ) animating = true;
				if ( cell.HasChest && now - cell.SpawnAt < 0.25f ) animating = true;
				if ( cell.HasDrone && now - cell.DroneAt < 0.18f ) animating = true;
			}

		hc.Add( R.GoldenRow );
		hc.Add( R.GoldenCol );
		hc.Add( R.LastChain );

		// Anything mid-flight is drawn from inline styles rather than keyframes,
		// so it needs a clock — but only while it is actually moving. The rest of
		// the time this hash is stable and the panel is never rebuilt at all.
		if ( animating ) hc.Add( (int)(now * 20f) );

		return hc.ToHashCode();
	}

	// ══════════════════════════════════════════════════════════════
	// Reactor events
	// ══════════════════════════════════════════════════════════════

	void OnPop( int row, int col, double value, bool crit )
	{
		if ( R == null || _floaters.Count > 10 ) return;

		float step = 100f / R.Size;
		_floaters.Add( new Floater {
			Text = "+" + IdleFormat.Short( value ),
			Cls  = crit ? "crit" : "",
			X = step * col + step * 0.5f,
			Y = step * row + step * 0.5f,
			Life = 0.9f,
			MaxLife = 0.9f,
		} );
	}

	void OnToastEvent( string icon, string text, string cls )
	{
		_toasts.Insert( 0, new ToastMsg { Icon = icon, Text = text, Cls = cls, Life = 3.2f } );
		while ( _toasts.Count > 4 ) _toasts.RemoveAt( _toasts.Count - 1 );
	}

	void OnDetonation( int chain, double payout, bool crit )
	{
		string shake = crit || chain >= 25 ? "idle-shake-3"
					 : chain >= 12 ? "idle-shake-2"
					 : chain >= 4  ? "idle-shake-1"
					 : "";

		if ( shake != "" )
		{
			_shakeCls   = shake;
			_shakeUntil = Time.Now + (shake == "idle-shake-3" ? 0.45f : 0.28f);
		}

		if ( chain < 15 && !crit ) return;

		double critPower = IdleBalance.CritPower( S?.Level( IdleUpgradeId.CritPower ) ?? 0 );

		_calloutText = crit
			? $"CRIT ×{critPower:F0}  ·  +{IdleFormat.Short( payout )}"
			: $"{chain} CHAIN  ·  +{IdleFormat.Short( payout )}";

		_calloutCls  = crit ? "crit" : chain >= 35 ? "mega" : "big";
		_calloutLife = 1f;
	}

	// ══════════════════════════════════════════════════════════════
	// Input
	// ══════════════════════════════════════════════════════════════

	void SetTab( string tab )
	{
		bool changed = _tab != tab;
		_tab = tab;
		Sound.Play( "UI_Button" );
		if ( changed ) TutSatisfy( IdleTutTrigger.TabChanged );
	}
	void SetBuyAmount( int amount ) { _buyAmount = amount; Sound.Play( "UI_Button" ); }

	int BuyCount( IdleUpgradeId id )
		=> _buyAmount < 0 ? Math.Max( 1, R.MaxAffordable( id ) ) : _buyAmount;

	void OnBuy( IdleUpgradeId id )
	{
		if ( R == null ) return;
		if ( !R.TryBuy( id, BuyCount( id ) ) ) Sound.Play( "Pop_Button_1" );
	}

	void OnBuyPerk( IdlePerkId id )
	{
		if ( R == null ) return;
		if ( !R.TryBuyPerk( id ) ) Sound.Play( "Pop_Button_1" );
	}

	void OnOverload()  => R?.Overload();
	void OnSurge()     => R?.ActivateSurge();
	void OnCellClick( int r, int c ) => R?.ClickCell( r, c );

	void OnClaimOffline() => R?.ClaimOffline();
	void OnClaimDaily()   => R?.ClaimDaily();

	void OnOpenMeltdown()  { _showMeltdown = true;  Sound.Play( "UI_Button" ); }
	void OnCloseMeltdown() { _showMeltdown = false; Sound.Play( "UI_Button" ); }

	void OnOpenSingularity()
	{
		_showSingularity = true;
		Sound.Play( "UI_Button" );
		if ( S != null && !S.SingularitySeen )
		{
			S.SingularitySeen = true;
			ChainReactionGame.Save?.Save();
		}
	}

	void OnCloseSingularity() { _showSingularity = false; Sound.Play( "UI_Button" ); }

	void OnSingularity()
	{
		_showSingularity = false;
		R?.DoSingularity();
		_tab = "powers";
	}

	void OnBuyPower( IdlePowerId id ) => R?.BuyPower( id );

	void OnMeltdown()
	{
		_showMeltdown = false;
		R?.DoMeltdown();
	}

	void OnCloseIntro()
	{
		_showIntro = false;
		if ( S != null )
		{
			S.IntroShown      = true;
			S.TutorialOffered = true;
			ChainReactionGame.Save?.Save();
		}
		Sound.Play( "PlayButton" );
	}

	void OnIntroTutorial()
	{
		if ( S != null )
		{
			S.IntroShown = true;
			ChainReactionGame.Save?.Save();
		}
		Sound.Play( "PlayButton" );
		StartTutorial();
	}

	/// <summary>The ? button in the top bar — replayable from anywhere, any time.</summary>
	void OnStartTutorial()
	{
		Sound.Play( "UI_Button" );
		if ( _tutRunning ) { EndTutorial( completed: true ); return; }
		StartTutorial();
	}

	void OnClaimContract( int slot )
	{
		if ( R == null ) return;
		if ( !R.ClaimContract( slot ) ) Sound.Play( "Pop_Button_1" );
	}

	void OnBack()
	{
		Sound.Play( "UI_Button" );
		Game?.ReturnToMenu();
	}

	// ══════════════════════════════════════════════════════════════
	// Class helpers
	// ══════════════════════════════════════════════════════════════

	string Pct( float f ) => (Math.Clamp( f, 0f, 1f ) * 100f).ToString( "F1" );

	string GlowClass()        => R.SurgeActive ? "surging" : "";
	string SurgeClass()       => R.SurgeActive ? "surging" : "";
	// Surge is the louder event and its styles are declared first, so a cascade
	// would otherwise paint over it. Let surge own the board while it lasts.
	string CascadeClass()     => R.CascadeActive && !R.SurgeActive ? "cascading" : "";
	string SurgeReadyClass()  => R.SurgeReady ? "ready" : "";
	string OverloadClass()    => R.OverloadReady ? "ready" : "cooling";
	string BoardShake()       => Time.Now < _shakeUntil ? _shakeCls : "";
	string TabClass( string t )     => _tab == t ? "on" : "";
	string BuyAmtClass( int amt )   => _buyAmount == amt ? "on" : "";
	string MaxedClass( bool maxed ) => maxed ? "maxed" : "";
	string CapClass()  => R.FragmentsToday >= R.DailyFragmentCap ? "capped" : "";
	string FeedClass() => R.FeedStarved ? "starved" : "";
	string MeltdownStripClass() => R.CanMeltdown ? "ready" : "";

	string StreakPipClass( int day )
	{
		int streak = S?.DailyStreak ?? 1;
		if ( day == streak ) return "on today";
		return day < streak ? "on" : "";
	}

	// ══════════════════════════════════════════════════════════════
	// Text helpers — one composed string per element (see note at top)
	// ══════════════════════════════════════════════════════════════

	// ── Daily contracts ───────────────────────────────────────────

	string ContractsCardClass() => R.ContractReady ? "ready" : "";

	string ContractClass( int slot )
	{
		if ( IdleContracts.IsClaimed( S, slot ) )  return "claimed";
		if ( IdleContracts.IsComplete( S, slot ) ) return "ready";
		return "";
	}

	string ContractIcon( int slot )
	{
		if ( IdleContracts.IsClaimed( S, slot ) ) return "✅";
		return IdleContracts.At( S, slot )?.Icon ?? "📋";
	}

	string ContractDescLabel( int slot )
	{
		var c = IdleContracts.At( S, slot );
		if ( c == null ) return "";
		return c.Desc( IdleContracts.Target( S, slot ) );
	}

	string ContractRewardLabel( int slot )
	{
		if ( IdleContracts.IsClaimed( S, slot ) ) return "CLAIMED";
		if ( IdleContracts.IsComplete( S, slot ) ) return "CLAIM →";
		return $"+{IdleContracts.Reward( S, slot )} 💠";
	}

	string ContractProgLabel( int slot )
	{
		var c = IdleContracts.At( S, slot );
		if ( c == null ) return "";

		double cur = Math.Min( IdleContracts.Progress( S, slot ), IdleContracts.Target( S, slot ) );
		double tgt = IdleContracts.Target( S, slot );

		if ( c.Id == "runtime" )
			return $"{IdleFormat.Duration( cur )} / {IdleFormat.Duration( tgt )}";

		return c.BigNumbers
			? $"{IdleFormat.Short( cur )} / {IdleFormat.Short( tgt )}"
			: $"{(long)cur} / {(long)tgt}";
	}

	string ContractResetLabel()
		=> "resets in " + IdleFormat.Duration( IdleContracts.SecondsUntilReset() );

	string ContractFootLabel()
	{
		int streak = S?.ContractStreak ?? 0;
		if ( IdleContracts.AllClaimed( S ) )
			return streak > 1
				? $"All three cleared · {streak} days running"
				: "All three cleared. Back tomorrow.";

		return "Clear all three for a ×3 boost and double Fragments on the last one.";
	}

	// ── Next goal pin ─────────────────────────────────────────────

	/// <summary>
	/// The unfinished Reactor Log entry closest to completion. Picking by
	/// fraction rather than by list order means the pin always shows something
	/// nearly done, which is the only version of this card that pulls.
	///
	/// Cached: BuildHash runs every frame and the markup reads this five more
	/// times per rebuild, so recomputing it on every access would walk the
	/// achievement list a hundred-odd times a frame for a card that changes
	/// once every few minutes.
	/// </summary>
	IdleAchievement _nextGoal;
	float _nextGoalAt;

	IdleAchievement NextGoal => _nextGoal;

	void RefreshNextGoal()
	{
		var s = S;
		if ( s == null ) { _nextGoal = null; return; }

		IdleAchievement best = null;
		float bestFrac = -1f;

		foreach ( var a in IdleAchievements.All )
		{
			if ( s.HasAchievement( a.Id ) ) continue;
			float f = a.Fraction( s );
			if ( f > bestFrac ) { bestFrac = f; best = a; }
		}
		_nextGoal = best;
	}

	string NextGoalProgLabel()
	{
		var a = NextGoal;
		if ( a == null ) return "";
		return ProgressLabel( a );
	}

	string NextGoalRewardLabel()
	{
		var a = NextGoal;
		if ( a == null ) return "";
		return RewardLabel( a );
	}

	string CoresLabel() => IdleFormat.Short( Cores );
	string RateLabel()  => IdleFormat.Short( R.Cps );
	string FluxLabel()  => (S?.Flux ?? 0).ToString();
	string FragLabel()  => (ChainReactionGame.Save?.TotalFragments ?? 0).ToString();

	string GlobalMultLabel() => "×" + IdleFormat.Short( R.GlobalMultiplier );
	string ExponentLabel()   => R.ChainExponent.ToString( "F2" );
	string LastChainLabel()  => $"{R.LastChain} chests";
	string BestChainLabel()  => $"{S.BestChain} chests";
	string BoardLabel()      => $"{R.Size} × {R.Size}";

	string FeedLabel() => R.FeedStarved ? "starving" : "keeping up";

	string SurgeBoostLabel() => $"SURGE ×{IdleBalance.SurgeMultiplier:F0}";
	string BoostLabel( IdleReactor.Boost b ) => $"{b.Label} ×{b.Mult:F1}";
	string SecondsLabel( float s ) => $"{s:F0}s";

	string CondenserLabel()
		=> $"next at {IdleFormat.Short( R.FragmentCost )} Cores · price climbs with each one";

	string FragTodayLabel() => $"{R.FragmentsToday} / {R.DailyFragmentCap}";

	string TierBlurb( int index, bool unlocked )
	{
		var tier = IdleBalance.Tiers[index];
		if ( unlocked ) return tier.Blurb;

		// Tier Memory can drop the gate to zero, so read the effective figure
		// rather than the catalog one.
		double need = IdleBalance.TierRequirement( index, S?.PowerLevel( IdlePowerId.TierSkip ) ?? 0 );
		return $"unlocks at {IdleFormat.Short( need )} cycle Cores";
	}

	string GoldenTimerLabel() => R.GoldenTimeLeft.ToString( "F0" );

	string OverloadLabel()
		=> R.OverloadReady ? "⚡ OVERLOAD" : $"{R.OverloadCooldownLeft:F1}s";

	string OverloadSubLabel() => $"×{IdleBalance.OverloadMultiplier:F1} burst · SPACE";
	string OverloadFillPct()  => ((1f - R.OverloadCooldownFraction) * 100f).ToString( "F0" );
	string UnleashLabel()     => $"UNLEASH ×{IdleBalance.SurgeMultiplier:F0}";

	string SurgeLabel()
	{
		if ( R.SurgeActive ) return $"SURGE {R.SurgeTimeLeft:F0}s";
		if ( R.SurgeReady )  return "SURGE READY";
		return $"CHARGE {R.Charge * 100f:F0}%";
	}

	string MeltdownLabel()
		=> R.CanMeltdown
			? $"Meltdown available — +{R.PendingFlux} Flux"
			: $"Meltdown unlocks at {IdleFormat.Short( R.MeltdownRequirement )} cycle Cores · {IdleFormat.Short( S.CycleCores )} banked";

	string BuyAmountLabel( int amount ) => amount < 0 ? "MAX" : $"×{amount}";

	string UpgradeClass( bool revealed, bool maxed, bool afford )
	{
		if ( !revealed ) return "locked";
		if ( maxed ) return "maxed";
		return afford ? "afford" : "poor";
	}

	string UpgradeIcon( IdleUpgrade up, bool revealed ) => revealed ? up.Icon : "🔒";
	string UpgradeName( IdleUpgrade up, bool revealed ) => revealed ? up.Name : "Locked";

	string UpgradeDesc( IdleUpgrade up, bool revealed )
		=> revealed ? up.Desc : $"Reach {IdleFormat.Short( up.RevealAt )} lifetime Cores to reveal.";

	string LevelLabel( IdleUpgrade up, int lvl, bool revealed )
	{
		if ( !revealed ) return "—";
		return up.MaxLevel < 100 ? $"Lv {lvl} / {up.MaxLevel}" : $"Lv {lvl}";
	}

	string CostLabel( bool maxed, double cost, int count )
	{
		if ( maxed ) return "MAXED";
		return count > 1
			? $"{IdleFormat.Short( cost )}  ×{count}"
			: IdleFormat.Short( cost );
	}

	string PerkLevelLabel( IdlePerk p, int lvl ) => p.Uncapped ? $"Lv {lvl}" : $"Lv {lvl} / {p.MaxLevel}";
	string PerkCostLabel( bool maxed, int cost ) => maxed ? "MAXED" : $"🔷 {cost}";

	/// <summary>
	/// Containment Field is deliberately hidden until the player has enough Flux
	/// history to understand what a sink is for — offered at Flux 0 it just reads
	/// as a bad first purchase.
	/// </summary>
	IEnumerable<IdlePerk> VisiblePerks()
		=> IdleUpgradeCatalog.Perks.Where( p => p.RevealAtFluxEarned <= 0
											 || (S?.FluxEarned ?? 0) >= p.RevealAtFluxEarned
											 || (S?.PerkLevel( p.Id ) ?? 0) > 0 );

	string PowerLevelLabel( IdlePower p, int lvl ) => $"Lv {lvl} / {p.MaxLevel}";
	string PowerCostLabel( bool maxed, int cost ) => maxed ? "MAXED" : $"✴️ {cost}";

	string ShardHeadLabel() => $"✴️ {S.Shards}";

	string ShardSubLabel()
		=> $"{S.ShardsEarned} earned · ×{IdleBalance.ShardMultiplier( S.ShardsEarned ):F2} permanent income";

	string SingStripClass() => R.CanSingularity ? "ready" : "";

	string SingularityLabel()
	{
		if ( R.CanSingularity )
			return $"Singularity available — +{R.PendingShards} Shards";

		return $"Singularity unlocks at {IdleBalance.SingularityRequirement} lifetime Flux · {S.FluxEarned} earned";
	}

	string SingPayoutLabel()  => "+" + R.PendingShards.ToString();
	string BurnedFluxLabel()  => $"{S.FluxEarned} lifetime Flux";

	string ShardKeepLabel()
	{
		int after = S.ShardsEarned + R.PendingShards;
		return $"×{IdleBalance.ShardMultiplier( after ):F2} income from Shards";
	}

	string FluxHeadLabel() => $"🔷 {S.Flux}";

	string FluxSubLabel()
		=> $"{S.FluxEarned} earned · ×{IdleBalance.FluxMultiplier( S.FluxEarned ):F2} permanent income";

	string LogHeadLabel() => $"{IdleAchievements.CompletedCount( S )} / {IdleAchievements.All.Count}";

	string AchIcon( IdleAchievement a, bool done ) => done ? a.Icon : "🔘";

	string RewardLabel( IdleAchievement a )
	{
		if ( a.RewardFlux > 0 && a.RewardFrags > 0 ) return $"+{a.RewardFlux}🔷  +{a.RewardFrags}💠";
		if ( a.RewardFlux > 0 ) return $"+{a.RewardFlux}🔷";
		return $"+{a.RewardFrags}💠";
	}

	string ProgressLabel( IdleAchievement a )
	{
		double cur = a.Progress( S );
		return a.BigNumbers
			? $"{IdleFormat.Short( cur )} / {IdleFormat.Short( a.Target )}"
			: $"{(long)cur} / {(long)a.Target}";
	}

	// ── Modal labels ──────────────────────────────────────────────
	string OfflineSubLabel()
		=> $"The reactor ran for {IdleFormat.Duration( R.PendingOfflineSeconds )} at {IdleFormat.Pct( R.OfflineEfficiency )} efficiency.";

	string OfflineAmountLabel() => "+" + IdleFormat.Short( R.PendingOfflineCores );

	string OfflineFragLabel() => $"condensing {R.PendingOfflineFrags} Fragments";

	string OfflineWarnLabel()
		=> $"Storage filled up after {R.OfflineCapHours:F0}h. Upgrade Core Storage to bank longer trips.";

	string DailyTitleLabel() => $"Day {S?.DailyStreak ?? 1}";
	string DailyMultLabel()  => $"×{IdleBalance.DailyReward( S?.DailyStreak ?? 1 ).mult:F1}";

	string DailyRewardLabel()
	{
		var reward = IdleBalance.DailyReward( S?.DailyStreak ?? 1 );
		return $"for {IdleFormat.Duration( reward.seconds )}  ·  +{reward.frags} Fragments";
	}

	string MeltPayoutLabel()  => "+" + R.PendingFlux;
	string BurnedCoresLabel() => $"{IdleFormat.Short( Cores )} Cores";

	string MeltMultBeforeLabel()
		=> "×" + IdleBalance.FluxMultiplier( S?.FluxEarned ?? 0 ).ToString( "F2" );

	string MeltMultAfterLabel()
		=> "×" + IdleBalance.FluxMultiplier( (S?.FluxEarned ?? 0) + R.PendingFlux ).ToString( "F2" );

	// ══════════════════════════════════════════════════════════════
	// Cell rendering
	// ══════════════════════════════════════════════════════════════

	string CellClass( IdleReactor.Cell cell, bool golden )
	{
		if ( golden ) return "golden";

		string cls = cell.HasChest ? "filled" : "empty";

		if ( cell.FlashUntil > Time.Now )
		{
			cls += cell.FlashKind switch
			{
				3 => " fx-drone",
				2 => " fx-kill",
				_ => " fx-blast",
			};
		}

		if ( cell.HasDrone ) cls += " armed";

		if ( cell.HasChest )
		{
			cls += cell.Chest switch
			{
				GridManager.ChestType.Gem       => " c-gem",
				GridManager.ChestType.BombChest => " c-bomb",
				GridManager.ChestType.Locked    => " c-locked",
				GridManager.ChestType.Cursed    => " c-cursed",
				GridManager.ChestType.Void      => " c-void",
				GridManager.ChestType.Boss      => " c-boss",
				_                               => " c-normal",
			};
		}

		return cls;
	}

	/// <summary>
	/// Spawn pop, computed here rather than as a keyframe: the panel can rebuild
	/// at any moment and a class-driven animation would restart mid-flight.
	/// </summary>
	string ChestStyle( IdleReactor.Cell cell )
	{
		float age = Time.Now - cell.SpawnAt;
		if ( age >= 0.25f || age < 0f ) return "";

		float scale = 0.4f + 0.6f * (age / 0.25f);
		return $"transform:scale({scale.ToString( "F2" )});";
	}

	/// <summary>Drone drop — falls onto the cell as the volley arms.</summary>
	string DroneStyle( IdleReactor.Cell cell )
	{
		float age = Time.Now - cell.DroneAt;
		if ( age >= 0.18f || age < 0f ) return "";

		float t = age / 0.18f;
		float scale = 2.0f - 1.0f * t;
		return $"transform:scale({scale.ToString( "F2" )});opacity:{t.ToString( "F2" )};";
	}

	string ChestGlyph( IdleReactor.Cell cell ) => cell.Chest switch
	{
		GridManager.ChestType.BombChest => "💣",
		GridManager.ChestType.Locked    => "🔒",
		GridManager.ChestType.Cursed    => "💀",
		GridManager.ChestType.Void      => "🌑",
		GridManager.ChestType.Boss      => "👑",
		GridManager.ChestType.Gem       => "💎",
		_                               => "🎁",
	};
}