diff --git a/IdleSpectator/AgentHarness.cs b/IdleSpectator/AgentHarness.cs
index ae5042a..cdb0eda 100644
--- a/IdleSpectator/AgentHarness.cs
+++ b/IdleSpectator/AgentHarness.cs
@@ -9309,8 +9309,7 @@ public static class AgentHarness
int rows = LifeSagaRail.LastHoverRowCount;
float height = LifeSagaRail.LastHoverHeight;
pass = LifeSagaViewController.EffectiveTab == LifeSagaViewController.Tab.Saga
- && height >= LifeSagaPanel.BodyHeightMin - 0.5f
- && height <= LifeSagaPanel.BodyHeightMax + 0.5f;
+ && Mathf.Abs(height - LifeSagaPanel.BodyHeightFixed) < 0.5f;
detail =
$"snapshot rows={rows} height={height:0.0} tab={LifeSagaViewController.EffectiveTab} min={LifeSagaPanel.BodyHeightMin} max={LifeSagaPanel.BodyHeightMax}";
break;
@@ -9420,20 +9419,19 @@ public static class AgentHarness
}
case "saga_panel_fixed_size":
{
- // Locked tabbed chrome width + content-height Saga body (capped).
+ // Locked rail width + exact fixed-height Saga body.
Vector2 size = WatchCaption.LastPanelSize;
float bodyH = LifeSagaPanel.BodyHeight;
float bodyW = LifeSagaPanel.BodyWidthPx;
bool onSaga = LifeSagaViewController.EffectiveTab == LifeSagaViewController.Tab.Saga;
bool widthOk = Mathf.Abs(bodyW - LifeSagaPanel.BodyWidthFixed) < 0.5f;
- bool heightOk = bodyH >= LifeSagaPanel.BodyHeightMin - 0.5f
- && bodyH <= LifeSagaPanel.BodyHeightMax + 0.5f;
+ bool heightOk = Mathf.Abs(bodyH - LifeSagaPanel.BodyHeightFixed) < 0.5f;
bool compact = widthOk && heightOk;
if (onSaga)
{
compact = compact
&& LifeSagaPanel.Visible
- && size.y <= LifeSagaPanel.BodyHeightMax + 100f
+ && Mathf.Abs(size.y - WatchCaption.SagaHoverHeightFixed) < 0.5f
&& size.x <= 260f
&& size.x >= bodyW - 0.5f;
}
@@ -9444,7 +9442,7 @@ public static class AgentHarness
pass = compact;
detail =
- $"size={size} body={bodyW:0.#}x{bodyH:0.#} fixedW={LifeSagaPanel.BodyWidthFixed} maxH={LifeSagaPanel.BodyHeightMax} onSaga={onSaga} compact={compact} dbg={LifeSagaPanel.LastLayoutDebug}";
+ $"size={size} body={bodyW:0.#}x{bodyH:0.#} fixedW={LifeSagaPanel.BodyWidthFixed} outerH={WatchCaption.SagaHoverHeightFixed:0.#} onSaga={onSaga} compact={compact} dbg={LifeSagaPanel.LastLayoutDebug}";
break;
}
case "caption_mc_identity":
diff --git a/IdleSpectator/HarnessScenarios.cs b/IdleSpectator/HarnessScenarios.cs
index 3d7ac43..156b007 100644
--- a/IdleSpectator/HarnessScenarios.cs
+++ b/IdleSpectator/HarnessScenarios.cs
@@ -4004,6 +4004,7 @@ internal static class HarnessScenarios
Step("ccb22", "saga_show_hover_first"),
Step("ccb23", "assert", expect: "caption_hover_keeps_beat"),
Step("ccb23b", "assert", expect: "saga_panel_cast_first"),
+ Step("ccb23c", "assert", expect: "saga_panel_fixed_size"),
Step("ccb24", "saga_hide_hover"),
Step("ccb25", "saga_hover_tick_away"),
Step("ccb26", "spawn", asset: "sheep", count: 1),
diff --git a/IdleSpectator/Story/LifeSagaPanel.cs b/IdleSpectator/Story/LifeSagaPanel.cs
index 38e26e8..a8483b5 100644
--- a/IdleSpectator/Story/LifeSagaPanel.cs
+++ b/IdleSpectator/Story/LifeSagaPanel.cs
@@ -12,33 +12,37 @@ namespace IdleSpectator;
/// Saga character, so no duplicate identity chrome is rendered here.
/// No portrait / name / title / stake chrome here - that space goes to Cast.
/// Width fills the stable rail chrome beside the live dossier portrait.
-/// Height sizes to content (capped); Legacy admits complete prioritized lines and
-/// uses a final ellipsis when lower-priority entries do not fit.
+/// The body has fixed geometry: compact 3x2 Cast cells above a two-line Legacy.
+/// Content changes never move the rail, nametag, portrait, or card edge.
///
public static class LifeSagaPanel
{
- /// Remaining hover width beside the 40px live portrait.
- public const float BodyWidthFixed = 188f;
- /// Hard cap so Saga never eats the screen.
- public const float BodyHeightMax = 172f;
+ /// Full inner hover width; Cast starts beside the portrait, Legacy spans below it.
+ public const float BodyWidthFixed = 232f;
+ /// Fixed hover depth; never derived from Cast or Legacy content.
+ public const float BodyHeightFixed = 74f;
+ public const float BodyHeightMax = BodyHeightFixed;
public const float BodyWidthMin = BodyWidthFixed;
public const float BodyWidthMax = BodyWidthFixed;
- public const float BodyHeightMin = 12f;
- public const float BodyHeightFixed = BodyHeightMax;
+ public const float BodyHeightMin = BodyHeightFixed;
- /// Shared cast budget for panel slots and presentation model.
- public const int MaxCast = 8;
- private const int MaxLegacy = 6;
+ /// Visible Cast cells. The sixth cell becomes +N when candidates overflow.
+ public const int MaxCast = 6;
+ /// Relationships retained by the model before the visible grid is allocated.
+ public const int MaxCastCandidates = 10;
+ private const int MaxLegacy = 2;
private const int CastPerRow = 3;
private const float Pad = 3f;
- private const float CastFace = 16f;
- private const float CastSlotW = 54f;
- private const float SecLabelH = 10f;
- private const float LineH = 10f;
+ private const float CastFace = 14f;
+ private const float CastSlotW = 59f;
+ private const float CastSlotH = 15f;
+ private const float SecLabelH = 9f;
+ private const float LineH = 9f;
+ private const float PortraitReserveW = 44f;
private const int BodyFont = 7;
- private const int BuildVersion = 12;
+ private const int BuildVersion = 14;
private static GameObject _root;
private static RectTransform _rootRt;
@@ -51,7 +55,7 @@ public static class LifeSagaPanel
private static string _speciesId = "";
private static string _fingerprint = "";
private static int _builtVersion;
- private static float _laidOutHeight = BodyHeightMin;
+ private static float _laidOutHeight = BodyHeightFixed;
private sealed class CastSlot
{
@@ -99,7 +103,7 @@ public static class LifeSagaPanel
_rootRt.pivot = new Vector2(0f, 1f);
_rootRt.anchorMin = new Vector2(0f, 1f);
_rootRt.anchorMax = new Vector2(0f, 1f);
- _rootRt.sizeDelta = new Vector2(BodyWidthFixed, BodyHeightMin);
+ _rootRt.sizeDelta = new Vector2(BodyWidthFixed, BodyHeightFixed);
_castHead = MakeLabel(_root.transform, "CastHead", BodyFont, HudTheme.Muted, FontStyle.Bold);
_castHead.text = "Cast";
@@ -124,7 +128,7 @@ public static class LifeSagaPanel
_speciesId = "";
_fingerprint = "";
LegacyParts.Clear();
- _laidOutHeight = BodyHeightMin;
+ _laidOutHeight = BodyHeightFixed;
if (_root != null)
{
_root.SetActive(false);
@@ -161,8 +165,8 @@ public static class LifeSagaPanel
_speciesId = model.SpeciesId ?? "";
if (!same)
{
- int shownCast = 0;
- for (int i = 0; i < model.Cast.Count && shownCast < CastSlots.Length; i++)
+ var eligibleCast = new List(model.Cast.Count);
+ for (int i = 0; i < model.Cast.Count; i++)
{
LifeSagaCastMember member = model.Cast[i];
if (member == null || CastDuplicatesCurrentBeat(model.UnitId, member.UnitId))
@@ -170,7 +174,25 @@ public static class LifeSagaPanel
continue;
}
- BindCast(CastSlots[shownCast], member);
+ eligibleCast.Add(member);
+ }
+
+ int visiblePeople = Mathf.Min(eligibleCast.Count, CastSlots.Length);
+ bool hasOverflow = eligibleCast.Count > CastSlots.Length;
+ if (hasOverflow)
+ {
+ visiblePeople = CastSlots.Length - 1;
+ }
+
+ int shownCast = 0;
+ for (; shownCast < visiblePeople; shownCast++)
+ {
+ BindCast(CastSlots[shownCast], eligibleCast[shownCast]);
+ }
+
+ if (hasOverflow)
+ {
+ BindCastOverflow(CastSlots[shownCast], eligibleCast.Count - visiblePeople);
shownCast++;
}
@@ -206,7 +228,9 @@ public static class LifeSagaPanel
LegacyParts.Add(line);
}
- _legacyText.text = LegacyParts.Count > 0 ? string.Join("\n", LegacyParts) : "";
+ _legacyText.text = LegacyParts.Count > 0
+ ? string.Join("\n", LegacyParts)
+ : "Their story is still unfolding";
}
Layout();
@@ -230,7 +254,7 @@ public static class LifeSagaPanel
float fullX = Pad;
float fullW = BodyWidthFixed - Pad * 2f;
float y = Pad;
- float yMax = BodyHeightMax - Pad;
+ float yMax = BodyHeightFixed - Pad;
// The Beat can update after the model binds in the same frame. Recheck by
// structured ids; display-name substring matching removed legitimate Cast
@@ -259,68 +283,49 @@ public static class LifeSagaPanel
}
}
- if (castN > 0)
+ float castX0 = PortraitReserveW;
+ float castW = BodyWidthFixed - castX0 - Pad;
+ _castHead.text = castN > 0 ? "Cast" : "Cast · No close ties recorded";
+ PlaceText(_castHead, castX0, y, castW, SecLabelH);
+ y += SecLabelH;
+ float castX = castX0;
+ int col = 0;
+ for (int i = 0; i < CastSlots.Length; i++)
{
- PlaceText(_castHead, fullX, y, fullW, SecLabelH);
- y += SecLabelH;
- float castRowH = CastFace + 18f;
- float castX = fullX;
- int col = 0;
- for (int i = 0; i < CastSlots.Length; i++)
+ if (!CastSlots[i].Root.activeSelf)
{
- if (!CastSlots[i].Root.activeSelf)
- {
- continue;
- }
-
- if (col >= CastPerRow)
- {
- y += castRowH + 2f;
- castX = fullX;
- col = 0;
- }
-
- RectTransform rt = CastSlots[i].Root.GetComponent();
- rt.anchoredPosition = new Vector2(castX, -y);
- rt.sizeDelta = new Vector2(CastSlotW, castRowH);
- CastSlots[i].Root.transform.SetAsLastSibling();
- castX += CastSlotW + 2f;
- col++;
+ continue;
}
- y += castRowH + 2f;
- }
- else
- {
- Hide(_castHead);
+ if (col >= CastPerRow)
+ {
+ y += CastSlotH + 2f;
+ castX = castX0;
+ col = 0;
+ }
+
+ RectTransform rt = CastSlots[i].Root.GetComponent();
+ rt.anchoredPosition = new Vector2(castX, -y);
+ rt.sizeDelta = new Vector2(CastSlotW, CastSlotH);
+ CastSlots[i].Root.transform.SetAsLastSibling();
+ castX += CastSlotW + 1f;
+ col++;
}
- float legacyBudget = Mathf.Max(0f, yMax - y - SecLabelH);
+ // Reserve both Cast rows even when sparse so Legacy never moves between characters.
+ y = 44f;
+ float legacyBudget = Mathf.Max(LineH, yMax - y - SecLabelH);
int omittedLegacy = 0;
string fittedLegacy = FitLegacyToHeight(fullW, legacyBudget, out omittedLegacy);
_legacyText.text = fittedLegacy;
- bool hasLegacy = !string.IsNullOrEmpty(fittedLegacy);
- if (hasLegacy && legacyBudget >= LineH)
- {
- PlaceText(_legacyHead, fullX, y, fullW, SecLabelH);
- y += SecLabelH;
- float legacyH = Mathf.Min(
- MeasureWrapHeight(_legacyText, fittedLegacy, fullW),
- Mathf.Max(LineH, yMax - y));
- PlaceText(_legacyText, fullX, y, fullW, legacyH);
- _legacyText.horizontalOverflow = HorizontalWrapMode.Wrap;
- _legacyText.verticalOverflow = VerticalWrapMode.Truncate;
- y += legacyH + 2f;
- }
- else
- {
- Hide(_legacyHead);
- Hide(_legacyText);
- hasLegacy = false;
- }
+ bool hasLegacy = LegacyParts.Count > 0;
+ PlaceText(_legacyHead, fullX, y, fullW, SecLabelH);
+ y += SecLabelH;
+ PlaceText(_legacyText, fullX, y, fullW, Mathf.Min(2f * LineH, yMax - y));
+ _legacyText.horizontalOverflow = HorizontalWrapMode.Overflow;
+ _legacyText.verticalOverflow = VerticalWrapMode.Truncate;
- y = Mathf.Max(y + Pad, BodyHeightMin);
- _laidOutHeight = Mathf.Clamp(y, BodyHeightMin, BodyHeightMax);
+ _laidOutHeight = BodyHeightFixed;
if (_rootRt != null)
{
@@ -333,50 +338,35 @@ public static class LifeSagaPanel
private static string FitLegacyToHeight(float width, float maxHeight, out int omitted)
{
- omitted = LegacyParts.Count;
- if (_legacyText == null || LegacyParts.Count == 0 || maxHeight < LineH)
+ omitted = Mathf.Max(0, LegacyParts.Count - MaxLegacy);
+ if (_legacyText == null || maxHeight < LineH)
{
return "";
}
- string fitted = "";
- int included = 0;
- for (int i = 0; i < LegacyParts.Count; i++)
+ if (LegacyParts.Count == 0)
{
- string next = string.IsNullOrEmpty(fitted)
- ? LegacyParts[i]
- : fitted + "\n" + LegacyParts[i];
- if (MeasureWrapHeight(_legacyText, next, width) > maxHeight + 0.5f)
- {
- break;
- }
-
- fitted = next;
- included++;
+ return "Their story is still unfolding";
}
- omitted = LegacyParts.Count - included;
- if (omitted <= 0)
+ int lineCount = Mathf.Min(MaxLegacy, Mathf.FloorToInt(maxHeight / LineH));
+ var lines = new List(lineCount);
+ int maxChars = Mathf.Max(12, Mathf.FloorToInt(width / 4.2f));
+ for (int i = 0; i < LegacyParts.Count && lines.Count < lineCount; i++)
{
- return fitted;
+ lines.Add(Ellipsize(LegacyParts[i], maxChars));
}
- string withEllipsis = string.IsNullOrEmpty(fitted) ? "…" : fitted + "\n…";
- return MeasureWrapHeight(_legacyText, withEllipsis, width) <= maxHeight + 0.5f
- ? withEllipsis
- : fitted;
+ omitted = Mathf.Max(0, LegacyParts.Count - lines.Count);
+ return string.Join("\n", lines);
}
- private static float MeasureWrapHeight(Text text, string value, float width)
+ private static string Ellipsize(string value, int maxChars)
{
- if (text == null || string.IsNullOrEmpty(value))
- {
- return LineH;
- }
-
- TextGenerationSettings settings = text.GetGenerationSettings(new Vector2(width, 0f));
- float pixels = text.cachedTextGeneratorForLayout.GetPreferredHeight(value, settings);
- return Mathf.Max(LineH, pixels / Mathf.Max(0.01f, text.pixelsPerUnit));
+ string clean = (value ?? "").Replace('\n', ' ').Replace('\r', ' ').Trim();
+ return clean.Length <= maxChars
+ ? clean
+ : clean.Substring(0, Mathf.Max(1, maxChars - 1)).TrimEnd() + "…";
}
private static CastSlot BuildCast(int index)
@@ -415,11 +405,11 @@ public static class LifeSagaPanel
Text name = MakeLabel(root.transform, "Name", BodyFont, HudTheme.Body, FontStyle.Normal);
Text relation = MakeLabel(root.transform, "Rel", BodyFont, HudTheme.Muted, FontStyle.Normal);
RectTransform nameRt = name.rectTransform;
- nameRt.anchoredPosition = new Vector2(0f, -(CastFace + 1f));
- nameRt.sizeDelta = new Vector2(CastSlotW, 9f);
+ nameRt.anchoredPosition = new Vector2(CastFace + 2f, 0f);
+ nameRt.sizeDelta = new Vector2(CastSlotW - CastFace - 2f, 8f);
RectTransform relRt = relation.rectTransform;
- relRt.anchoredPosition = new Vector2(0f, -(CastFace + 9f));
- relRt.sizeDelta = new Vector2(CastSlotW, 9f);
+ relRt.anchoredPosition = new Vector2(CastFace + 2f, -8f);
+ relRt.sizeDelta = new Vector2(CastSlotW - CastFace - 2f, 7f);
root.SetActive(false);
return new CastSlot
@@ -453,6 +443,17 @@ public static class LifeSagaPanel
slot.Root.SetActive(true);
}
+ private static void BindCastOverflow(CastSlot slot, int omitted)
+ {
+ slot.UnitId = 0;
+ slot.Face.sprite = null;
+ slot.Face.enabled = false;
+ slot.FaceBg.color = new Color(0.12f, 0.13f, 0.16f, 0.95f);
+ FitCastText(slot.Name, "+" + Mathf.Max(1, omitted));
+ FitCastText(slot.Relation, "more ties");
+ slot.Root.SetActive(true);
+ }
+
/// The camera Beat already explains this exact member for this exact subject.
private static bool CastDuplicatesCurrentBeat(long panelSubjectId, long castMemberId)
{
@@ -502,7 +503,7 @@ public static class LifeSagaPanel
}
string fitted = value ?? "";
- const int maxReadableChars = 16;
+ const int maxReadableChars = 11;
if (fitted.Length > maxReadableChars)
{
fitted = fitted.Substring(0, maxReadableChars - 1).TrimEnd() + "…";
@@ -512,7 +513,7 @@ public static class LifeSagaPanel
text.resizeTextForBestFit = true;
text.resizeTextMinSize = 6;
text.resizeTextMaxSize = BodyFont;
- text.horizontalOverflow = HorizontalWrapMode.Wrap;
+ text.horizontalOverflow = HorizontalWrapMode.Overflow;
text.verticalOverflow = VerticalWrapMode.Truncate;
}
@@ -555,14 +556,6 @@ public static class LifeSagaPanel
text.gameObject.SetActive(!string.IsNullOrEmpty(text.text));
}
- private static void Hide(Text text)
- {
- if (text != null)
- {
- text.gameObject.SetActive(false);
- }
- }
-
private static string Fingerprint(LifeSagaViewModel model)
{
var sb = new StringBuilder(192);
diff --git a/IdleSpectator/Story/LifeSagaPresentation.cs b/IdleSpectator/Story/LifeSagaPresentation.cs
index a7a4cd7..6e531f6 100644
--- a/IdleSpectator/Story/LifeSagaPresentation.cs
+++ b/IdleSpectator/Story/LifeSagaPresentation.cs
@@ -437,7 +437,7 @@ public static class LifeSagaPresentation
void AddLive(Actor other, string label, string truth)
{
- if (other == null || model.Cast.Count >= LifeSagaPanel.MaxCast)
+ if (other == null || model.Cast.Count >= LifeSagaPanel.MaxCastCandidates)
{
return;
}
@@ -461,7 +461,7 @@ public static class LifeSagaPresentation
void AddObserved(LifeSagaIdentity identity, string label, string truth)
{
- if (identity.Id == 0 || model.Cast.Count >= LifeSagaPanel.MaxCast || !seen.Add(identity.Id))
+ if (identity.Id == 0 || model.Cast.Count >= LifeSagaPanel.MaxCastCandidates || !seen.Add(identity.Id))
{
return;
}
@@ -518,9 +518,9 @@ public static class LifeSagaPresentation
}
// Former bonds from memory when live cast still has room.
- if (memory != null && model.Cast.Count < LifeSagaPanel.MaxCast)
+ if (memory != null && model.Cast.Count < LifeSagaPanel.MaxCastCandidates)
{
- for (int i = 0; i < memory.Facts.Count && model.Cast.Count < LifeSagaPanel.MaxCast; i++)
+ for (int i = 0; i < memory.Facts.Count && model.Cast.Count < LifeSagaPanel.MaxCastCandidates; i++)
{
LifeSagaFact f = memory.Facts[i];
if (f == null || f.OtherId == 0 || seen.Contains(f.OtherId))
@@ -540,9 +540,9 @@ public static class LifeSagaPresentation
}
// Living Others from unresolved war/plot joins.
- if (memory != null && model.Cast.Count < LifeSagaPanel.MaxCast)
+ if (memory != null && model.Cast.Count < LifeSagaPanel.MaxCastCandidates)
{
- for (int i = 0; i < memory.Facts.Count && model.Cast.Count < LifeSagaPanel.MaxCast; i++)
+ for (int i = 0; i < memory.Facts.Count && model.Cast.Count < LifeSagaPanel.MaxCastCandidates; i++)
{
LifeSagaFact f = memory.Facts[i];
if (f == null || f.Resolved || f.OtherId == 0 || seen.Contains(f.OtherId))
@@ -569,7 +569,7 @@ public static class LifeSagaPresentation
// Family peers fill remaining slots for crown/combat/plot/pack/bond lenses.
if (actor != null
- && model.Cast.Count < LifeSagaPanel.MaxCast
+ && model.Cast.Count < LifeSagaPanel.MaxCastCandidates
&& (primary == LifeSagaLens.PackAlpha
|| primary == LifeSagaLens.LoveGrief
|| primary == LifeSagaLens.CrownClan
@@ -579,7 +579,7 @@ public static class LifeSagaPresentation
string peerLabel = primary == LifeSagaLens.PackAlpha
? SagaProse.CastLabelLive("packmate")
: SagaProse.CastLabelLive("kin");
- foreach (Actor peer in ActorRelation.EnumerateFamilyMembers(actor, max: LifeSagaPanel.MaxCast + 2))
+ foreach (Actor peer in ActorRelation.EnumerateFamilyMembers(actor, max: LifeSagaPanel.MaxCastCandidates + 2))
{
if (peer == null || peer == actor)
{
@@ -587,7 +587,7 @@ public static class LifeSagaPresentation
}
AddLive(peer, peerLabel, "is");
- if (model.Cast.Count >= LifeSagaPanel.MaxCast)
+ if (model.Cast.Count >= LifeSagaPanel.MaxCastCandidates)
{
break;
}
diff --git a/IdleSpectator/WatchCaption.cs b/IdleSpectator/WatchCaption.cs
index f6b75cc..864fa5c 100644
--- a/IdleSpectator/WatchCaption.cs
+++ b/IdleSpectator/WatchCaption.cs
@@ -206,6 +206,11 @@ public static class WatchCaption
public static Vector2 LastPanelSize { get; private set; }
+ /// Exact outer height of rail + nametag + fixed Saga body.
+ public static float SagaHoverHeightFixed =>
+ PadTop + LifeSagaRail.SlotSize + 2f + HeaderH + Gap
+ + LifeSagaPanel.BodyHeightFixed + 2f + PadTop - Gap;
+
public static bool LastLayoutOk { get; private set; }
/// True when the dossier card GameObject is active.
@@ -1062,10 +1067,10 @@ public static class WatchCaption
if (panelMode)
{
// Hover depth prioritizes the full Identity. The live task is already represented
- // by the Beat and returns with the dossier rows as soon as hover ends.
+ // by the camera dossier and returns with its rows as soon as hover ends. Beat and
+ // Context are hidden, so neither may reserve phantom vertical space here.
hasTask = false;
- hasReason = !string.IsNullOrEmpty(LastBeatLine)
- || (_reasonText != null && !string.IsNullOrEmpty(_reasonText.text));
+ hasReason = false;
}
bool hasBody = !panelMode
@@ -2824,6 +2829,13 @@ public static class WatchCaption
bool panelMode = LifeSagaViewController.IsHoverPreview && !HasStatusBanner();
bool railChrome = LifeSagaRail.Visible;
+ // Defensive ownership boundary: Saga preview never lays out camera Beat space even
+ // if a caller still has a composed LastBeatLine from immediately before hover.
+ if (panelMode)
+ {
+ hasReason = false;
+ }
+
// Rail chrome keeps a constant width so hover preview cannot reflow the glyphs.
if (railChrome || panelMode)
{
@@ -2941,7 +2953,7 @@ public static class WatchCaption
LifeSagaPanel.EnsureBuilt(_root.transform);
DossierAvatar.Place(PadX, y, LiveMax);
DossierAvatar.SetHostVisible(true);
- y = LifeSagaPanel.Place(y, PadX + LiveMax + 4f);
+ y = LifeSagaPanel.Place(y, PadX);
if (railChrome)
{
_panelWidth = PanelWidthMax;
diff --git a/docs/life-saga.md b/docs/life-saga.md
index 9da6af8..bc8709c 100644
--- a/docs/life-saga.md
+++ b/docs/life-saga.md
@@ -4,6 +4,7 @@ Dynamic top-10 cast of interesting lives (MCs). Owns the dossier rail and soft c
Chronicle stays a history book and never drives the camera.
Short combat/love/war arcs stay in [`StoryPlanner`](../IdleSpectator/Story/StoryPlanner.cs) as chapter beats.
Durable observed facts live in [`LifeSagaMemory`](../IdleSpectator/Story/LifeSagaMemory.cs) and survive roster churn.
+The fixed-layout hover redesign is specified in the [Saga UX redesign plan](saga-ux-redesign-plan.md).
AFK caption is a **character beat**: Identity (who) + Beat (live tip) + Context (spine or stake).
[`CaptionComposer`](../IdleSpectator/Story/CaptionComposer.cs) merges saga identity into the live caption without baking titles into `InterestCandidate.Label`.
diff --git a/docs/saga-ux-redesign-plan.md b/docs/saga-ux-redesign-plan.md
new file mode 100644
index 0000000..c7a5735
--- /dev/null
+++ b/docs/saga-ux-redesign-plan.md
@@ -0,0 +1,218 @@
+# Saga UX redesign plan
+
+Goal: make the Saga surface a stable character browser for following the world's main
+characters. Moving between glyphs must change the character, never the geometry.
+
+Status: **Design approved in principle; implementation pending.**
+
+Related documents:
+
+- [Life Saga](life-saga.md)
+- [Story system coherence](story-system-coherence-plan.md)
+- [Story Planner](story-planner.md)
+
+## Problem
+
+The current hover dossier has the right information but its layout is still derived from
+its contents. Cast width, legacy length, and portrait state can alter the panel height. Since
+the card is bottom anchored, that makes the rail and nametag shift while the pointer moves
+between glyphs. The result feels unstable even when every individual field is correct.
+
+The surface also risks presenting two identities at once: the camera subject and the hovered
+Saga character. During Saga preview, every identity-bearing element must belong to the Saga
+character and camera-specific Beat and Context must disappear.
+
+## Product contract
+
+At a glance, Saga answers:
+
+1. **Who is this main character?** — live name, role/species, sex, favorite state, and unique appearance.
+2. **Who matters to them?** — a compact, prioritized Cast.
+3. **What has defined their life?** — a short Legacy.
+4. **What will interaction do?** — hover previews; the existing click action toggles Prefer.
+
+Saga is a character browser. It does not restate the camera Beat or Story Planner Context.
+
+## Fixed geometry
+
+Use one fixed logical envelope for every Saga state:
+
+| Region | Size | Contract |
+|---|---:|---|
+| Whole surface | `240 × 117` | Never content-sized |
+| Glyph rail | `232 × 18` | Fixed position and hit targets |
+| Gap | `232 × 2` | Reserved |
+| Nametag | `232 × 15` | One line; fixed controls at right |
+| Gap | `232 × 1` | Reserved |
+| Body | `232 × 74` | Portrait, Cast, and Legacy stay inside |
+| Outer padding | `4` horizontal, `3` vertical | Constant |
+
+The body must never report a content-derived preferred height. Empty and full characters use
+the same bounds. Supported UI scaling may scale the entire envelope uniformly but must not
+reflow its internal regions.
+
+```text
+┌──────────────────────────────────────────────────────────┐
+│ [glyph] [glyph] [glyph] [glyph] ... │ rail
+├──────────────────────────────────────────────────────────┤
+│ sprite Name · Role sex favorite │ nametag
+├──────────────────────────────────────────────────────────┤
+│ ┌──────────────┐ Cast │
+│ │ │ [face Name] [face Name] [face Name] │
+│ │ live unique │ [face Name] [face Name] [face +N] │
+│ │ portrait │ │
+│ └──────────────┘ Legacy │
+│ • complete, bounded legacy line │
+│ • complete, bounded legacy line │
+│ • complete, bounded legacy line │
+└──────────────────────────────────────────────────────────┘
+```
+
+## Portrait
+
+- Reserve a fixed `40 × 40` vanilla stone frame at the upper-left of the body.
+- A living character uses their current animated sprite so unique equipment and appearance remain visible.
+- A fallen or unavailable character uses the last captured character sprite, then species art only as a fallback.
+- The frame and surrounding regions never move when the sprite source changes.
+- Vanity/decorative framing is disabled; the portrait identifies the character rather than decorating the card.
+
+## Cast
+
+Cast owns a fixed region to the right of the portrait.
+
+- Maximum six visible entries in a `3 × 2` grid.
+- Priority: partner, child/parent, close friend, rival, then other known relationships.
+- Each entry has a fixed icon cell and a single text line.
+- If more than six entries qualify, the sixth cell becomes `+N` rather than creating a third row.
+- Long labels ellipsize inside their cell; they never shrink the panel or overlap adjacent cells.
+- Empty state: `No close ties recorded` in the same region.
+
+## Legacy
+
+Legacy owns the full-width region below the portrait and Cast.
+
+- Maximum two rendered lines, allocated by durable narrative importance.
+- Prefer complete short statements. If a statement cannot fit its assigned line, ellipsize it.
+- Never add another row or increase the body height.
+- Do not repeat identity, species, role, relationship labels already visible in the nametag or Cast.
+- Empty state: `Their story is still unfolding`.
+
+## Rail portraits
+
+The rail is a visual index of individuals, not species.
+
+- Cache by `UnitId`, using the latest successfully captured inspect sprite.
+- Capture on roster admission and refresh while watched or previewed at a bounded cadence.
+- Retain the last valid unique sprite after focus changes or death.
+- Never replace a valid character sprite with generic species art.
+- Use species art only until no unique capture has ever succeeded.
+- Remove cached entries only when the character leaves the roster permanently.
+
+Two characters of the same species must remain visually distinguishable whenever the game has
+provided distinct character sprites.
+
+## Interaction states
+
+### Camera dossier
+
+Outside Saga hover, the normal dossier owns the nametag, Beat, Context, and live camera portrait.
+
+### Saga preview
+
+While a glyph is hovered:
+
+- Acquire the existing camera read-pause lease.
+- Rebind the primary nametag, species/role, sex, favorite, portrait, Cast, and Legacy to the hovered character.
+- Hide camera Beat, Context, and camera-specific dossier rows.
+- Keep the fixed Saga envelope and all controls in exactly the same coordinates.
+- Restore the latest camera dossier atomically on pointer exit.
+
+Rapidly scrubbing across glyphs must not briefly restore the camera identity between two Saga identities.
+
+### Click behavior
+
+Phase one keeps the existing click-to-toggle-Prefer behavior. A persistent pinned preview would
+conflict with that undiscoverable action, so pinning is deferred until Prefer has its own explicit
+control. Status banners cancel preview and retain priority.
+
+## Ownership
+
+| Element | Camera dossier | Saga preview |
+|---|---|---|
+| Glyph rail | `LifeSagaRail` | `LifeSagaRail` |
+| Nametag and controls | Camera subject | Hovered Saga character |
+| Large portrait | Camera subject | Hovered Saga character |
+| Beat / Context | Camera story | Hidden |
+| Cast / Legacy | Hidden | Hovered Saga character |
+| Camera focus | `InterestDirector` | Unchanged and paused for reading |
+
+## Layout invariants
+
+1. The glyph rail has the same screen coordinates for every roster member.
+2. The outer card rectangle has the same screen coordinates and dimensions for empty, short, and full content.
+3. The nametag control cluster never moves with name length.
+4. The large portrait frame never moves or resizes.
+5. Cast never creates a third row.
+6. Legacy never increases body height or exceeds two lines.
+7. Empty states reserve the same geometry as populated states.
+8. Entering or leaving hover does not move the glyph under the pointer.
+9. Pointer exit restores the complete current camera dossier, not a stale snapshot.
+10. Hover never changes camera focus or roster order.
+
+## Implementation plan
+
+### Phase A — Fixed shell
+
+1. Give the Saga root, rail, nametag, and body explicit fixed bounds.
+2. Remove content-driven preferred-height calculations from the hover path.
+3. Reserve portrait, Cast, and Legacy rectangles before binding any content.
+
+### Phase B — Portrait and Cast
+
+1. Place the restored live stone-frame portrait in its fixed rectangle.
+2. Replace flexible Cast layout with the bounded `3 × 2` grid.
+3. Add deterministic relationship priority, overflow, and empty-state behavior.
+
+### Phase C — Legacy allocator
+
+1. Rank durable facts by narrative importance.
+2. Remove facts redundant with the visible identity and Cast.
+3. Allocate at most two complete lines with measured ellipsis.
+
+### Phase D — Interaction hardening
+
+1. Make hover-to-hover swaps atomic.
+2. Make pointer exit restore the latest camera state.
+3. Verify status-banner cancellation and camera pause ownership.
+4. Consider pinned preview only after Prefer has an explicit separate control.
+
+## Verification
+
+Add deterministic scenarios:
+
+- `saga_fixed_shell` — empty, sparse, and full subjects produce identical bounds.
+- `saga_hover_identity_swap` — every identity element follows the hovered glyph.
+- `saga_hover_restore_camera` — exit restores the latest camera dossier.
+- `saga_rail_portrait_cache` — same-species characters retain distinct cached sprites.
+- `saga_cast_fixed_grid` — seven or more ties render five entries plus `+N`, with no third row.
+- `saga_legacy_fixed_budget` — long legacy prose remains inside two lines.
+- `saga_hover_scrub_stability` — rapid glyph changes do not move the rail or flash camera identity.
+
+Capture visual evidence for same-species characters, six Cast entries, a fallen cached portrait,
+empty Cast/Legacy, maximum-length names, and every supported UI scale.
+
+## Definition of done
+
+- All layout invariants pass in deterministic harness coverage.
+- Existing Saga/coherence scenarios remain green.
+- No duplicate camera/Saga information appears during preview.
+- No text clips or paints beyond its assigned region.
+- Same-species characters remain distinguishable after their unique sprite has been observed.
+- A frame-by-frame comparison shows zero rail, nametag, portrait-frame, or card-edge movement while scrubbing glyphs.
+
+## Non-goals
+
+- Saga does not become a second Chronicle.
+- Saga does not drive or lock camera selection.
+- The card does not show the current camera Beat or Context while previewing another character.
+- Persistent pinning is not added until its interaction no longer conflicts with Prefer.
diff --git a/docs/story-system-coherence-plan.md b/docs/story-system-coherence-plan.md
index 5df1381..e60f551 100644
--- a/docs/story-system-coherence-plan.md
+++ b/docs/story-system-coherence-plan.md
@@ -11,6 +11,7 @@ hardening remains before the three-pass release gate can be claimed**.
Related design documents:
- [Life Saga](life-saga.md)
+- [Saga UX redesign](saga-ux-redesign-plan.md)
- [Story Planner](story-planner.md)
- [Camera presentability](camera-presentability-plan.md)
- [Scoring model](scoring-model.md)