Phase 21Q - Production Visualisation Repair

This commit is contained in:
Nick Beckley 2026-07-15 20:47:23 +00:00
parent fd37df65c6
commit 4e31784f46
8 changed files with 490 additions and 32 deletions

View File

@ -54,6 +54,8 @@ var tests = new (string Name, Action Test)[]
("Illustration assignment repair enforces explicit evidence groups and strict subtypes", IllustrationAssignmentRepairEnforcesExplicitEvidenceGroupsAndStrictSubtypes), ("Illustration assignment repair enforces explicit evidence groups and strict subtypes", IllustrationAssignmentRepairEnforcesExplicitEvidenceGroupsAndStrictSubtypes),
("Phase 21O preserves adult defaults and real-mode continuity", Phase21OPreservesAdultDefaultsAndRealModeContinuity), ("Phase 21O preserves adult defaults and real-mode continuity", Phase21OPreservesAdultDefaultsAndRealModeContinuity),
("Phase 21P uses one authoritative current visualisation scene", Phase21PUsesOneAuthoritativeCurrentVisualisationScene), ("Phase 21P uses one authoritative current visualisation scene", Phase21PUsesOneAuthoritativeCurrentVisualisationScene),
("Phase 21Q repairs production visualisation regressions", Phase21QRepairsProductionVisualisationRegressions),
("Phase 21Q view keeps panels bounded and real controls hidden", Phase21QViewKeepsPanelsBoundedAndRealControlsHidden),
("Scan review post supports full-book form submissions", ScanReviewPostSupportsFullBookFormSubmissions), ("Scan review post supports full-book form submissions", ScanReviewPostSupportsFullBookFormSubmissions),
("Story Intelligence experience boot does not serialise live model", StoryIntelligenceExperienceBootDoesNotSerialiseLiveModel), ("Story Intelligence experience boot does not serialise live model", StoryIntelligenceExperienceBootDoesNotSerialiseLiveModel),
("Live visualisation strips illustration diagnostics", LiveVisualisationStripsIllustrationDiagnostics), ("Live visualisation strips illustration diagnostics", LiveVisualisationStripsIllustrationDiagnostics),
@ -628,7 +630,7 @@ static void Phase21OPreservesAdultDefaultsAndRealModeContinuity()
Assert(examiner.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.Adult, $"Driving examiner should infer Adult, got {examiner.AgeBand}."); Assert(examiner.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.Adult, $"Driving examiner should infer Adult, got {examiner.AgeBand}.");
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Bristol Road") == "Road", "Road names should classify as Road."); Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Bristol Road") == "Road", "Road names should classify as Road.");
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Cock Hill Lane") == "Road", "Lane names should classify as Road."); Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Cock Hill Lane") == "Road", "Lane names should classify as Road.");
Assert(!StoryIntelligenceIllustrationCompatibility.AssetCompatible("Hatchback", "Car"), "Hatchbacks must not borrow generic car artwork."); Assert(StoryIntelligenceIllustrationCompatibility.AssetCompatible("Hatchback", "Car"), "Hatchbacks may fall back to neutral generic car artwork.");
Assert(!StoryIntelligenceIllustrationCompatibility.AssetCompatible("SportsCar", "Hatchback"), "Vehicle subtypes should remain strict."); Assert(!StoryIntelligenceIllustrationCompatibility.AssetCompatible("SportsCar", "Hatchback"), "Vehicle subtypes should remain strict.");
var view = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "PlotLine", "Views", "Development", "StoryIntelligenceExperience.cshtml")); var view = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "PlotLine", "Views", "Development", "StoryIntelligenceExperience.cshtml"));
@ -653,11 +655,11 @@ static void Phase21PUsesOneAuthoritativeCurrentVisualisationScene()
Assert(view.Contains("Scenes = Model.Scenes", StringComparison.Ordinal), "Initial real page payload must include the same scene collection as polled snapshots."); Assert(view.Contains("Scenes = Model.Scenes", StringComparison.Ordinal), "Initial real page payload must include the same scene collection as polled snapshots.");
} }
static IllustrationCharacterMetadata Metadata(string code, string ageBand, string presentation, bool isUnknownFigure = false) static IllustrationCharacterMetadata Metadata(string code, string ageBand, string presentation, bool isUnknownFigure = false, string hairColour = "Unknown")
=> new( => new(
ageBand, ageBand,
presentation, presentation,
"Unknown", hairColour,
"Unknown", "Unknown",
"Unknown", "Unknown",
"Unknown", "Unknown",
@ -866,6 +868,60 @@ static void IllustrationEvidenceTraceDoesNotRecursivelyStorePreviousEvidence()
Assert(source.Contains("evidenceHash", StringComparison.Ordinal), "Evidence trace should keep only a compact previous evidence hash."); Assert(source.Contains("evidenceHash", StringComparison.Ordinal), "Evidence trace should keep only a compact previous evidence hash.");
} }
static void Phase21QRepairsProductionVisualisationRegressions()
{
var unknownNamed = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Sarah", ["Sarah watches from the lane."], []);
var mrColin = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Mr Colin Webb", ["Mr Colin Webb is a driving examiner."], []);
var maggie = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Maggie", ["Maggie is sixteen.", "Maggie pushed back her red hair."], []);
var darkHairCandidate = Metadata("char-young-adult-brunette-observer", "OlderTeen", "Feminine", hairColour: "Brown");
var teenCandidate = Metadata("char-young-helper", "YoungTeen", "Androgynous");
Assert(unknownNamed.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.Unknown, $"Unknown named age must remain Unknown, got {unknownNamed.AgeBand}.");
Assert(mrColin.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Masculine, $"Mr Colin should infer Masculine, got {mrColin.Presentation}.");
Assert(mrColin.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.Adult, $"Driving examiner title/profession should infer Adult, got {mrColin.AgeBand}.");
Assert(Rejected(mrColin, teenCandidate).Any(reason => reason.Contains("age hard mismatch", StringComparison.OrdinalIgnoreCase)), "Adult title/profession should reject child/teen candidates.");
Assert(maggie.HairColour == StoryIntelligenceIllustrationCompatibility.HairColours.Red, $"Maggie red-hair evidence should be explicit, got {maggie.HairColour}.");
Assert(Rejected(maggie, darkHairCandidate).Any(reason => reason.Contains("hair colour hard mismatch", StringComparison.OrdinalIgnoreCase)), "Red-hair evidence must invalidate dark-haired assignments.");
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Cock Hill Lane") == "Road", "Cock Hill Lane should classify as Road/Lane.");
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("derelict hospital waiting room") == "Hospital", "Derelict hospital should remain Hospital rather than generic interior.");
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("bench beside the road") == "Road", "Bench context beside road should not become indoor room artwork.");
Assert(!StoryIntelligenceIllustrationCompatibility.LocationCompatible("Road", "CarPark"), "Lane/Road must not use car park artwork.");
Assert(!StoryIntelligenceIllustrationCompatibility.LocationCompatible("WaitingRoom", "FlatInterior"), "Waiting room must not use flat interior artwork.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("blue VW Golf") == "Hatchback", "Blue VW Golf should classify as Hatchback.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetColour("blue VW Golf") == "Blue", "Blue VW Golf should retain explicit colour.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("TR6 sports car") == "SportsCar", "TR6 should remain a sports car.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("parcel") == "Parcel", "Parcel should not use letter/document imagery.");
Assert(!StoryIntelligenceIllustrationCompatibility.AssetCompatible("Hatchback", "SportsCar"), "Blue hatchback must not silently use red sports-car imagery.");
var serviceSource = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/StoryIntelligenceIllustrationMatchingService.cs"));
Assert(serviceSource.Contains("DemandGenerationThreshold = 1", StringComparison.Ordinal), "Live import demand should queue on first repeated missing archetype pass, not after dozens of scenes.");
Assert(serviceSource.Contains("ApplySupportingIllustrationDemandAsync", StringComparison.Ordinal), "Location and asset demand must run from the visualisation pipeline.");
Assert(serviceSource.Contains("MateriallyWrong(existingScore.Candidate.Metadata, observation.Evidence)", StringComparison.Ordinal), "Existing assignments must be revalidated against current evidence.");
Assert(serviceSource.Contains("BalancedSkinToneFallback", StringComparison.Ordinal), "Unresolved appearance should use deterministic project/book diversity rather than one global skin-tone default.");
var librarySource = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/IllustrationLibraryServices.cs"));
var applyApprovedSource = librarySource[librarySource.IndexOf("public async Task ApplyApprovedIllustrationsAsync", StringComparison.Ordinal)..];
Assert(!applyApprovedSource.Contains("StarterCodes.Contains", StringComparison.Ordinal), "Generated demand illustrations must not be filtered out by the runtime approved-image lookup.");
}
static void Phase21QViewKeepsPanelsBoundedAndRealControlsHidden()
{
var view = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Views/Development/StoryIntelligenceExperience.cshtml"));
var script = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/wwwroot/js/story-intelligence-experience-prototype.js"));
var css = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/wwwroot/css/story-intelligence-experience-prototype.css"));
Assert(!view.Contains("data-scene-title", StringComparison.Ordinal), "Redundant scene heading should be removed from the real visualisation.");
Assert(view.IndexOf("data-knowledge", StringComparison.Ordinal) < view.IndexOf("data-relationships", StringComparison.Ordinal), "Knowledge threads should be prioritised above relationship changes.");
Assert(view.Contains("Model.Mode == \"simulation\"", StringComparison.Ordinal), "Prototype controls must remain simulation-only.");
Assert(script.Contains("relationships.slice(0, 2)", StringComparison.Ordinal), "Relationship changes should be capped to the two most relevant visible cards.");
Assert(script.Contains("renderOverflowSummary", StringComparison.Ordinal), "Relationship overflow should be indicated compactly.");
Assert(css.Contains("max-height: calc(100vh - 126px)", StringComparison.Ordinal), "Side columns should be bounded to the viewport.");
Assert(css.Contains("overflow: auto", StringComparison.Ordinal), "Side-panel content should have internal scrolling.");
Assert(css.Contains("z-index: 180", StringComparison.Ordinal), "Character labels should render in a high label layer above nodes and paths.");
}
static JsonSerializerOptions JsonOptions() static JsonSerializerOptions JsonOptions()
=> new() => new()
{ {

View File

@ -925,7 +925,6 @@ public sealed class IllustrationLibraryService(
{ {
var items = await repository.ListAsync(new IllustrationLibraryFilter()); var items = await repository.ListAsync(new IllustrationLibraryFilter());
var lookup = items var lookup = items
.Where(item => IllustrationStarterBatchDefinition.StarterCodes.Contains(item.StableCode))
.Where(item => item.Status is IllustrationLibraryStatuses.Approved or IllustrationLibraryStatuses.Generated) .Where(item => item.Status is IllustrationLibraryStatuses.Approved or IllustrationLibraryStatuses.Generated)
.OrderBy(item => item.Status == IllustrationLibraryStatuses.Approved ? 0 : 1) .OrderBy(item => item.Status == IllustrationLibraryStatuses.Approved ? 0 : 1)
.ThenByDescending(item => item.UpdatedUtc) .ThenByDescending(item => item.UpdatedUtc)

View File

@ -253,14 +253,20 @@ public static class StoryIntelligenceIllustrationCompatibility
if (ContainsAny(text, "living room", "sitting room", "lounge")) return "LivingRoom"; if (ContainsAny(text, "living room", "sitting room", "lounge")) return "LivingRoom";
if (ContainsAny(text, "hall", "hallway", "corridor")) return "Hallway"; if (ContainsAny(text, "hall", "hallway", "corridor")) return "Hallway";
if (text.Contains("stair", StringComparison.OrdinalIgnoreCase)) return "Stairwell"; if (text.Contains("stair", StringComparison.OrdinalIgnoreCase)) return "Stairwell";
if (ContainsAny(text, "front door", "porch", "entrance", "house", "exterior")) return "HouseExterior"; if (ContainsAny(text, "hospital", "derelict hospital")) return "Hospital";
if (ContainsAny(text, "pub", "public house")) return "Pub";
if (ContainsAny(text, "beach", "seafront")) return "Beach";
if (ContainsAny(text, "railway station", "train station", "station platform")) return "RailwayStation";
if (ContainsAny(text, "canal", "towpath")) return "Canal";
if (ContainsAny(text, "garage")) return "Garage";
if (ContainsAny(text, "front door", "porch", "entrance", "house exterior", "outside the house", "exterior")) return "HouseExterior";
if (ContainsAny(text, "office", "desk", "study", "library")) return "Office"; if (ContainsAny(text, "office", "desk", "study", "library")) return "Office";
if (text.Contains("school", StringComparison.OrdinalIgnoreCase)) return "School"; if (text.Contains("school", StringComparison.OrdinalIgnoreCase)) return "School";
if (text.Contains("waiting room", StringComparison.OrdinalIgnoreCase)) return "WaitingRoom"; if (text.Contains("waiting room", StringComparison.OrdinalIgnoreCase)) return "WaitingRoom";
if (ContainsAny(text, "road", "street", "lane", "drive", "close", "avenue", "boulevard", "motorway", "bridge", "roundabout", "footpath", "track")) return "Road"; if (ContainsAny(text, "road", "street", "lane", "drive", "close", "avenue", "boulevard", "motorway", "bridge", "roundabout", "footpath", "path", "track")) return "Road";
if (ContainsAny(text, "car park", "parking")) return "CarPark"; if (ContainsAny(text, "car park", "parking")) return "CarPark";
if (text.Contains("shop", StringComparison.OrdinalIgnoreCase)) return "Shop"; if (text.Contains("shop", StringComparison.OrdinalIgnoreCase)) return "Shop";
if (text.Contains("hospital", StringComparison.OrdinalIgnoreCase)) return "Hospital"; if (ContainsAny(text, "bench", "bus stop")) return "StreetFurniture";
if (ContainsAny(text, "flat", "apartment", "room")) return "FlatInterior"; if (ContainsAny(text, "flat", "apartment", "room")) return "FlatInterior";
return "UnknownInterior"; return "UnknownInterior";
} }
@ -301,6 +307,20 @@ public static class StoryIntelligenceIllustrationCompatibility
return "UnknownObject"; return "UnknownObject";
} }
public static string AssetColour(string? value)
{
var text = (value ?? string.Empty).ToLowerInvariant();
if (ContainsAny(text, "blue", "navy")) return "Blue";
if (ContainsAny(text, "red", "scarlet", "crimson")) return "Red";
if (ContainsAny(text, "green")) return "Green";
if (ContainsAny(text, "yellow")) return "Yellow";
if (ContainsAny(text, "black")) return "Black";
if (ContainsAny(text, "white", "cream")) return "White";
if (ContainsAny(text, "grey", "gray", "silver")) return "Grey";
if (ContainsAny(text, "brown", "tan")) return "Brown";
return "Unknown";
}
public static bool LocationCompatible(string requestedType, string candidateType) public static bool LocationCompatible(string requestedType, string candidateType)
=> requestedType == candidateType => requestedType == candidateType
|| (requestedType is "FrontDoor" or "Porch" && candidateType is "HouseExterior") || (requestedType is "FrontDoor" or "Porch" && candidateType is "HouseExterior")
@ -309,6 +329,8 @@ public static class StoryIntelligenceIllustrationCompatibility
public static bool AssetCompatible(string requestedType, string candidateType) public static bool AssetCompatible(string requestedType, string candidateType)
=> requestedType == candidateType => requestedType == candidateType
|| (requestedType == "Hatchback" && candidateType == "Car")
|| (requestedType == "FamilyCar" && candidateType == "Car")
|| (requestedType == "Note" && candidateType is "Letter" or "Document") || (requestedType == "Note" && candidateType is "Letter" or "Document")
|| (requestedType == "Rucksack" && candidateType == "Rucksack"); || (requestedType == "Rucksack" && candidateType == "Rucksack");
@ -340,7 +362,7 @@ public static class StoryIntelligenceIllustrationCompatibility
if (ContainsAny(text, "teenager", "teenage")) return new(AgeBands.OlderTeen, 0.86m); if (ContainsAny(text, "teenager", "teenage")) return new(AgeBands.OlderTeen, 0.86m);
if (ContainsAny(text, "young adult", "young woman", "young man")) return new(AgeBands.YoungAdult, 0.82m); if (ContainsAny(text, "young adult", "young woman", "young man")) return new(AgeBands.YoungAdult, 0.82m);
if (ContainsAny(text, "middle-aged", "middle aged", "mature adult")) return new(AgeBands.MatureAdult, 0.86m); if (ContainsAny(text, "middle-aged", "middle aged", "mature adult")) return new(AgeBands.MatureAdult, 0.86m);
if (ContainsAny(text, " mr ", " mr. ", " mrs ", " mrs. ", " miss ", " miss. ", " ms ", " ms. ", " doctor", " dr ", " dr. ", " professor", " driving examiner", " teacher", " detective", " police officer", " nurse", " receptionist", " solicitor", " landlord", " manager")) return new(AgeBands.Adult, 0.88m); if (ContainsAny(text, " mr ", " mr. ", " mrs ", " mrs. ", " miss ", " miss. ", " ms ", " ms. ", " doctor", " dr ", " dr. ", " professor", " driving examiner", " teacher", " detective", " police officer", " nurse", " receptionist", " solicitor", " landlord", " manager", "social worker")) return new(AgeBands.Adult, 0.88m);
if (ContainsAny(text, "elderly", "senior", "old woman", "old man")) return new(AgeBands.Senior, 0.9m); if (ContainsAny(text, "elderly", "senior", "old woman", "old man")) return new(AgeBands.Senior, 0.9m);
return EvidenceSignal.Unknown; return EvidenceSignal.Unknown;
} }
@ -405,8 +427,8 @@ public static class StoryIntelligenceIllustrationCompatibility
private static EvidenceSignal NamePresentation(string text) private static EvidenceSignal NamePresentation(string text)
{ {
if (ContainsAny(text, " beth ", " maggie ", " rosie ", " grace ", " rebecca ", " annie ", " mary ", " susan ", " helen ", " elen ")) return new(Presentations.Feminine, 0.82m); if (ContainsAny(text, " beth ", " maggie ", " rosie ", " grace ", " zoe ", " rebecca ", " annie ", " mary ", " sharon ", " catherine ", " susan ", " helen ", " elen ")) return new(Presentations.Feminine, 0.82m);
if (ContainsAny(text, " adam ", " colin ", " david ", " kevin ", " simon ", " graham ", " gareth ", " john ", " michael ", " peter ", " george ")) return new(Presentations.Masculine, 0.82m); if (ContainsAny(text, " adam ", " colin ", " david ", " kevin ", " simon ", " graham ", " gareth ", " john ", " michael ", " peter ", " richard ", " rick ", " george ")) return new(Presentations.Masculine, 0.82m);
return EvidenceSignal.Unknown; return EvidenceSignal.Unknown;
} }
@ -442,13 +464,13 @@ public static class StoryIntelligenceIllustrationCompatibility
} }
if (StartsWithAny(clean, "mr", "father", "dad", "uncle", "brother", "son", "boy", "man", "male") if (StartsWithAny(clean, "mr", "father", "dad", "uncle", "brother", "son", "boy", "man", "male")
|| clean is "adam" or "colin" or "david" or "kevin" or "simon" or "graham" or "gareth" or "john" or "michael" or "peter" or "george") || clean is "adam" or "colin" or "david" or "kevin" or "simon" or "graham" or "gareth" or "john" or "michael" or "peter" or "george" or "richard" or "rick")
{ {
return new(Presentations.Masculine, 0.98m); return new(Presentations.Masculine, 0.98m);
} }
if (StartsWithAny(clean, "mrs", "miss", "ms", "mother", "mum", "mom", "aunt", "sister", "daughter", "girl", "woman", "female") if (StartsWithAny(clean, "mrs", "miss", "ms", "mother", "mum", "mom", "aunt", "sister", "daughter", "girl", "woman", "female")
|| clean is "beth" or "maggie" or "grace" or "rosie" or "rebecca" or "annie" or "mary" or "susan" or "helen" or "elen") || clean is "beth" or "maggie" or "grace" or "rosie" or "rebecca" or "annie" or "mary" or "susan" or "helen" or "elen" or "zoe" or "sharon" or "catherine")
{ {
return new(Presentations.Feminine, 0.98m); return new(Presentations.Feminine, 0.98m);
} }

