From 0c60f1a2ba9c20b22bc768be5072d0fe4b79ec66 Mon Sep 17 00:00:00 2001 From: DazedAnon Date: Wed, 22 Jul 2026 19:47:38 -0500 Subject: [PATCH] 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 --- IdleSpectator/AgentHarness.cs | 12 +- IdleSpectator/HarnessScenarios.cs | 1 + IdleSpectator/Story/LifeSagaPanel.cs | 235 ++++++++++---------- IdleSpectator/Story/LifeSagaPresentation.cs | 18 +- IdleSpectator/WatchCaption.cs | 20 +- docs/life-saga.md | 1 + docs/saga-ux-redesign-plan.md | 218 ++++++++++++++++++ docs/story-system-coherence-plan.md | 1 + 8 files changed, 365 insertions(+), 141 deletions(-) create mode 100644 docs/saga-ux-redesign-plan.md 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)