diff --git a/IdleSpectator/AgentHarness.cs b/IdleSpectator/AgentHarness.cs index b453ec8..0e434fe 100644 --- a/IdleSpectator/AgentHarness.cs +++ b/IdleSpectator/AgentHarness.cs @@ -42,6 +42,9 @@ public static class AgentHarness private static string LastScreenshotPath = ""; private static Vector2 _sagaPanelSizeAnchor; private static string _captionBeatAnchor = ""; + private static Vector2 _captionAvatarAnchor = Vector2.zero; + private static float _captionBodyAnchorY; + private static float _captionNarrativeAnchorY; private static readonly string[] PreferredSpawnAssets = { "sheep", "cow", "pig", "chicken", "dog", "cat", "rabbit", "monkey", "fox", "crab" @@ -4171,6 +4174,7 @@ public static class AgentHarness { LifeSagaRoster.Tick(Time.unscaledTime); LifeSagaRail.Refresh(); + WatchCaption.RequestRelayout(); _cmdOk++; Emit( cmd, @@ -4182,8 +4186,16 @@ public static class AgentHarness case "caption_capture_beat": _captionBeatAnchor = WatchCaption.VisibleBeatLine ?? ""; + _captionAvatarAnchor = DossierAvatar.HostAnchoredPosition; + _captionBodyAnchorY = WatchCaption.LastBodyAnchorY; + _captionNarrativeAnchorY = WatchCaption.LastNarrativeAnchorY; _cmdOk++; - Emit(cmd, ok: true, detail: $"beat='{_captionBeatAnchor}'"); + Emit( + cmd, + ok: true, + detail: + $"beat='{_captionBeatAnchor}' avatar={_captionAvatarAnchor} " + + $"anchors={_captionBodyAnchorY:0.0}/{_captionNarrativeAnchorY:0.0}"); break; case "saga_show_hover_first": @@ -4252,12 +4264,17 @@ public static class AgentHarness LifeSagaRail.Refresh(); long focusId = EventFeedUtil.SafeId(MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null); bool preferBefore = focusId != 0 && LifeSagaRoster.IsPrefer(focusId); - LifeSagaRail.HarnessClick(0); + int clicks = preferBefore ? 2 : 1; + bool clicked = true; + for (int i = 0; i < clicks; i++) + { + clicked = LifeSagaRail.HarnessClickUnit(focusId) && clicked; + } WatchCaption.RequestRelayout(); // Prefer toggle only - does not open hover / pin saga subject. - bool preferAfter = LifeSagaRail.LastPreferPipVisible - || (focusId != 0 && LifeSagaRoster.IsPrefer(focusId)); - bool ok = preferAfter || preferBefore != (focusId != 0 && LifeSagaRoster.IsPrefer(focusId)); + bool focusPreferAfter = focusId != 0 && LifeSagaRoster.IsPrefer(focusId); + bool preferAfter = LifeSagaRail.LastPreferPipVisible || focusPreferAfter; + bool ok = clicked && focusPreferAfter; // After click, Prefer state on slot 0 should be readable. var slots = new System.Collections.Generic.List(LifeSagaRoster.Cap); LifeSagaRoster.CopySlots(slots); @@ -4275,7 +4292,12 @@ public static class AgentHarness _cmdFail++; } - Emit(cmd, ok, detail: $"preferBefore={preferBefore} preferAfter={preferAfter} hover={LifeSagaViewController.IsHoverPreview}"); + Emit( + cmd, + ok, + detail: + $"clicked={clicked} clicks={clicks} focus={focusId} preferBefore={preferBefore} " + + $"preferAfter={preferAfter} hover={LifeSagaViewController.IsHoverPreview}"); break; } @@ -10279,6 +10301,57 @@ public static class AgentHarness $"size={size} body={bodyW:0.#}x{bodyH:0.#} fixedW={LifeSagaPanel.BodyWidthFixed} outerH={WatchCaption.SagaHoverHeightFixed:0.#} onSaga={onSaga} compact={compact} dbg={LifeSagaPanel.LastLayoutDebug}"; break; } + case "saga_hover_visual_stable": + { + LifeSagaViewModel model = LifeSagaViewController.BuildEffectivePresentation(); + string hoverName = (model?.Name ?? "").Replace("★ ", "").Trim(); + + // Organic idle refresh: no layout invalidation. This catches visibility + // ownership bugs hidden by the initial pointer-enter relayout. + WatchCaption.HarnessRefreshSagaSteadyState(); + bool steadyPanel = LifeSagaPanel.Visible; + bool steadyAvatar = DossierAvatar.HostVisible; + + string beforeName = WatchCaption.VisibleNametagText; + int beforeFont = WatchCaption.VisibleNametagFontSize; + Vector2 beforeAvatar = DossierAvatar.HostAnchoredPosition; + + // A second same-hover refresh used to overwrite the hovered name with the + // camera headline, enlarge the font, and reveal the portrait before placement. + WatchCaption.RequestRelayout(); + + string afterName = WatchCaption.VisibleNametagText; + int afterFont = WatchCaption.VisibleNametagFontSize; + Vector2 afterAvatar = DossierAvatar.HostAnchoredPosition; + bool correctName = !string.IsNullOrEmpty(hoverName) + && beforeName.IndexOf(hoverName, StringComparison.Ordinal) >= 0 + && afterName.IndexOf(hoverName, StringComparison.Ordinal) >= 0; + bool fontStable = beforeFont == afterFont + && afterFont > 0 + && afterFont <= WatchCaption.SagaHoverFontCeiling; + bool avatarStable = DossierAvatar.HostVisible + && Vector2.Distance(beforeAvatar, afterAvatar) < 0.1f + && Vector2.Distance(_captionAvatarAnchor, afterAvatar) < 0.1f; + bool narrativeBelowBody = _captionNarrativeAnchorY + >= _captionBodyAnchorY + 39.9f; + pass = LifeSagaViewController.IsHoverPreview + && LifeSagaPanel.Visible + && steadyPanel + && steadyAvatar + && correctName + && fontStable + && avatarStable + && narrativeBelowBody; + detail = + $"name='{beforeName}'/'{afterName}' hoverName='{hoverName}' " + + $"font={beforeFont}/{afterFont} ceiling={WatchCaption.SagaHoverFontCeiling} " + + $"avatar={_captionAvatarAnchor}/{beforeAvatar}/{afterAvatar} " + + $"dossierAnchors={_captionBodyAnchorY:0.0}/{_captionNarrativeAnchorY:0.0} " + + $"hoverAnchor={WatchCaption.LastBodyAnchorY:0.0} below={narrativeBelowBody} " + + $"steady={steadyPanel}/{steadyAvatar} " + + $"visible={DossierAvatar.HostVisible} pass={pass}"; + break; + } case "caption_mc_identity": { string idLine = WatchCaption.LastHeadline ?? ""; diff --git a/IdleSpectator/DossierAvatar.cs b/IdleSpectator/DossierAvatar.cs index e1632a7..4725fd6 100644 --- a/IdleSpectator/DossierAvatar.cs +++ b/IdleSpectator/DossierAvatar.cs @@ -29,6 +29,14 @@ public static class DossierAvatar /// Fallen archive species-icon portrait is showing. public static bool ShowingSpeciesFallback => _shownActorId == -2; + /// Harness/UI diagnostic: portrait host is currently paintable. + public static bool HostVisible => + _host != null && _host.gameObject.activeSelf; + + /// Harness/UI diagnostic: final local placement within the dossier card. + public static Vector2 HostAnchoredPosition => + _host != null ? _host.anchoredPosition : Vector2.zero; + /// True if is inside the live portrait rect. public static bool ContainsScreenPoint(Vector2 screenPoint) { diff --git a/IdleSpectator/HarnessScenarios.cs b/IdleSpectator/HarnessScenarios.cs index 499f741..5fd1675 100644 --- a/IdleSpectator/HarnessScenarios.cs +++ b/IdleSpectator/HarnessScenarios.cs @@ -3979,6 +3979,7 @@ internal static class HarnessScenarios Step("sad10a", "saga_memory_record_focus", asset: "War", value: "opposing commander", expect: "harness_cast_relation"), Step("sad11", "assert", expect: "saga_tab_selected", value: "dossier"), + Step("sad11a", "saga_rail_refresh"), // saga_select_tab saga → hover preview of watched MC (compat). Step("sad11b", "caption_capture_beat"), Step("sad12", "saga_select_tab", value: "saga"), @@ -3986,6 +3987,7 @@ internal static class HarnessScenarios Step("sad14", "assert", expect: "saga_panel_bound"), Step("sad14b", "assert", expect: "saga_panel_fixed_size"), Step("sad14c", "assert", expect: "caption_hover_keeps_beat"), + Step("sad14c2", "assert", expect: "saga_hover_visual_stable"), Step("sad14d", "assert", expect: "saga_panel_cast_first"), Step("sad14e", "assert", expect: "saga_panel_cast_relations", value: "1"), Step("sad15", "screenshot", value: "saga-hover-depth"), diff --git a/IdleSpectator/Story/LifeSagaRail.cs b/IdleSpectator/Story/LifeSagaRail.cs index 820f167..1a00b25 100644 --- a/IdleSpectator/Story/LifeSagaRail.cs +++ b/IdleSpectator/Story/LifeSagaRail.cs @@ -629,6 +629,26 @@ public static class LifeSagaRail OnClick(index); } + /// Harness: click the visible slot for an exact unit instead of assuming slot 0. + internal static bool HarnessClickUnit(long unitId) + { + if (unitId == 0) + { + return false; + } + + for (int i = 0; i < Slots.Length; i++) + { + if (Slots[i] != null && Slots[i].UnitId == unitId) + { + OnClick(i); + return true; + } + } + + return false; + } + private static void OnClick(int index) { if (index < 0 || index >= Slots.Length) diff --git a/IdleSpectator/Story/LifeSagaViewController.cs b/IdleSpectator/Story/LifeSagaViewController.cs index 2fd91e4..522deb0 100644 --- a/IdleSpectator/Story/LifeSagaViewController.cs +++ b/IdleSpectator/Story/LifeSagaViewController.cs @@ -88,6 +88,13 @@ public static class LifeSagaViewController return; } + if (_hoverPreviewId == 0) + { + // Capture the already-painted dossier typography before hover changes ownership + // of the header. Every glyph in this hover session uses that same ceiling. + WatchCaption.BeginSagaHoverSession(); + } + _hoverExitAt = -1f; _hoverPreviewId = unitId; WatchCaption.PrimeSagaHoverHeader(unitId); diff --git a/IdleSpectator/WatchCaption.cs b/IdleSpectator/WatchCaption.cs index 5088ba2..7243eaa 100644 --- a/IdleSpectator/WatchCaption.cs +++ b/IdleSpectator/WatchCaption.cs @@ -127,6 +127,10 @@ public static class WatchCaption private static string _statusBanner = ""; private static float _statusBannerUntil; private static string _lastLoggedCaption = ""; + // Saga hover owns the painted header without changing the camera dossier's semantic + // LastHeadline. Keep its display text and entry typography separate from that cache. + private static string _sagaHoverHeadline = ""; + private static int _sagaHoverFontCeiling; // OwnedEventReason performs stranger-name presentability checks against the living // population. A developed world makes that expensive, while the active director // candidate is normally unchanged for many 0.2-second caption refreshes. @@ -175,6 +179,8 @@ public static class WatchCaption public static string LastHeadline { get; private set; } = ""; public static string VisibleNametagText => _nameText != null ? (_nameText.text ?? "") : ""; + public static int VisibleNametagFontSize => _nameText != null ? _nameText.fontSize : 0; + public static int SagaHoverFontCeiling => _sagaHoverFontCeiling; public static string LastDetail { get; private set; } = ""; @@ -246,6 +252,10 @@ public static class WatchCaption + LifeSagaPanel.BodyHeightFixed + 2f + PadTop - Gap; public static bool LastLayoutOk { get; private set; } + /// Harness/UI: top anchor shared by the dossier and Saga portrait. + public static float LastBodyAnchorY { get; private set; } + /// Harness/UI: first bottom narrative row after body/status/trait details. + public static float LastNarrativeAnchorY { get; private set; } /// True when the dossier card GameObject is active. public static bool Visible => _visible && _root != null && _root.activeSelf; @@ -537,6 +547,8 @@ public static class WatchCaption _nextRoutineLiveRefreshAt = 0f; _nextRoutineSpineAt = 0f; _headlineLocked = false; + _sagaHoverHeadline = ""; + _sagaHoverFontCeiling = 0; _historyColW = HistoryColMinW; _statusBanner = ""; _statusBannerUntil = 0f; @@ -940,6 +952,18 @@ public static class WatchCaption RefreshStorySpine(force: true); } + /// + /// Capture stable typography before Saga hover takes temporary ownership of the + /// painted header. Switching between glyphs must not make the name pulse larger. + /// + public static void BeginSagaHoverSession() + { + _sagaHoverHeadline = ""; + _sagaHoverFontCeiling = _nameText != null + ? Mathf.Clamp(_nameText.fontSize, 7, 10) + : 10; + } + /// /// Swap the hovered identity before building Cast/Legacy. This keeps pointer feedback /// immediate even when the deeper saga model has several relationships to resolve. @@ -968,7 +992,7 @@ public static class WatchCaption SpeciesId = slot?.SpeciesId ?? (actor != null && actor.asset != null ? actor.asset.id : "") }; - ApplySagaHoverHeader(model); + ApplySagaHoverHeader(model, deferPortraitReveal: true); } /// @@ -986,6 +1010,15 @@ public static class WatchCaption RefreshOwnedReason(); } + /// + /// Harness: exercise the periodic same-hover refresh path without marking geometry dirty. + /// Cast/Legacy and portrait must remain visible when Relayout correctly has no work. + /// + public static void HarnessRefreshSagaSteadyState() + { + RefreshStorySpine(force: true); + } + private static bool HasStatusBanner() { return !string.IsNullOrEmpty(_statusBanner) && Time.unscaledTime < _statusBannerUntil; @@ -1632,7 +1665,9 @@ public static class WatchCaption LifeSagaPanel.Bind(model); } - private static void ApplySagaHoverHeader(LifeSagaViewModel model) + private static void ApplySagaHoverHeader( + LifeSagaViewModel model, + bool deferPortraitReveal = false) { if (model == null || model.UnitId == 0) { @@ -1656,11 +1691,12 @@ public static class WatchCaption string headline = !string.IsNullOrEmpty(title) ? name + " · " + title : (!string.IsNullOrEmpty(species) ? name + " (" + species + ")" : name); + _sagaHoverHeadline = headline.Length > 48 + ? CaptionComposer.TruncateIdentity(headline, 48) + : headline; if (_nameText != null) { - _nameText.text = headline.Length > 48 - ? CaptionComposer.TruncateIdentity(headline, 48) - : headline; + _nameText.text = _sagaHoverHeadline; } if (_speciesIcon != null) @@ -1692,6 +1728,13 @@ public static class WatchCaption _sexIcon.gameObject.SetActive(sexSprite != null); } + // The pointer-enter prime stages the new portrait while hidden. Periodic same-hover + // refreshes must preserve its current visibility because unchanged geometry correctly + // skips Relayout. + if (deferPortraitReveal) + { + DossierAvatar.SetHostVisible(false); + } if (actor != null && actor.isAlive()) { DossierAvatar.Show(actor); @@ -1700,7 +1743,6 @@ public static class WatchCaption { DossierAvatar.ShowSpecies(model.SpeciesId); } - DossierAvatar.SetHostVisible(true); RefreshFavoriteVisualFor(model.UnitId, actor, fallbackFavorite: false); } @@ -3130,6 +3172,8 @@ public static class WatchCaption bool hasReason, int historyCount) { + LastBodyAnchorY = 0f; + LastNarrativeAnchorY = 0f; float headerW = MeasureHeaderWidth(hasTask); // Traits beside avatar only when history and statuses leave the body-right slot free. bool traitsBeside = hasBody && historyCount <= 0 && statusCount <= 0 && traitCount > 0; @@ -3218,37 +3262,6 @@ public static class WatchCaption if (!panelMode) { - bool hasConnection = _connectionText != null - && _connectionText.gameObject.activeSelf - && !string.IsNullOrEmpty(_connectionText.text); - if (hasConnection) - { - PlaceLine(_connectionText.GetComponent(), y, ConnectionH, PadX); - y += ConnectionH + Gap; - } - - // Narrative header order: Identity → Connection → Beat → Context. The event that - // explains the shot must be readable before portrait, history, status, and traits. - if (hasReason) - { - float reasonH = MeasureReasonHeight(); - PlaceLine( - _reasonText != null ? _reasonText.GetComponent() : null, - y, - reasonH, - PadX); - y += reasonH + Gap; - } - - bool hasStorySpine = _storySpineText != null - && _storySpineText.gameObject.activeSelf - && !string.IsNullOrEmpty(_storySpineText.text); - if (hasStorySpine) - { - PlaceLine(_storySpineText.GetComponent(), y, StorySpineH, PadX); - y += StorySpineH + Gap; - } - // Row visibility is owned by Place*/fill - never leave empty rows active at stale // positions (SetDossierBodyVisible only hides on hover; it must not resurrect them). if (historyCount <= 0 && _historyCol != null) @@ -3268,6 +3281,7 @@ public static class WatchCaption if (hasBody) { + LastBodyAnchorY = y; float bodyH = historyCount > 0 ? MeasureHistoryColumnHeight(historyCount) : BodyH; PlaceBody(y, historyCount, bodyH); if (historyCount > 0) @@ -3298,14 +3312,50 @@ public static class WatchCaption WidenPanelForChipRow(traitsUsed); y += TraitsH + Gap; } + + // Keep the portrait/body anchor invariant between Dossier and Saga hover. + // Connection, orange Beat, and Context explain the shot after the character + // details instead of inserting a variable-height gap above the portrait. + LastNarrativeAnchorY = y; + bool hasConnection = _connectionText != null + && _connectionText.gameObject.activeSelf + && !string.IsNullOrEmpty(_connectionText.text); + if (hasConnection) + { + PlaceLine(_connectionText.GetComponent(), y, ConnectionH, PadX); + y += ConnectionH + Gap; + } + + if (hasReason) + { + float reasonH = MeasureReasonHeight(); + PlaceLine( + _reasonText != null ? _reasonText.GetComponent() : null, + y, + reasonH, + PadX); + y += reasonH + Gap; + } + + bool hasStorySpine = _storySpineText != null + && _storySpineText.gameObject.activeSelf + && !string.IsNullOrEmpty(_storySpineText.text); + if (hasStorySpine) + { + PlaceLine(_storySpineText.GetComponent(), y, StorySpineH, PadX); + y += StorySpineH + Gap; + } } if (panelMode) { LifeSagaPanel.EnsureBuilt(_root.transform); + DossierAvatar.SetHostVisible(false); + LastBodyAnchorY = y; + LastNarrativeAnchorY = 0f; DossierAvatar.Place(PadX, y, LiveMax); - DossierAvatar.SetHostVisible(true); y = LifeSagaPanel.Place(y, PadX); + DossierAvatar.SetHostVisible(true); if (railChrome) { _panelWidth = PanelWidthMax; @@ -3605,8 +3655,14 @@ public static class WatchCaption maxW = Mathf.Max(NameMinW, maxW); _nameText.resizeTextForBestFit = false; _nameText.horizontalOverflow = HorizontalWrapMode.Overflow; - _nameText.fontSize = 10; - string full = !string.IsNullOrEmpty(LastHeadline) ? LastHeadline : (_nameText.text ?? ""); + bool hover = LifeSagaViewController.IsHoverPreview; + int baseFontSize = hover && _sagaHoverFontCeiling > 0 + ? Mathf.Clamp(_sagaHoverFontCeiling, 7, 10) + : 10; + _nameText.fontSize = baseFontSize; + string full = hover && !string.IsNullOrEmpty(_sagaHoverHeadline) + ? _sagaHoverHeadline + : (!string.IsNullOrEmpty(LastHeadline) ? LastHeadline : (_nameText.text ?? "")); if (string.IsNullOrEmpty(full)) { _nameText.text = ""; @@ -3620,7 +3676,10 @@ public static class WatchCaption return Mathf.Clamp(natural, NameMinW, maxW); } - int fittedSize = Mathf.Clamp(Mathf.FloorToInt(10f * maxW / natural), 7, 10); + int fittedSize = Mathf.Clamp( + Mathf.FloorToInt(baseFontSize * maxW / natural), + 7, + baseFontSize); _nameText.fontSize = fittedSize; _nameText.text = full; _nameText.verticalOverflow = VerticalWrapMode.Truncate; diff --git a/docs/five-mc-ensemble-plan.md b/docs/five-mc-ensemble-plan.md index 446fa6f..1b15730 100644 --- a/docs/five-mc-ensemble-plan.md +++ b/docs/five-mc-ensemble-plan.md @@ -21,7 +21,9 @@ validation window is pending. - Civilized families use `Family` / `Kin`; `Pack` / `Packmate` / `Pack Alpha` are reserved for non-civilized creatures. WorldBox's shared Family/alpha storage is never exposed as human pack terminology. -- Saga hover primes the new identity immediately and reads sex directly from the hovered actor. +- Saga hover primes the new identity immediately, reads sex directly from the hovered actor, keeps + the entry font ceiling, and reveals the staged portrait only after its fixed layout is placed; + steady-state refresh never hides already-placed hover chrome. It no longer invokes the full dossier/species-population scan before the visible name swap. ## Product outcome @@ -198,7 +200,8 @@ Recognition should come from repeated, stable signals rather than extra prose on When the camera follows a non-MC because of an MC connection, the viewer must see that connection immediately. -- Add one muted, single-line **Connection** row directly below Identity and above the orange Beat: +- Add one muted, single-line **Connection** row at the start of the bottom narrative group, + directly above the orange Beat: ```text Identity Omya (Human) @@ -214,7 +217,8 @@ immediately. - When an event's exact named participant has a typed relation to a current MC and that relation is not already stated, Beat may add one compact qualifier: `Kills Omya (child of Waf)`. This explains significance without turning the focused character's Connection into duplicate prose. -- Render Identity → Connection → Beat → Context before portrait/history and detail chips. +- Render Identity → portrait/history → status/trait details → Connection → Beat → Context. + Bottom-grouping the narrative rows keeps the portrait anchored when Saga hover hides them. - Prefer the clearest confirmed relation: `Waf's partner`, `Waf's daughter`, `Waf's father`, `Waf's best friend`, `Waf's old foe`, `Fights beside Waf`, `Opposes Waf in the war`, or `Caught in Waf's city`. @@ -327,7 +331,7 @@ refill budget. 6. Bind and relayout the rail only when its ordered unit-id sequence or visible state actually changes. Refresh a live portrait at the existing bounded cadence; do not refresh five portraits every frame. -7. Add the one-line Connection slot to the camera dossier between Identity and Beat. +7. Add the one-line Connection slot above Beat in the bottom narrative group. 8. Measure maximum authored Connection strings at supported UI scales and keep them to one ellipsized line. 9. Include Connection identity in the dossier fingerprint so focus changes update atomically diff --git a/docs/life-saga.md b/docs/life-saga.md index 7de005a..2aa1485 100644 --- a/docs/life-saga.md +++ b/docs/life-saga.md @@ -96,8 +96,9 @@ Outside hover, caption Identity/Beat/Context track camera focus. During Saga hov primary nametag temporarily binds to the hovered MC (species, name/title, sex, favorite), while camera Beat/Context are hidden to prevent false attribution. Camera composition keeps running underneath and restores immediately when hover ends. -Outside hover, the visible order is Identity, Connection, Beat, Context, then portrait/history and -status/trait details, so the reason for the current shot is never stranded below biography chrome. +Outside hover, the visible order is Identity, portrait/history, status/trait details, then +Connection, Beat, and Context. Narrative explanation remains grouped at the bottom while the +portrait keeps the same body anchor used by Saga hover. ## Rail UX @@ -111,6 +112,10 @@ status/trait details, so the reason for the current shot is never stranded below - Hovering a glyph opens Cast/Legacy depth (`LifeSagaPanel`) and pauses camera switching. - Leaving the glyph hides the panel, restores dossier body rows, and releases the pause lease. - Hover temporarily swaps the primary nametag to the hovered life; it does not change camera focus. +- The hovered name, font, portrait, and Cast/Legacy body commit as one visual transition. The name + keeps the dossier's entry font ceiling for the full hover session, and pointer-enter stages the + portrait hidden until its fixed position is assigned. Steady-state refresh preserves visibility + when unchanged geometry correctly skips relayout. - The orange camera Beat and Context are hidden during hover rather than attributed to the hovered life. - Hover restores the hovered character's live stone-frame dossier portrait beside the panel (species fallback only when no living actor is available). @@ -198,10 +203,14 @@ Quiet can refresh briefly when a soft crumb ends (bounded). - Live portrait tile refresh is ~5 Hz; rail live sprites are only for the watched glyph (others use species icons). - Hover identity is primed before Cast/Legacy binding and uses direct actor sex metadata; it does not call the population-counting dossier builder. +- Hover fitting reads its own display headline rather than the camera dossier's cached identity; + repeated relayouts therefore cannot briefly restore the wrong name or enlarge the font. - Rail face animation refreshes the Active glyph ~1/s; other glyphs stay static between structure changes. - Plot join memory records the joiner only; full member snapshots stay on create/finish/cancel. - RelationEvidence caches invalidate on memory revision + live lover/friend change. -- Hitch probe: harness `hitch_probe` / load save via `load_save_slot` (slot 2 = Box of Magic). Optional `.harness/autoload_slot`. +- Hitch probe: run harness scenario `narrative_scheduler_health` (it enables the + `hitch_probe` action) / load save via `load_save_slot` (slot 2 = Box of Magic). + Optional `.harness/autoload_slot`. ## Session