View File

@ -11,6 +11,7 @@ namespace PlotLine.Services;
public interface IStoryIntelligenceIllustrationMatchingService public interface IStoryIntelligenceIllustrationMatchingService
{ {
Task ApplyCharacterIllustrationsAsync(int projectId, StoryIntelligenceExperiencePrototypeViewModel prototype); Task ApplyCharacterIllustrationsAsync(int projectId, StoryIntelligenceExperiencePrototypeViewModel prototype);
Task ApplySupportingIllustrationDemandAsync(int projectId, StoryIntelligenceExperiencePrototypeViewModel prototype);
} }
public sealed class StoryIntelligenceIllustrationMatchingService( public sealed class StoryIntelligenceIllustrationMatchingService(
@ -22,7 +23,7 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
{ {
private const int SuitableScore = 48; private const int SuitableScore = 48;
private const int ReassignmentMargin = 24; private const int ReassignmentMargin = 24;
private const int DemandGenerationThreshold = 3; private const int DemandGenerationThreshold = 1;
private static readonly JsonSerializerOptions JsonOptions = new() private static readonly JsonSerializerOptions JsonOptions = new()
{ {
PropertyNamingPolicy = JsonNamingPolicy.CamelCase, PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
@ -152,6 +153,225 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
} }
} }
public async Task ApplySupportingIllustrationDemandAsync(int projectId, StoryIntelligenceExperiencePrototypeViewModel prototype)
{
if (projectId <= 0 || prototype.Scenes.Count == 0)
{
return;
}
var items = await library.ListAsync(new IllustrationLibraryFilter());
var lookup = items
.Where(item => item.Status is IllustrationLibraryStatuses.Approved or IllustrationLibraryStatuses.Generated)
.GroupBy(item => item.StableCode, StringComparer.OrdinalIgnoreCase)
.ToDictionary(group => group.Key, group => group.ToList(), StringComparer.OrdinalIgnoreCase);
foreach (var location in prototype.Scenes.Select(scene => scene.Location))
{
await ApplyLocationDemandAsync(projectId, location, lookup);
}
foreach (var asset in prototype.Scenes.SelectMany(scene => scene.Assets))
{
await ApplyAssetDemandAsync(projectId, asset, lookup);
}
}
private async Task ApplyLocationDemandAsync(
int projectId,
StoryIntelligenceExperienceLocationState location,
IReadOnlyDictionary<string, List<IllustrationLibraryItem>> lookup)
{
var type = StoryIntelligenceIllustrationCompatibility.LocationType($"{location.Name} {location.Label}");
if (type is "UnknownInterior")
{
location.ImageResolution.ReasonChosen ??= "Location type is unresolved; neutral location fallback retained and no finished unrelated artwork assigned.";
location.ImageResolution.DemandStatus ??= "Not queued until location type resolves";
return;
}
if (!location.ImageResolution.FallbackUsed && !string.IsNullOrWhiteSpace(location.ImageResolution.FinalImageUrl))
{
return;
}
var demandKey = $"location|{type}".ToLowerInvariant();
var code = $"loc-demand-{StableHash(demandKey)}";
location.LibraryCode = code;
if (TryApplyGeneratedSupportingImage(location.Id, code, location.ImagePath, lookup, out var resolution))
{
location.ImageResolution = WithDemandDiagnostics(
resolution,
demandKey,
"Satisfied by generated demand illustration",
$"Applied generated {type} location illustration from demand key {demandKey}.");
location.ImagePath = resolution.FinalImageUrl ?? location.ImagePath;
return;
}
var demand = await assignments.UpsertDemandAsync(new StoryIntelligenceIllustrationDemandRequest(
projectId,
demandKey,
"Unknown",
"Unknown",
"Unknown",
"Unknown",
"Unknown",
"Unknown",
"Unknown"));
var queued = await QueueDemandSpecAsync(LocationDemandSpecification(code, demandKey, type));
location.ImageResolution = WithDemandDiagnostics(
location.ImageResolution,
demandKey,
queued
? $"Queued missing {type} location illustration ({demand.RequestCount:N0}/{DemandGenerationThreshold:N0})"
: $"Demand recorded for missing {type} location illustration ({demand.RequestCount:N0}/{DemandGenerationThreshold:N0})",
$"No generated compatible {type} location illustration exists; neutral typed fallback retained while reusable demand is generated.",
code,
queued ? "DemandQueued" : "DemandRecorded");
}
private async Task ApplyAssetDemandAsync(
int projectId,
StoryIntelligenceExperienceAssetState asset,
IReadOnlyDictionary<string, List<IllustrationLibraryItem>> lookup)
{
var evidenceText = $"{asset.Name} {asset.Label}";
var type = StoryIntelligenceIllustrationCompatibility.AssetType(evidenceText);
if (type is "UnknownObject")
{
asset.ImageResolution.ReasonChosen ??= "Asset type is unresolved; neutral asset fallback retained and no unrelated finished artwork assigned.";
asset.ImageResolution.DemandStatus ??= "Not queued until asset type resolves";
return;
}
if (!asset.ImageResolution.FallbackUsed && !string.IsNullOrWhiteSpace(asset.ImageResolution.FinalImageUrl))
{
return;
}
var colour = StoryIntelligenceIllustrationCompatibility.AssetColour(evidenceText);
var demandKey = $"asset|{type}|{colour}".ToLowerInvariant();
var code = $"asset-demand-{StableHash(demandKey)}";
asset.LibraryCode = code;
if (TryApplyGeneratedSupportingImage(asset.Id, code, asset.ImagePath, lookup, out var resolution))
{
asset.ImageResolution = WithDemandDiagnostics(
resolution,
demandKey,
"Satisfied by generated demand illustration",
$"Applied generated {type} asset illustration from demand key {demandKey}.");
asset.ImagePath = resolution.FinalImageUrl ?? asset.ImagePath;
return;
}
var demand = await assignments.UpsertDemandAsync(new StoryIntelligenceIllustrationDemandRequest(
projectId,
demandKey,
"Unknown",
"Unknown",
"Unknown",
"Unknown",
"Unknown",
"Unknown",
"Unknown"));
var queued = await QueueDemandSpecAsync(AssetDemandSpecification(code, demandKey, type, colour));
asset.ImageResolution = WithDemandDiagnostics(
asset.ImageResolution,
demandKey,
queued
? $"Queued missing {colour.ToLowerInvariant()} {type} asset illustration ({demand.RequestCount:N0}/{DemandGenerationThreshold:N0})"
: $"Demand recorded for missing {colour.ToLowerInvariant()} {type} asset illustration ({demand.RequestCount:N0}/{DemandGenerationThreshold:N0})",
$"No generated compatible {type} asset illustration exists; neutral typed fallback retained while reusable demand is generated.",
code,
queued ? "DemandQueued" : "DemandRecorded");
}
private bool TryApplyGeneratedSupportingImage(
string entityId,
string code,
string fallbackImageUrl,
IReadOnlyDictionary<string, List<IllustrationLibraryItem>> lookup,
out StoryIntelligenceExperienceImageResolution resolution)
{
if (lookup.TryGetValue(code, out var candidates))
{
foreach (var candidate in candidates)
{
var publicUrl = ResolveExistingPublicUploadUrl(candidate.ThumbnailPath) ?? ResolveExistingPublicUploadUrl(candidate.FilePath);
if (publicUrl is null)
{
continue;
}
resolution = new StoryIntelligenceExperienceImageResolution
{
EntityId = entityId,
StableCode = code,
IllustrationLibraryItemId = candidate.IllustrationLibraryItemID,
Status = candidate.Status,
FinalImageUrl = publicUrl,
FallbackImageUrl = fallbackImageUrl,
FallbackUsed = false
};
return true;
}
}
resolution = new StoryIntelligenceExperienceImageResolution();
return false;
}
private static StoryIntelligenceExperienceImageResolution WithDemandDiagnostics(
StoryIntelligenceExperienceImageResolution source,
string demandKey,
string demandStatus,
string reasonChosen,
string? stableCode = null,
string? status = null)
=> new()
{
EntityId = source.EntityId,
StableCode = stableCode ?? source.StableCode,
IllustrationLibraryItemId = source.IllustrationLibraryItemId,
Status = status ?? source.Status,
FinalImageUrl = source.FinalImageUrl,
FallbackImageUrl = source.FallbackImageUrl,
FallbackUsed = source.FallbackUsed,
AssignmentConfidence = source.AssignmentConfidence,
MatchingScore = source.MatchingScore,
RejectedCandidates = source.RejectedCandidates,
ReasonChosen = reasonChosen,
ReasonReassigned = source.ReasonReassigned,
PreviousIllustration = source.PreviousIllustration,
PreviousEvidence = source.PreviousEvidence,
CurrentEvidence = source.CurrentEvidence,
DemandKey = demandKey,
EvidenceWarnings = source.EvidenceWarnings,
DemandStatus = demandStatus,
ResolvedPresentation = source.ResolvedPresentation,
ResolvedAgeBand = source.ResolvedAgeBand,
RelationshipDerivedConfidence = source.RelationshipDerivedConfidence,
NameDerivedConfidence = source.NameDerivedConfidence,
PronounDerivedConfidence = source.PronounDerivedConfidence,
IllustrationReuseCount = source.IllustrationReuseCount,
IllustrationAllocationStatus = source.IllustrationAllocationStatus
};
private async Task<bool> QueueDemandSpecAsync(IllustrationGenerationSpecification specification)
{
var prompt = promptBuilder.Build(specification);
var item = await library.UpsertPlannedAsync(specification, prompt);
if (item.Status is IllustrationLibraryStatuses.Queued or IllustrationLibraryStatuses.Generating or IllustrationLibraryStatuses.Generated or IllustrationLibraryStatuses.Approved)
{
return false;
}
return await library.QueueAsync(item.IllustrationLibraryItemID, null) > 0;
}
private MatchScore? ChooseAssignment( private MatchScore? ChooseAssignment(
CharacterObservation observation, CharacterObservation observation,
StoryIntelligenceCharacterIllustrationAssignment? existing, StoryIntelligenceCharacterIllustrationAssignment? existing,
@ -171,7 +391,7 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
} }
var materiallyWrong = MateriallyWrong(existingScore.Candidate.Metadata, observation.Evidence); var materiallyWrong = MateriallyWrong(existingScore.Candidate.Metadata, observation.Evidence);
if (materiallyWrong && observation.Evidence.HasStrongPresentationEvidence) if (materiallyWrong)
{ {
return best; return best;
} }
@ -291,7 +511,7 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
var presentation = NormaliseUnknown(demand.Presentation, "Androgynous"); var presentation = NormaliseUnknown(demand.Presentation, "Androgynous");
var hairColour = NormaliseUnknown(demand.HairColour, "Brown"); var hairColour = NormaliseUnknown(demand.HairColour, "Brown");
var hairLength = NormaliseUnknown(demand.HairLength, "Medium"); var hairLength = NormaliseUnknown(demand.HairLength, "Medium");
var skinTone = NormaliseUnknown(demand.SkinTone, "Medium"); var skinTone = NormaliseUnknown(demand.SkinTone, BalancedSkinToneFallback(demand.DemandKey));
var features = new List<string> { "DemandGenerated" }; var features = new List<string> { "DemandGenerated" };
if (string.Equals(demand.Glasses, "Yes", StringComparison.OrdinalIgnoreCase)) if (string.Equals(demand.Glasses, "Yes", StringComparison.OrdinalIgnoreCase))
{ {
@ -335,6 +555,77 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
}; };
} }
private static IllustrationGenerationSpecification LocationDemandSpecification(string code, string demandKey, string type)
=> new()
{
Category = IllustrationLibraryCategories.Location,
Code = code,
Title = $"{ReadableToken(type)} location",
Description = "Demand-created reusable location illustration for Story Intelligence matching.",
VisualSubject = $"A clear reusable {ReadableToken(type).ToLowerInvariant()} environment suitable for manuscript visualisation.",
Composition = "single readable environment, no prominent people, strong central focal point, suitable for circular and wide crops",
Mood = "story-ready, atmospheric but clear, visually specific rather than generic",
LocationType = type,
Era = "Contemporary or late twentieth century",
Setting = type is "Road" ? "Exterior" : type is "CarPark" or "HouseExterior" ? "Exterior" : "Interior",
Palette = ["clear blue", "warm practical light", "balanced neutral accents"],
MetadataTags = ["location", "demand-generated", "story-intelligence"],
Metadata = new(StringComparer.OrdinalIgnoreCase)
{
["libraryPurpose"] = "Story Intelligence demand-generated location",
["privacy"] = "generic-no-manuscript-data",
["style"] = "editorial-stylised-realism",
["generationSource"] = "Demand generation",
["demandKey"] = demandKey,
["locationType"] = type
}
};
private static IllustrationGenerationSpecification AssetDemandSpecification(string code, string demandKey, string type, string colour)
=> new()
{
Category = IllustrationLibraryCategories.Asset,
Code = code,
Title = $"{ReadableToken(colour)} {ReadableToken(type)}",
Description = "Demand-created reusable asset illustration for Story Intelligence matching.",
VisualSubject = colour == "Unknown"
? $"A clear reusable {ReadableToken(type).ToLowerInvariant()} object."
: $"A clear reusable {colour.ToLowerInvariant()} {ReadableToken(type).ToLowerInvariant()} object.",
Composition = "single dominant centred object, crisp silhouette, full object visible where practical, no text or logos",
Mood = "clean, readable, evidence-like, story-ready",
ObjectType = type,
Colour = colour,
Era = "Contemporary or late twentieth century",
Palette = colour == "Unknown"
? ["clear blue", "warm light", "balanced neutral"]
: [colour.ToLowerInvariant(), "clear blue", "warm light"],
MetadataTags = ["asset", "demand-generated", "story-intelligence"],
Metadata = new(StringComparer.OrdinalIgnoreCase)
{
["libraryPurpose"] = "Story Intelligence demand-generated asset",
["privacy"] = "generic-no-manuscript-data",
["style"] = "editorial-stylised-realism",
["generationSource"] = "Demand generation",
["demandKey"] = demandKey,
["objectType"] = type,
["colour"] = colour
}
};
private static string ReadableToken(string value)
=> Regex.Replace(value, "([a-z])([A-Z])", "$1 $2");
private static string BalancedSkinToneFallback(string demandKey)
{
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(demandKey));
return (hash[0] % 3) switch
{
0 => "Light",
1 => "Medium",
_ => "Dark"
};
}
private static Dictionary<string, CharacterObservation> BuildCharacterObservations(StoryIntelligenceExperiencePrototypeViewModel prototype) private static Dictionary<string, CharacterObservation> BuildCharacterObservations(StoryIntelligenceExperiencePrototypeViewModel prototype)
{ {
var observations = new Dictionary<string, CharacterObservation>(StringComparer.OrdinalIgnoreCase); var observations = new Dictionary<string, CharacterObservation>(StringComparer.OrdinalIgnoreCase);

View File

@ -147,6 +147,7 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
if (run.ProjectID.HasValue) if (run.ProjectID.HasValue)
{ {
await illustrationMatcher.ApplyCharacterIllustrationsAsync(run.ProjectID.Value, model); await illustrationMatcher.ApplyCharacterIllustrationsAsync(run.ProjectID.Value, model);
await illustrationMatcher.ApplySupportingIllustrationDemandAsync(run.ProjectID.Value, model);
ApplyIllustrationDiagnostics(model); ApplyIllustrationDiagnostics(model);
} }
StripLiveDiagnostics(model); StripLiveDiagnostics(model);
@ -283,6 +284,7 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
StabiliseCharacterIllustrations(model); StabiliseCharacterIllustrations(model);
await illustrationLibrary.ApplyApprovedIllustrationsAsync(model); await illustrationLibrary.ApplyApprovedIllustrationsAsync(model);
await illustrationMatcher.ApplyCharacterIllustrationsAsync(session.ProjectID, model); await illustrationMatcher.ApplyCharacterIllustrationsAsync(session.ProjectID, model);
await illustrationMatcher.ApplySupportingIllustrationDemandAsync(session.ProjectID, model);
ApplyIllustrationDiagnostics(model); ApplyIllustrationDiagnostics(model);
StripLiveDiagnostics(model); StripLiveDiagnostics(model);
LogSnapshotSynchronisation(importSessionId, currentSnapshot, current, scenes, model.ChangeToken); LogSnapshotSynchronisation(importSessionId, currentSnapshot, current, scenes, model.ChangeToken);
@ -433,7 +435,7 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
Id = CharacterId(group.Key), Id = CharacterId(group.Key),
LibraryCode = libraryCode, LibraryCode = libraryCode,
Name = group.Key, Name = group.Key,
Role = isPov ? "POV character" : FirstConfigured(character.RoleInScene, character.MentionedOnly == true ? "Referenced" : "Scene character"), Role = isPov ? "POV character" : NonPovRole(character),
Relevance = Trim(FirstConfigured(character.Notes, string.Join(", ", character.Actions ?? []), character.MentionedOnly == true ? "Mentioned only" : "Present"), 60), Relevance = Trim(FirstConfigured(character.Notes, string.Join(", ", character.Actions ?? []), character.MentionedOnly == true ? "Mentioned only" : "Present"), 60),
Weight = isPov ? 100 : CharacterWeight(character), Weight = isPov ? 100 : CharacterWeight(character),
ImagePath = CharacterFallback(group.Key) ImagePath = CharacterFallback(group.Key)
@ -484,6 +486,16 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
.ToList(); .ToList();
} }
private static string NonPovRole(SceneIntelligenceCharacter character)
{
var role = FirstConfigured(character.RoleInScene, character.MentionedOnly == true ? "Referenced" : "Scene character");
return role.Contains("pov", StringComparison.OrdinalIgnoreCase)
|| role.Contains("point of view", StringComparison.OrdinalIgnoreCase)
|| role.Contains("narrator", StringComparison.OrdinalIgnoreCase)
? character.MentionedOnly == true ? "Referenced" : "Scene character"
: role;
}
private static string? ResolveNarratorName(SceneIntelligenceScene scene) private static string? ResolveNarratorName(SceneIntelligenceScene scene)
{ {
var povName = Clean(scene.PointOfView?.CharacterName); var povName = Clean(scene.PointOfView?.CharacterName);

View File

@ -150,8 +150,7 @@
<section class="story-exp-stage" aria-labelledby="living-story-title"> <section class="story-exp-stage" aria-labelledby="living-story-title">
<div class="story-exp-stage-copy"> <div class="story-exp-stage-copy">
<p class="story-exp-eyebrow">Living story view</p> <p class="story-exp-eyebrow">Living story view</p>
<h1 id="living-story-title" data-scene-title>Reading the manuscript</h1> <p id="living-story-title" class="story-exp-scene-summary" data-scene-summary>Reading the manuscript</p>
<p data-scene-summary></p>
</div> </div>
<div class="story-exp-visual" data-visual-stage> <div class="story-exp-visual" data-visual-stage>
@ -177,10 +176,6 @@
</section> </section>
<aside class="story-exp-right" aria-label="Story understanding"> <aside class="story-exp-right" aria-label="Story understanding">
<section class="story-exp-panel" aria-labelledby="relationships-title">
<h2 id="relationships-title">Relationship changes</h2>
<div class="story-exp-insight-list" data-relationships></div>
</section>
<section class="story-exp-panel" aria-labelledby="knowledge-title"> <section class="story-exp-panel" aria-labelledby="knowledge-title">
<h2 id="knowledge-title">Knowledge threads</h2> <h2 id="knowledge-title">Knowledge threads</h2>
<div class="story-exp-insight-list" data-knowledge></div> <div class="story-exp-insight-list" data-knowledge></div>
@ -189,6 +184,10 @@
<h2 id="observations-title">Story observations</h2> <h2 id="observations-title">Story observations</h2>
<div class="story-exp-insight-list" data-observations></div> <div class="story-exp-insight-list" data-observations></div>
</section> </section>
<section class="story-exp-panel story-exp-panel--compact" aria-labelledby="relationships-title">
<h2 id="relationships-title">Relationship changes</h2>
<div class="story-exp-insight-list" data-relationships></div>
</section>
</aside> </aside>
</section> </section>

View File

@ -299,7 +299,17 @@ input:focus-visible {
display: grid; display: grid;
align-content: start; align-content: start;
gap: 14px; gap: 14px;
max-height: calc(100vh - 126px);
min-height: 0; min-height: 0;
overflow: hidden;
}
.story-exp-left {
grid-template-rows: auto auto minmax(150px, 1fr) minmax(120px, 0.72fr);
}
.story-exp-right {
grid-template-rows: minmax(190px, 1.2fr) minmax(150px, 0.95fr) auto;
} }
.story-exp-left, .story-exp-left,
@ -324,6 +334,17 @@ input:focus-visible {
padding: 16px; padding: 16px;
} }
.story-exp-panel {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
min-height: 0;
overflow: hidden;
}
.story-exp-panel--compact {
padding: 12px;
}
.story-exp-progress-ring { .story-exp-progress-ring {
position: relative; position: relative;
display: grid; display: grid;
@ -445,6 +466,9 @@ input:focus-visible {
margin: 0; margin: 0;
padding: 0; padding: 0;
list-style: none; list-style: none;
min-height: 0;
overflow: auto;
scrollbar-width: thin;
} }
.story-exp-list li, .story-exp-list li,
@ -507,9 +531,8 @@ input:focus-visible {
.story-exp-stage-copy { .story-exp-stage-copy {
display: grid; display: grid;
grid-template-columns: minmax(260px, 0.64fr) 1fr; grid-template-columns: minmax(0, 1fr);
align-items: center; align-items: center;
gap: 18px;
min-height: 42px; min-height: 42px;
} }
@ -524,12 +547,18 @@ input:focus-visible {
white-space: nowrap; white-space: nowrap;
} }
.story-exp-scene-summary,
.story-exp-stage-copy p:last-child { .story-exp-stage-copy p:last-child {
grid-column: 2; grid-column: 1;
margin: 0; margin: 0;
color: var(--story-soft); color: var(--story-soft);
font-size: clamp(0.78rem, 0.86vw, 0.92rem); font-size: clamp(0.78rem, 0.86vw, 0.92rem);
line-height: 1.28; line-height: 1.28;
max-width: 100%;
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
} }
.story-exp-visual { .story-exp-visual {
@ -672,6 +701,7 @@ input:focus-visible {
.story-exp-characters { .story-exp-characters {
position: relative; position: relative;
z-index: 90;
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
@ -683,7 +713,7 @@ input:focus-visible {
height: var(--node-size, 150px); height: var(--node-size, 150px);
transform: translate(var(--x, 0), var(--y, 0)) scale(var(--scale, 1)); transform: translate(var(--x, 0), var(--y, 0)) scale(var(--scale, 1));
opacity: var(--opacity, 1); opacity: var(--opacity, 1);
z-index: calc(var(--z, 3) + 30); z-index: calc(var(--z, 3) + 90);
pointer-events: auto; pointer-events: auto;
transition: transform 1050ms cubic-bezier(.16, .84, .28, 1), opacity 780ms ease; transition: transform 1050ms cubic-bezier(.16, .84, .28, 1), opacity 780ms ease;
} }
@ -729,7 +759,7 @@ input:focus-visible {
.story-character__text { .story-character__text {
position: absolute; position: absolute;
z-index: 80; z-index: 180;
top: calc(var(--node-size, 150px) + 9px); top: calc(var(--node-size, 150px) + 9px);
left: 50%; left: 50%;
display: grid; display: grid;
@ -741,8 +771,8 @@ input:focus-visible {
border: 1px solid rgba(151, 185, 218, 0.1); border: 1px solid rgba(151, 185, 218, 0.1);
border-radius: 10px; border-radius: 10px;
padding: 5px 7px 6px; padding: 5px 7px 6px;
background: rgba(4, 10, 20, 0.58); background: rgba(4, 10, 20, 0.82);
box-shadow: 0 12px 26px rgba(0, 0, 0, 0.24); box-shadow: 0 12px 26px rgba(0, 0, 0, 0.28), 0 0 0 1px rgba(4, 10, 20, 0.22);
backdrop-filter: blur(8px); backdrop-filter: blur(8px);
text-align: center; text-align: center;
transform: translateX(-50%); transform: translateX(-50%);
@ -1088,6 +1118,9 @@ input:focus-visible {
.story-exp-insight-list { .story-exp-insight-list {
display: grid; display: grid;
gap: 9px; gap: 9px;
min-height: 0;
overflow: auto;
scrollbar-width: thin;
} }
.story-exp-insight { .story-exp-insight {
@ -1100,6 +1133,31 @@ input:focus-visible {
transition: opacity 560ms ease, transform 700ms cubic-bezier(.16, .84, .28, 1); transition: opacity 560ms ease, transform 700ms cubic-bezier(.16, .84, .28, 1);
} }
.story-exp-panel--compact .story-exp-insight {
padding: 8px 9px;
}
.story-exp-panel--compact .story-exp-insight p {
display: -webkit-box;
overflow: hidden;
margin: 0;
font-size: 0.76rem;
line-height: 1.25;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.story-exp-more {
border: 1px solid rgba(151, 185, 218, 0.12);
border-radius: 10px;
padding: 7px 9px;
background: rgba(255, 255, 255, 0.035);
color: var(--story-soft);
font-size: 0.78rem;
font-weight: 800;
text-align: left;
}
.story-exp-list li, .story-exp-list li,
.story-exp-feed li { .story-exp-feed li {
transition: opacity 520ms ease, transform 640ms cubic-bezier(.16, .84, .28, 1); transition: opacity 520ms ease, transform 640ms cubic-bezier(.16, .84, .28, 1);

View File

@ -161,7 +161,6 @@
} }
function updateSceneCopy(scene) { function updateSceneCopy(scene) {
text(dom.sceneTitle, `Scene ${scene.sceneNumber}${scene.title}`);
text(dom.sceneSummary, scene.summary); text(dom.sceneSummary, scene.summary);
} }
@ -346,12 +345,14 @@
} }
function reconcileRelationships(scene) { function reconcileRelationships(scene) {
renderInsights(dom.relationships, scene.relationships.map((relationship) => ({ const relationships = scene.relationships.map((relationship) => ({
id: relationship.id, id: relationship.id,
title: relationship.label, title: relationship.label,
detail: relationship.state, detail: relationship.state,
tone: relationship.weight >= 90 ? "confirmed" : relationship.weight >= 70 ? "strengthening" : "emerging" tone: relationship.weight >= 90 ? "confirmed" : relationship.weight >= 70 ? "strengthening" : "emerging"
}))); }));
renderInsights(dom.relationships, relationships.slice(0, 2));
renderOverflowSummary(dom.relationships, relationships.length - 2, "relationship");
} }
function updateKnowledgeThreads(scene) { function updateKnowledgeThreads(scene) {
@ -458,6 +459,26 @@
}); });
} }
function renderOverflowSummary(container, hiddenCount, noun) {
if (!container) return;
let node = container.querySelector("[data-insight-overflow]");
if (hiddenCount <= 0) {
node?.remove();
return;
}
if (!node) {
node = document.createElement("button");
node.type = "button";
node.className = "story-exp-more";
node.dataset.insightOverflow = "true";
container.append(node);
}
node.textContent = `+${hiddenCount} more ${noun}${hiddenCount === 1 ? "" : "s"}`;
node.title = `${hiddenCount} additional ${noun}${hiddenCount === 1 ? "" : "s"} in this scene`;
}
function renderList(container, items) { function renderList(container, items) {
const activeIds = new Set(items.map((item, index) => feedId(item, index))); const activeIds = new Set(items.map((item, index) => feedId(item, index)));
items.forEach((item, index) => { items.forEach((item, index) => {