feat(saga): stabilize hover dossier layout

- Fix Saga geometry so portraits and glyphs remain aligned during hover
- Bound Cast and Legacy content with overflow and empty states
- Prevent hidden camera beats from reserving Saga layout space
- Add fixed-size regression coverage and document the UX contract
This commit is contained in:
DazedAnon 2026-07-22 19:47:38 -05:00
parent 086921bd95
commit 0c60f1a2ba
8 changed files with 365 additions and 141 deletions

View file

@ -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":

View file

@ -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),

View file

@ -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.
/// </summary>
public static class LifeSagaPanel
{
/// <summary>Remaining hover width beside the 40px live portrait.</summary>
public const float BodyWidthFixed = 188f;
/// <summary>Hard cap so Saga never eats the screen.</summary>
public const float BodyHeightMax = 172f;
/// <summary>Full inner hover width; Cast starts beside the portrait, Legacy spans below it.</summary>
public const float BodyWidthFixed = 232f;
/// <summary>Fixed hover depth; never derived from Cast or Legacy content.</summary>
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;
/// <summary>Shared cast budget for panel slots and presentation model.</summary>
public const int MaxCast = 8;
private const int MaxLegacy = 6;
/// <summary>Visible Cast cells. The sixth cell becomes +N when candidates overflow.</summary>
public const int MaxCast = 6;
/// <summary>Relationships retained by the model before the visible grid is allocated.</summary>
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<LifeSagaCastMember>(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<RectTransform>();
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<RectTransform>();
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<string>(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);
}
/// <summary>The camera Beat already explains this exact member for this exact subject.</summary>
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);

View file

@ -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;
}

View file

@ -206,6 +206,11 @@ public static class WatchCaption
public static Vector2 LastPanelSize { get; private set; }
/// <summary>Exact outer height of rail + nametag + fixed Saga body.</summary>
public static float SagaHoverHeightFixed =>
PadTop + LifeSagaRail.SlotSize + 2f + HeaderH + Gap
+ LifeSagaPanel.BodyHeightFixed + 2f + PadTop - Gap;
public static bool LastLayoutOk { get; private set; }
/// <summary>True when the dossier card GameObject is active.</summary>
@ -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;

View file

@ -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`.

View file

@ -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.

View file

@ -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)