feat(caption): enhance saga hover functionality and visual stability

- Introduce new anchors for caption avatar and narrative positioning
- Implement stable typography capture during saga hover sessions
- Add diagnostics for avatar visibility and anchored position
- Refactor saga hover refresh logic to maintain visual consistency
- Update harness scenarios to validate hover visual stability
This commit is contained in:
DazedAnon 2026-07-24 14:29:10 -05:00
parent 12e2ba0d01
commit 96db725383
8 changed files with 236 additions and 54 deletions

View file

@ -42,6 +42,9 @@ public static class AgentHarness
private static string LastScreenshotPath = ""; private static string LastScreenshotPath = "";
private static Vector2 _sagaPanelSizeAnchor; private static Vector2 _sagaPanelSizeAnchor;
private static string _captionBeatAnchor = ""; private static string _captionBeatAnchor = "";
private static Vector2 _captionAvatarAnchor = Vector2.zero;
private static float _captionBodyAnchorY;
private static float _captionNarrativeAnchorY;
private static readonly string[] PreferredSpawnAssets = private static readonly string[] PreferredSpawnAssets =
{ {
"sheep", "cow", "pig", "chicken", "dog", "cat", "rabbit", "monkey", "fox", "crab" "sheep", "cow", "pig", "chicken", "dog", "cat", "rabbit", "monkey", "fox", "crab"
@ -4171,6 +4174,7 @@ public static class AgentHarness
{ {
LifeSagaRoster.Tick(Time.unscaledTime); LifeSagaRoster.Tick(Time.unscaledTime);
LifeSagaRail.Refresh(); LifeSagaRail.Refresh();
WatchCaption.RequestRelayout();
_cmdOk++; _cmdOk++;
Emit( Emit(
cmd, cmd,
@ -4182,8 +4186,16 @@ public static class AgentHarness
case "caption_capture_beat": case "caption_capture_beat":
_captionBeatAnchor = WatchCaption.VisibleBeatLine ?? ""; _captionBeatAnchor = WatchCaption.VisibleBeatLine ?? "";
_captionAvatarAnchor = DossierAvatar.HostAnchoredPosition;
_captionBodyAnchorY = WatchCaption.LastBodyAnchorY;
_captionNarrativeAnchorY = WatchCaption.LastNarrativeAnchorY;
_cmdOk++; _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; break;
case "saga_show_hover_first": case "saga_show_hover_first":
@ -4252,12 +4264,17 @@ public static class AgentHarness
LifeSagaRail.Refresh(); LifeSagaRail.Refresh();
long focusId = EventFeedUtil.SafeId(MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null); long focusId = EventFeedUtil.SafeId(MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null);
bool preferBefore = focusId != 0 && LifeSagaRoster.IsPrefer(focusId); 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(); WatchCaption.RequestRelayout();
// Prefer toggle only - does not open hover / pin saga subject. // Prefer toggle only - does not open hover / pin saga subject.
bool preferAfter = LifeSagaRail.LastPreferPipVisible bool focusPreferAfter = focusId != 0 && LifeSagaRoster.IsPrefer(focusId);
|| (focusId != 0 && LifeSagaRoster.IsPrefer(focusId)); bool preferAfter = LifeSagaRail.LastPreferPipVisible || focusPreferAfter;
bool ok = preferAfter || preferBefore != (focusId != 0 && LifeSagaRoster.IsPrefer(focusId)); bool ok = clicked && focusPreferAfter;
// After click, Prefer state on slot 0 should be readable. // After click, Prefer state on slot 0 should be readable.
var slots = new System.Collections.Generic.List<LifeSagaSlot>(LifeSagaRoster.Cap); var slots = new System.Collections.Generic.List<LifeSagaSlot>(LifeSagaRoster.Cap);
LifeSagaRoster.CopySlots(slots); LifeSagaRoster.CopySlots(slots);
@ -4275,7 +4292,12 @@ public static class AgentHarness
_cmdFail++; _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; 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}"; $"size={size} body={bodyW:0.#}x{bodyH:0.#} fixedW={LifeSagaPanel.BodyWidthFixed} outerH={WatchCaption.SagaHoverHeightFixed:0.#} onSaga={onSaga} compact={compact} dbg={LifeSagaPanel.LastLayoutDebug}";
break; 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": case "caption_mc_identity":
{ {
string idLine = WatchCaption.LastHeadline ?? ""; string idLine = WatchCaption.LastHeadline ?? "";

View file

@ -29,6 +29,14 @@ public static class DossierAvatar
/// <summary>Fallen archive species-icon portrait is showing.</summary> /// <summary>Fallen archive species-icon portrait is showing.</summary>
public static bool ShowingSpeciesFallback => _shownActorId == -2; public static bool ShowingSpeciesFallback => _shownActorId == -2;
/// <summary>Harness/UI diagnostic: portrait host is currently paintable.</summary>
public static bool HostVisible =>
_host != null && _host.gameObject.activeSelf;
/// <summary>Harness/UI diagnostic: final local placement within the dossier card.</summary>
public static Vector2 HostAnchoredPosition =>
_host != null ? _host.anchoredPosition : Vector2.zero;
/// <summary>True if <paramref name="screenPoint"/> is inside the live portrait rect.</summary> /// <summary>True if <paramref name="screenPoint"/> is inside the live portrait rect.</summary>
public static bool ContainsScreenPoint(Vector2 screenPoint) public static bool ContainsScreenPoint(Vector2 screenPoint)
{ {

View file

@ -3979,6 +3979,7 @@ internal static class HarnessScenarios
Step("sad10a", "saga_memory_record_focus", asset: "War", value: "opposing commander", Step("sad10a", "saga_memory_record_focus", asset: "War", value: "opposing commander",
expect: "harness_cast_relation"), expect: "harness_cast_relation"),
Step("sad11", "assert", expect: "saga_tab_selected", value: "dossier"), Step("sad11", "assert", expect: "saga_tab_selected", value: "dossier"),
Step("sad11a", "saga_rail_refresh"),
// saga_select_tab saga → hover preview of watched MC (compat). // saga_select_tab saga → hover preview of watched MC (compat).
Step("sad11b", "caption_capture_beat"), Step("sad11b", "caption_capture_beat"),
Step("sad12", "saga_select_tab", value: "saga"), Step("sad12", "saga_select_tab", value: "saga"),
@ -3986,6 +3987,7 @@ internal static class HarnessScenarios
Step("sad14", "assert", expect: "saga_panel_bound"), Step("sad14", "assert", expect: "saga_panel_bound"),
Step("sad14b", "assert", expect: "saga_panel_fixed_size"), Step("sad14b", "assert", expect: "saga_panel_fixed_size"),
Step("sad14c", "assert", expect: "caption_hover_keeps_beat"), 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("sad14d", "assert", expect: "saga_panel_cast_first"),
Step("sad14e", "assert", expect: "saga_panel_cast_relations", value: "1"), Step("sad14e", "assert", expect: "saga_panel_cast_relations", value: "1"),
Step("sad15", "screenshot", value: "saga-hover-depth"), Step("sad15", "screenshot", value: "saga-hover-depth"),

View file

@ -629,6 +629,26 @@ public static class LifeSagaRail
OnClick(index); OnClick(index);
} }
/// <summary>Harness: click the visible slot for an exact unit instead of assuming slot 0.</summary>
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) private static void OnClick(int index)
{ {
if (index < 0 || index >= Slots.Length) if (index < 0 || index >= Slots.Length)

View file

@ -88,6 +88,13 @@ public static class LifeSagaViewController
return; 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; _hoverExitAt = -1f;
_hoverPreviewId = unitId; _hoverPreviewId = unitId;
WatchCaption.PrimeSagaHoverHeader(unitId); WatchCaption.PrimeSagaHoverHeader(unitId);

View file

@ -127,6 +127,10 @@ public static class WatchCaption
private static string _statusBanner = ""; private static string _statusBanner = "";
private static float _statusBannerUntil; private static float _statusBannerUntil;
private static string _lastLoggedCaption = ""; 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 // OwnedEventReason performs stranger-name presentability checks against the living
// population. A developed world makes that expensive, while the active director // population. A developed world makes that expensive, while the active director
// candidate is normally unchanged for many 0.2-second caption refreshes. // 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 LastHeadline { get; private set; } = "";
public static string VisibleNametagText => _nameText != null ? (_nameText.text ?? "") : ""; 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; } = ""; public static string LastDetail { get; private set; } = "";
@ -246,6 +252,10 @@ public static class WatchCaption
+ LifeSagaPanel.BodyHeightFixed + 2f + PadTop - Gap; + LifeSagaPanel.BodyHeightFixed + 2f + PadTop - Gap;
public static bool LastLayoutOk { get; private set; } public static bool LastLayoutOk { get; private set; }
/// <summary>Harness/UI: top anchor shared by the dossier and Saga portrait.</summary>
public static float LastBodyAnchorY { get; private set; }
/// <summary>Harness/UI: first bottom narrative row after body/status/trait details.</summary>
public static float LastNarrativeAnchorY { get; private set; }
/// <summary>True when the dossier card GameObject is active.</summary> /// <summary>True when the dossier card GameObject is active.</summary>
public static bool Visible => _visible && _root != null && _root.activeSelf; public static bool Visible => _visible && _root != null && _root.activeSelf;
@ -537,6 +547,8 @@ public static class WatchCaption
_nextRoutineLiveRefreshAt = 0f; _nextRoutineLiveRefreshAt = 0f;
_nextRoutineSpineAt = 0f; _nextRoutineSpineAt = 0f;
_headlineLocked = false; _headlineLocked = false;
_sagaHoverHeadline = "";
_sagaHoverFontCeiling = 0;
_historyColW = HistoryColMinW; _historyColW = HistoryColMinW;
_statusBanner = ""; _statusBanner = "";
_statusBannerUntil = 0f; _statusBannerUntil = 0f;
@ -940,6 +952,18 @@ public static class WatchCaption
RefreshStorySpine(force: true); RefreshStorySpine(force: true);
} }
/// <summary>
/// Capture stable typography before Saga hover takes temporary ownership of the
/// painted header. Switching between glyphs must not make the name pulse larger.
/// </summary>
public static void BeginSagaHoverSession()
{
_sagaHoverHeadline = "";
_sagaHoverFontCeiling = _nameText != null
? Mathf.Clamp(_nameText.fontSize, 7, 10)
: 10;
}
/// <summary> /// <summary>
/// Swap the hovered identity before building Cast/Legacy. This keeps pointer feedback /// Swap the hovered identity before building Cast/Legacy. This keeps pointer feedback
/// immediate even when the deeper saga model has several relationships to resolve. /// immediate even when the deeper saga model has several relationships to resolve.
@ -968,7 +992,7 @@ public static class WatchCaption
SpeciesId = slot?.SpeciesId SpeciesId = slot?.SpeciesId
?? (actor != null && actor.asset != null ? actor.asset.id : "") ?? (actor != null && actor.asset != null ? actor.asset.id : "")
}; };
ApplySagaHoverHeader(model); ApplySagaHoverHeader(model, deferPortraitReveal: true);
} }
/// <summary> /// <summary>
@ -986,6 +1010,15 @@ public static class WatchCaption
RefreshOwnedReason(); RefreshOwnedReason();
} }
/// <summary>
/// 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.
/// </summary>
public static void HarnessRefreshSagaSteadyState()
{
RefreshStorySpine(force: true);
}
private static bool HasStatusBanner() private static bool HasStatusBanner()
{ {
return !string.IsNullOrEmpty(_statusBanner) && Time.unscaledTime < _statusBannerUntil; return !string.IsNullOrEmpty(_statusBanner) && Time.unscaledTime < _statusBannerUntil;
@ -1632,7 +1665,9 @@ public static class WatchCaption
LifeSagaPanel.Bind(model); LifeSagaPanel.Bind(model);
} }
private static void ApplySagaHoverHeader(LifeSagaViewModel model) private static void ApplySagaHoverHeader(
LifeSagaViewModel model,
bool deferPortraitReveal = false)
{ {
if (model == null || model.UnitId == 0) if (model == null || model.UnitId == 0)
{ {
@ -1656,11 +1691,12 @@ public static class WatchCaption
string headline = !string.IsNullOrEmpty(title) string headline = !string.IsNullOrEmpty(title)
? name + " · " + title ? name + " · " + title
: (!string.IsNullOrEmpty(species) ? name + " (" + species + ")" : name); : (!string.IsNullOrEmpty(species) ? name + " (" + species + ")" : name);
if (_nameText != null) _sagaHoverHeadline = headline.Length > 48
{
_nameText.text = headline.Length > 48
? CaptionComposer.TruncateIdentity(headline, 48) ? CaptionComposer.TruncateIdentity(headline, 48)
: headline; : headline;
if (_nameText != null)
{
_nameText.text = _sagaHoverHeadline;
} }
if (_speciesIcon != null) if (_speciesIcon != null)
@ -1692,6 +1728,13 @@ public static class WatchCaption
_sexIcon.gameObject.SetActive(sexSprite != null); _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()) if (actor != null && actor.isAlive())
{ {
DossierAvatar.Show(actor); DossierAvatar.Show(actor);
@ -1700,7 +1743,6 @@ public static class WatchCaption
{ {
DossierAvatar.ShowSpecies(model.SpeciesId); DossierAvatar.ShowSpecies(model.SpeciesId);
} }
DossierAvatar.SetHostVisible(true);
RefreshFavoriteVisualFor(model.UnitId, actor, fallbackFavorite: false); RefreshFavoriteVisualFor(model.UnitId, actor, fallbackFavorite: false);
} }
@ -3130,6 +3172,8 @@ public static class WatchCaption
bool hasReason, bool hasReason,
int historyCount) int historyCount)
{ {
LastBodyAnchorY = 0f;
LastNarrativeAnchorY = 0f;
float headerW = MeasureHeaderWidth(hasTask); float headerW = MeasureHeaderWidth(hasTask);
// Traits beside avatar only when history and statuses leave the body-right slot free. // Traits beside avatar only when history and statuses leave the body-right slot free.
bool traitsBeside = hasBody && historyCount <= 0 && statusCount <= 0 && traitCount > 0; bool traitsBeside = hasBody && historyCount <= 0 && statusCount <= 0 && traitCount > 0;
@ -3218,37 +3262,6 @@ public static class WatchCaption
if (!panelMode) if (!panelMode)
{ {
bool hasConnection = _connectionText != null
&& _connectionText.gameObject.activeSelf
&& !string.IsNullOrEmpty(_connectionText.text);
if (hasConnection)
{
PlaceLine(_connectionText.GetComponent<RectTransform>(), 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<RectTransform>() : null,
y,
reasonH,
PadX);
y += reasonH + Gap;
}
bool hasStorySpine = _storySpineText != null
&& _storySpineText.gameObject.activeSelf
&& !string.IsNullOrEmpty(_storySpineText.text);
if (hasStorySpine)
{
PlaceLine(_storySpineText.GetComponent<RectTransform>(), y, StorySpineH, PadX);
y += StorySpineH + Gap;
}
// Row visibility is owned by Place*/fill - never leave empty rows active at stale // 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). // positions (SetDossierBodyVisible only hides on hover; it must not resurrect them).
if (historyCount <= 0 && _historyCol != null) if (historyCount <= 0 && _historyCol != null)
@ -3268,6 +3281,7 @@ public static class WatchCaption
if (hasBody) if (hasBody)
{ {
LastBodyAnchorY = y;
float bodyH = historyCount > 0 ? MeasureHistoryColumnHeight(historyCount) : BodyH; float bodyH = historyCount > 0 ? MeasureHistoryColumnHeight(historyCount) : BodyH;
PlaceBody(y, historyCount, bodyH); PlaceBody(y, historyCount, bodyH);
if (historyCount > 0) if (historyCount > 0)
@ -3298,14 +3312,50 @@ public static class WatchCaption
WidenPanelForChipRow(traitsUsed); WidenPanelForChipRow(traitsUsed);
y += TraitsH + Gap; 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<RectTransform>(), y, ConnectionH, PadX);
y += ConnectionH + Gap;
}
if (hasReason)
{
float reasonH = MeasureReasonHeight();
PlaceLine(
_reasonText != null ? _reasonText.GetComponent<RectTransform>() : null,
y,
reasonH,
PadX);
y += reasonH + Gap;
}
bool hasStorySpine = _storySpineText != null
&& _storySpineText.gameObject.activeSelf
&& !string.IsNullOrEmpty(_storySpineText.text);
if (hasStorySpine)
{
PlaceLine(_storySpineText.GetComponent<RectTransform>(), y, StorySpineH, PadX);
y += StorySpineH + Gap;
}
} }
if (panelMode) if (panelMode)
{ {
LifeSagaPanel.EnsureBuilt(_root.transform); LifeSagaPanel.EnsureBuilt(_root.transform);
DossierAvatar.SetHostVisible(false);
LastBodyAnchorY = y;
LastNarrativeAnchorY = 0f;
DossierAvatar.Place(PadX, y, LiveMax); DossierAvatar.Place(PadX, y, LiveMax);
DossierAvatar.SetHostVisible(true);
y = LifeSagaPanel.Place(y, PadX); y = LifeSagaPanel.Place(y, PadX);
DossierAvatar.SetHostVisible(true);
if (railChrome) if (railChrome)
{ {
_panelWidth = PanelWidthMax; _panelWidth = PanelWidthMax;
@ -3605,8 +3655,14 @@ public static class WatchCaption
maxW = Mathf.Max(NameMinW, maxW); maxW = Mathf.Max(NameMinW, maxW);
_nameText.resizeTextForBestFit = false; _nameText.resizeTextForBestFit = false;
_nameText.horizontalOverflow = HorizontalWrapMode.Overflow; _nameText.horizontalOverflow = HorizontalWrapMode.Overflow;
_nameText.fontSize = 10; bool hover = LifeSagaViewController.IsHoverPreview;
string full = !string.IsNullOrEmpty(LastHeadline) ? LastHeadline : (_nameText.text ?? ""); 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)) if (string.IsNullOrEmpty(full))
{ {
_nameText.text = ""; _nameText.text = "";
@ -3620,7 +3676,10 @@ public static class WatchCaption
return Mathf.Clamp(natural, NameMinW, maxW); 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.fontSize = fittedSize;
_nameText.text = full; _nameText.text = full;
_nameText.verticalOverflow = VerticalWrapMode.Truncate; _nameText.verticalOverflow = VerticalWrapMode.Truncate;

View file

@ -21,7 +21,9 @@ validation window is pending.
- Civilized families use `Family` / `Kin`; `Pack` / `Packmate` / `Pack Alpha` are reserved for - 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 non-civilized creatures. WorldBox's shared Family/alpha storage is never exposed as human pack
terminology. 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. It no longer invokes the full dossier/species-population scan before the visible name swap.
## Product outcome ## 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 When the camera follows a non-MC because of an MC connection, the viewer must see that connection
immediately. 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 ```text
Identity Omya (Human) 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 - 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 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. 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`, - 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 `Waf's best friend`, `Waf's old foe`, `Fights beside Waf`, `Opposes Waf in the war`, or
`Caught in Waf's city`. `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 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 changes. Refresh a live portrait at the existing bounded cadence; do not refresh five portraits
every frame. 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 8. Measure maximum authored Connection strings at supported UI scales and keep them to one
ellipsized line. ellipsized line.
9. Include Connection identity in the dossier fingerprint so focus changes update atomically 9. Include Connection identity in the dossier fingerprint so focus changes update atomically

View file

@ -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), 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 while camera Beat/Context are hidden to prevent false attribution. Camera composition keeps
running underneath and restores immediately when hover ends. running underneath and restores immediately when hover ends.
Outside hover, the visible order is Identity, Connection, Beat, Context, then portrait/history and Outside hover, the visible order is Identity, portrait/history, status/trait details, then
status/trait details, so the reason for the current shot is never stranded below biography chrome. 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 ## 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. - 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. - 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. - 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. - 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 - Hover restores the hovered character's live stone-frame dossier portrait beside the panel
(species fallback only when no living actor is available). (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). - 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 - Hover identity is primed before Cast/Legacy binding and uses direct actor sex metadata; it does
not call the population-counting dossier builder. 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. - 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. - 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. - 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 ## Session