diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs index 134b890..c3f9566 100644 --- a/PlotLine.Tests/Program.cs +++ b/PlotLine.Tests/Program.cs @@ -59,6 +59,7 @@ var tests = new (string Name, Action Test)[] ("Phase 21R replay uses persisted data only", Phase21RReplayUsesPersistedDataOnly), ("Phase 21R scene browser and panels are replay friendly", Phase21RSceneBrowserAndPanelsAreReplayFriendly), ("Phase 21S uses durable Story Memory assignments", Phase21SUsesDurableStoryMemoryAssignments), + ("Story Intelligence visual polish keeps labels fixed and semantics specific", StoryIntelligenceVisualPolishKeepsLabelsFixedAndSemanticsSpecific), ("Scan review post supports full-book form submissions", ScanReviewPostSupportsFullBookFormSubmissions), ("Story Intelligence experience boot does not serialise live model", StoryIntelligenceExperienceBootDoesNotSerialiseLiveModel), ("Live visualisation strips illustration diagnostics", LiveVisualisationStripsIllustrationDiagnostics), @@ -559,7 +560,7 @@ static void StoryIntelligenceEvidenceExtractionPreventsObservedAttributeLeakage( Assert(StoryIntelligenceIllustrationCompatibility.AssetType("money") == "Money", "Money must not classify as Car."); Assert(StoryIntelligenceIllustrationCompatibility.AssetType("bottle") == "Bottle", "Bottle must not classify as LockedBox."); Assert(StoryIntelligenceIllustrationCompatibility.AssetType("scarves") == "Clothing", "Scarves must not classify as Car."); - Assert(StoryIntelligenceIllustrationCompatibility.AssetType("driving licence") == "Document", "Driving licence should classify as Document."); + Assert(StoryIntelligenceIllustrationCompatibility.AssetType("driving licence") == "DrivingLicence", "Driving licence should classify as DrivingLicence."); Assert(StoryIntelligenceIllustrationCompatibility.AssetType("ring") == "Jewellery", "Ring should classify as Jewellery."); Assert(StoryIntelligenceIllustrationCompatibility.AssetType("gun") == "Weapon", "Gun should classify as Weapon."); } @@ -609,8 +610,8 @@ static void IllustrationAssignmentRepairEnforcesExplicitEvidenceGroupsAndStrictS Assert(StoryIntelligenceIllustrationCompatibility.AssetType("supercharger") == "CarPart", "Supercharger should not classify as full car."); Assert(StoryIntelligenceIllustrationCompatibility.AssetType("biscuit tin") == "Tin", "Biscuit tin should not classify as Photograph."); Assert(StoryIntelligenceIllustrationCompatibility.AssetType("parcel") == "Parcel", "Parcel should not classify as Letter."); - Assert(StoryIntelligenceIllustrationCompatibility.AssetType("pass certificate") == "Document", "Pass certificate should classify as Document."); - Assert(StoryIntelligenceIllustrationCompatibility.AssetType("driving licence") == "Document", "Driving licence should classify as Document."); + Assert(StoryIntelligenceIllustrationCompatibility.AssetType("pass certificate") == "Certificate", "Pass certificate should classify as Certificate."); + Assert(StoryIntelligenceIllustrationCompatibility.AssetType("driving licence") == "DrivingLicence", "Driving licence should classify as DrivingLicence."); Assert(StoryIntelligenceIllustrationCompatibility.AssetType("thin cotton blouse") == "Clothing", "Blouse should classify as Clothing."); Assert(StoryIntelligenceIllustrationCompatibility.AssetType("two pairs of sunglasses") == "Clothing", "Sunglasses should classify as Clothing."); @@ -990,6 +991,40 @@ static void Phase21RSceneBrowserAndPanelsAreReplayFriendly() Assert(progressViewModel.Contains("public int? ImportSessionID", StringComparison.Ordinal), "Import progress page should carry the import session for replay links."); } +static void StoryIntelligenceVisualPolishKeepsLabelsFixedAndSemanticsSpecific() +{ + 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")); + var snapshot = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs")); + + Assert(!script.Contains("node.dataset.labelSide", StringComparison.Ordinal), "Character labels must not use dynamic side positioning."); + Assert(!css.Contains(".story-character[data-label-side=\"left\"]", StringComparison.Ordinal), "Left-floating character labels must be removed."); + Assert(!css.Contains(".story-character[data-layout-role=\"partner\"] .story-character__text", StringComparison.Ordinal), "Partner labels must not float beside portraits."); + Assert(css.Contains("top: calc(var(--node-size, 150px) + 8px)", StringComparison.Ordinal), "Character captions should be fixed below portraits."); + Assert(css.Contains("background: rgba(4, 10, 20, 0.91)", StringComparison.Ordinal), "Character caption opacity should be increased for readability."); + Assert(css.Contains(".story-exp-insight span {\n display: none;", StringComparison.Ordinal), "Redundant insight tone headings should be hidden."); + + Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Cock Hill Lane") == "Road", "Cock Hill Lane should classify as Road."); + Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Mount Pleasant Road") == "Road", "Mount Pleasant Road should classify as Road."); + Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Queens Court") == "Road", "Court should classify as Road."); + Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Station Gardens") == "Road", "Gardens should classify as Road."); + Assert(StoryIntelligenceIllustrationCompatibility.LocationType("driver seat inside the car") == "VehicleInterior", "Driver seat should classify as VehicleInterior."); + Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Driving Test Centre waiting room") == "DrivingTestCentre", "Driving test centre should have its own location family."); + Assert(!StoryIntelligenceIllustrationCompatibility.LocationCompatible("VehicleInterior", "HouseExterior"), "Vehicle interiors must not resolve to houses or buildings."); + + Assert(StoryIntelligenceIllustrationCompatibility.AssetType("passport") == "Passport", "Passport should not be Generic Object."); + Assert(StoryIntelligenceIllustrationCompatibility.AssetType("sealed envelope") == "Envelope", "Envelope should not be Generic Object."); + Assert(StoryIntelligenceIllustrationCompatibility.AssetType("Highway Code book") == "DrivingManual", "Highway Code should classify as a driving manual."); + Assert(StoryIntelligenceIllustrationCompatibility.AssetType("traffic sign") == "TrafficSign", "Traffic sign should classify as TrafficSign."); + Assert(StoryIntelligenceIllustrationCompatibility.AssetType("wall clock") == "Clock", "Clock should classify as Clock."); + Assert(StoryIntelligenceIllustrationCompatibility.AssetType("park bench") == "Bench", "Bench should classify as Bench."); + + Assert(snapshot.Contains("\"loc-generic-road\" => $\"{FallbackRoot}/location-road.svg\"", StringComparison.Ordinal), "Roads need a generic road fallback illustration."); + Assert(snapshot.Contains("\"loc-vehicle-interior\" => $\"{FallbackRoot}/location-vehicle-interior.svg\"", StringComparison.Ordinal), "Vehicle interiors need a non-building fallback illustration."); + Assert(snapshot.Contains("\"asset-document\" => $\"{FallbackRoot}/asset-document.svg\"", StringComparison.Ordinal), "Documents need a recognisable document fallback."); + Assert(snapshot.Contains("\"asset-traffic-sign\" => $\"{FallbackRoot}/asset-traffic-sign.svg\"", StringComparison.Ordinal), "Traffic signs need a recognisable fallback."); +} + static JsonSerializerOptions JsonOptions() => new() { diff --git a/PlotLine/Services/StoryIntelligenceIllustrationCompatibility.cs b/PlotLine/Services/StoryIntelligenceIllustrationCompatibility.cs index f3011d4..695e186 100644 --- a/PlotLine/Services/StoryIntelligenceIllustrationCompatibility.cs +++ b/PlotLine/Services/StoryIntelligenceIllustrationCompatibility.cs @@ -1,10 +1,13 @@ using System.Text.Json; +using System.Text.RegularExpressions; using PlotLine.Models; namespace PlotLine.Services; public static class StoryIntelligenceIllustrationCompatibility { + private static readonly Regex RoadSuffixRegex = new(@"\b[A-Z0-9][\w' -]*\s(road|street|lane|drive|close|court|way|avenue|terrace|gardens|highway|motorway|bridge|roundabout|junction|crossroads|carriageway|track|footpath|path|cycleway|tunnel|flyover|underpass)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled); + public static class AgeBands { public const string Child = "Child"; @@ -247,6 +250,10 @@ public static class StoryIntelligenceIllustrationCompatibility public static string LocationType(string? value) { var text = (value ?? string.Empty).ToLowerInvariant(); + var padded = $" {text} "; + if (ContainsAny(text, "driving test centre", "driving test center", "test centre", "test center")) return "DrivingTestCentre"; + if (ContainsAny(text, "police station")) return "PoliceStation"; + if (ContainsAny(text, "car interior", "vehicle interior", "inside the car", "inside car", "driver seat", "driver's seat", "passenger seat", "rear seat", "back seat", "boot", "cabin", "dashboard")) return "VehicleInterior"; if (ContainsAny(text, "bathroom", "toilet", "washroom")) return "Bathroom"; if (text.Contains("kitchen", StringComparison.OrdinalIgnoreCase)) return "Kitchen"; if (text.Contains("bedroom", StringComparison.OrdinalIgnoreCase)) return "Bedroom"; @@ -254,17 +261,26 @@ public static class StoryIntelligenceIllustrationCompatibility if (ContainsAny(text, "hall", "hallway", "corridor")) return "Hallway"; if (text.Contains("stair", StringComparison.OrdinalIgnoreCase)) return "Stairwell"; if (ContainsAny(text, "hospital", "derelict hospital")) return "Hospital"; + if (ContainsAny(text, "church", "chapel")) return "Church"; + if (ContainsAny(text, "restaurant", "cafe", "café", "diner")) return "Restaurant"; 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, "garage", "workshop")) 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 (text.Contains("school", StringComparison.OrdinalIgnoreCase)) return "School"; if (text.Contains("waiting room", StringComparison.OrdinalIgnoreCase)) return "WaitingRoom"; - 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", "forecourt")) return "CarPark"; + if (ContainsAny(text, + "road", "street", "lane", "drive", " close ", "court", "way", "avenue", "terrace", "gardens", + "highway", "motorway", "bridge", "roundabout", "junction", "crossroads", "dual carriageway", + "track", "footpath", " path ", "cycleway", "tunnel", "flyover", "underpass", "boulevard") + || RoadSuffixRegex.IsMatch(padded)) + { + return "Road"; + } if (text.Contains("shop", StringComparison.OrdinalIgnoreCase)) return "Shop"; if (ContainsAny(text, "bench", "bus stop")) return "StreetFurniture"; if (ContainsAny(text, "flat", "apartment", "room")) return "FlatInterior"; @@ -275,13 +291,32 @@ public static class StoryIntelligenceIllustrationCompatibility { var text = (value ?? string.Empty).ToLowerInvariant(); var padded = $" {text} "; + if (ContainsAny(text, "driving licence", "driver licence", "provisional licence", "license", "licence")) return "DrivingLicence"; + if (ContainsAny(text, "highway code")) return "DrivingManual"; + if (ContainsAny(text, "passport")) return "Passport"; + if (ContainsAny(text, "certificate", "pass certificate")) return "Certificate"; + if (ContainsAny(text, "envelope")) return "Envelope"; + if (ContainsAny(text, "traffic sign", "road sign", "street sign")) return "TrafficSign"; + if (ContainsAny(text, "clock", "watch")) return "Clock"; + if (ContainsAny(text, "wallet", "purse")) return "Wallet"; + if (ContainsAny(text, "handbag", "bag")) return "Handbag"; + if (ContainsAny(text, "newspaper")) return "Newspaper"; + if (ContainsAny(text, "magazine")) return "Magazine"; + if (ContainsAny(text, "painting", "picture frame")) return "Painting"; + if (ContainsAny(text, "bench")) return "Bench"; + if (ContainsAny(text, "tree")) return "Tree"; + if (ContainsAny(text, "lamp", "light")) return "Lamp"; + if (ContainsAny(text, "window")) return "Window"; + if (ContainsAny(text, "door")) return "Door"; + if (ContainsAny(text, "fence", "gate")) return "Fence"; + if (ContainsAny(text, "gift", "present")) return "Gift"; if (text.Contains("ambulance", StringComparison.OrdinalIgnoreCase)) return "Ambulance"; if (text.Contains("stretcher", StringComparison.OrdinalIgnoreCase)) return "Stretcher"; if (text.Contains("body bag", StringComparison.OrdinalIgnoreCase)) return "BodyBag"; if (text.Contains("bottle", StringComparison.OrdinalIgnoreCase)) return "Bottle"; if (ContainsAny(text, "money", "cash", "coins", "banknote")) return "Money"; if (ContainsAny(text, "scarf", "scarves")) return "Clothing"; - if (ContainsAny(text, "driving licence", "driver licence", "provisional licence", "license", "licence", "pass certificate", "certificate", "paperwork")) return "Document"; + if (ContainsAny(text, "paperwork")) return "Document"; if (ContainsAny(text, "ring", "necklace", "jewellery", "jewelry")) return "Jewellery"; if (ContainsAny(text, "gun", "pistol", "rifle", "weapon")) return "Weapon"; if (ContainsAny(padded, " hatchback ", " vw golf ", " volkswagen golf ", " golf gti ", " ford fiesta ", " fiesta ")) return "Hatchback"; diff --git a/PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs b/PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs index 3adc998..b858553 100644 --- a/PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs +++ b/PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs @@ -2187,7 +2187,14 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( { var value = $"{name} {location?.LocationType} {location?.GenericRoomType} {setting?.LocationType}".ToLowerInvariant(); var type = StoryIntelligenceIllustrationCompatibility.LocationType(value); - if (type is "Bathroom" or "WaitingRoom" or "Road") return string.Empty; + if (type == "Bathroom") return "loc-generic-bathroom"; + if (type == "Kitchen") return "loc-generic-kitchen"; + if (type == "WaitingRoom") return "loc-generic-waiting-room"; + if (type == "DrivingTestCentre") return "loc-driving-test-centre"; + if (type == "VehicleInterior") return "loc-vehicle-interior"; + if (type == "Road") return "loc-generic-road"; + if (type == "Garage") return "loc-garage-workshop"; + if (type is "Hospital" or "PoliceStation" or "School" or "Church" or "Restaurant" or "Pub") return $"loc-generic-{StableKey(type)}"; if (type is "Kitchen" or "Bedroom" or "LivingRoom" or "FlatInterior" or "Hallway") return "loc-small-flat-interior"; if (type == "Office") return "loc-domestic-desk"; if (type == "Stairwell") return "loc-narrow-stairwell"; @@ -2200,12 +2207,22 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( { var value = $"{name} {asset.AssetType} {asset.Status}".ToLowerInvariant(); var type = StoryIntelligenceIllustrationCompatibility.AssetType(value); - if (type is "Letter" or "Note" or "Document") return "asset-folded-letter"; - if (type is "Notebook" or "Book") return "asset-worn-notebook"; + if (type is "Letter" or "Note" or "Document" or "Certificate" or "Passport" or "DrivingLicence") return "asset-document"; + if (type is "Envelope") return "asset-envelope"; + if (type is "Notebook") return "asset-worn-notebook"; + if (type is "Book" or "DrivingManual" or "Newspaper" or "Magazine") return "asset-book"; if (type == "Car") return "asset-red-classic-car"; if (type == "Keys") return "asset-old-keys"; if (type == "Telephone") return "asset-phone-recorder"; if (type == "Photograph") return "asset-photo-print"; + if (type is "Painting") return "asset-photo-print"; + if (type == "Bottle") return "asset-bottle"; + if (type == "Clock") return "asset-clock"; + if (type == "TrafficSign") return "asset-traffic-sign"; + if (type is "Wallet" or "Handbag" or "Suitcase") return "asset-bag-wallet"; + if (type is "Parcel" or "Gift") return "asset-parcel"; + if (type is "Bench") return "asset-bench"; + if (type is "Tree" or "Lamp" or "Window" or "Door" or "Fence") return "asset-environment-object"; if (type == "Rucksack") return "asset-rucksack"; return string.Empty; } @@ -2234,6 +2251,19 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( "loc-narrow-stairwell" => $"{FallbackRoot}/location-stairwell.svg", "loc-wet-car-park" => $"{FallbackRoot}/location-car-park.svg", "loc-estate-house-exterior" => $"{FallbackRoot}/location-doweries.svg", + "loc-generic-road" => $"{FallbackRoot}/location-road.svg", + "loc-vehicle-interior" => $"{FallbackRoot}/location-vehicle-interior.svg", + "loc-generic-bathroom" => $"{FallbackRoot}/location-bathroom.svg", + "loc-generic-kitchen" => $"{FallbackRoot}/location-kitchen.svg", + "loc-generic-waiting-room" => $"{FallbackRoot}/location-waiting-room.svg", + "loc-driving-test-centre" => $"{FallbackRoot}/location-driving-test-centre.svg", + "loc-garage-workshop" => $"{FallbackRoot}/location-garage-workshop.svg", + "loc-generic-hospital" => $"{FallbackRoot}/location-building.svg", + "loc-generic-policestation" => $"{FallbackRoot}/location-building.svg", + "loc-generic-school" => $"{FallbackRoot}/location-building.svg", + "loc-generic-church" => $"{FallbackRoot}/location-building.svg", + "loc-generic-restaurant" => $"{FallbackRoot}/location-building.svg", + "loc-generic-pub" => $"{FallbackRoot}/location-building.svg", _ => $"{FallbackRoot}/location-flat.svg" }; @@ -2241,10 +2271,21 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( => AssetLibraryCode(name, new SceneIntelligenceAsset()) switch { "asset-folded-letter" => $"{FallbackRoot}/asset-letter.svg", + "asset-document" => $"{FallbackRoot}/asset-document.svg", + "asset-envelope" => $"{FallbackRoot}/asset-envelope.svg", "asset-worn-notebook" => $"{FallbackRoot}/asset-notebook.svg", + "asset-book" => $"{FallbackRoot}/asset-book.svg", "asset-red-classic-car" => $"{FallbackRoot}/asset-red-tr6.svg", "asset-old-keys" => $"{FallbackRoot}/asset-keys.svg", "asset-phone-recorder" => $"{FallbackRoot}/asset-phone.svg", + "asset-photo-print" => $"{FallbackRoot}/asset-photo.svg", + "asset-bottle" => $"{FallbackRoot}/asset-bottle.svg", + "asset-clock" => $"{FallbackRoot}/asset-clock.svg", + "asset-traffic-sign" => $"{FallbackRoot}/asset-traffic-sign.svg", + "asset-bag-wallet" => $"{FallbackRoot}/asset-bag-wallet.svg", + "asset-parcel" => $"{FallbackRoot}/asset-parcel.svg", + "asset-bench" => $"{FallbackRoot}/asset-bench.svg", + "asset-environment-object" => $"{FallbackRoot}/asset-environment.svg", _ => $"{FallbackRoot}/asset-generic.svg" }; diff --git a/PlotLine/wwwroot/css/story-intelligence-experience-prototype.css b/PlotLine/wwwroot/css/story-intelligence-experience-prototype.css index c501799..e33e30c 100644 --- a/PlotLine/wwwroot/css/story-intelligence-experience-prototype.css +++ b/PlotLine/wwwroot/css/story-intelligence-experience-prototype.css @@ -286,6 +286,48 @@ input:focus-visible { transform: translateX(-50%); } +.story-exp-rebuild { + display: flex; + align-items: center; + justify-content: center; + flex-wrap: wrap; + gap: 9px; + margin: 0 18px 10px; + border: 1px solid rgba(151, 185, 218, 0.12); + border-radius: 999px; + padding: 8px 10px; + background: rgba(4, 12, 24, 0.74); +} + +.story-exp-rebuild label { + display: inline-flex; + align-items: center; + gap: 8px; + color: var(--story-muted); + font-size: 0.76rem; + font-weight: 800; + text-transform: uppercase; +} + +.story-exp-rebuild select, +.story-exp-rebuild button { + border: 1px solid rgba(151, 185, 218, 0.18); + border-radius: 999px; + background: rgba(255, 255, 255, 0.045); + color: var(--story-soft); + font-size: 0.76rem; +} + +.story-exp-rebuild select { + max-width: min(280px, 40vw); + padding: 6px 22px 6px 9px; +} + +.story-exp-rebuild button { + padding: 6px 12px; + font-weight: 800; +} + .story-exp-shell { display: grid; grid-template-columns: minmax(180px, 0.46fr) minmax(820px, 2.72fr) minmax(188px, 0.48fr); @@ -301,7 +343,15 @@ input:focus-visible { gap: 14px; max-height: calc(100vh - 126px); min-height: 0; - overflow: hidden; + overflow-x: hidden; + overflow-y: auto; + scrollbar-width: none; +} + +.story-exp-left::-webkit-scrollbar, +.story-exp-right::-webkit-scrollbar { + width: 0; + height: 0; } .story-exp-left { @@ -331,18 +381,18 @@ input:focus-visible { .story-exp-progress, .story-exp-panel { - padding: 16px; + padding: 11px; } .story-exp-panel { display: grid; - grid-template-rows: auto minmax(0, 1fr); + grid-template-rows: auto auto; min-height: 0; overflow: hidden; } .story-exp-panel--compact { - padding: 12px; + padding: 9px; } .story-exp-progress-ring { @@ -454,38 +504,39 @@ input:focus-visible { } .story-exp-panel h2 { - margin: 0 0 12px; - font-size: 1rem; + margin: 0 0 7px; + font-size: 0.88rem; letter-spacing: 0.01em; } .story-exp-list, .story-exp-feed { display: grid; - gap: 8px; + gap: 5px; margin: 0; padding: 0; list-style: none; min-height: 0; - overflow: auto; - scrollbar-width: thin; + overflow: visible; } .story-exp-list li, .story-exp-feed li, .story-exp-insight { border: 1px solid rgba(255, 255, 255, 0.065); - border-radius: 14px; - padding: 10px 11px; + border-radius: 9px; + padding: 7px 8px; background: rgba(255, 255, 255, 0.035); color: var(--story-soft); + min-width: 0; + overflow-wrap: anywhere; } .story-exp-list li { display: grid; - grid-template-columns: 18px 1fr; + grid-template-columns: 14px 1fr; align-items: start; - gap: 8px; + gap: 6px; } .story-exp-list li span { @@ -701,7 +752,7 @@ input:focus-visible { .story-exp-characters { position: relative; - z-index: 90; + z-index: 120; width: 100%; height: 100%; } @@ -713,13 +764,13 @@ input:focus-visible { height: var(--node-size, 150px); transform: translate(var(--x, 0), var(--y, 0)) scale(var(--scale, 1)); opacity: var(--opacity, 1); - z-index: calc(var(--z, 3) + 90); + z-index: calc(var(--z, 3) + 120); pointer-events: auto; transition: transform 1050ms cubic-bezier(.16, .84, .28, 1), opacity 780ms ease; } .story-character.is-pov { - z-index: 48; + z-index: 190; } .story-character.is-context .story-character__image { @@ -760,58 +811,28 @@ input:focus-visible { .story-character__text { position: absolute; z-index: 180; - top: calc(var(--node-size, 150px) + 9px); + top: calc(var(--node-size, 150px) + 8px); left: 50%; display: grid; justify-items: center; - gap: 2px; + gap: 1px; width: min(188px, calc(var(--node-size, 150px) + 58px)); max-width: calc(100vw - 32px); margin-left: 0; - border: 1px solid rgba(151, 185, 218, 0.1); - border-radius: 10px; + border: 1px solid rgba(151, 185, 218, 0.16); + border-radius: 9px; padding: 5px 7px 6px; - background: rgba(4, 10, 20, 0.82); + background: rgba(4, 10, 20, 0.91); 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); text-align: center; transform: translateX(-50%); } -.story-character.is-pov .story-character__text { - top: auto; - left: calc(50% + 34px); - bottom: calc(100% + 9px); -} - -.story-character[data-layout-role="partner"] .story-character__text { - top: 50%; - left: calc(100% + 12px); - justify-items: start; - text-align: left; - transform: translateY(-50%); -} - -.story-character[data-layout-role="support"] .story-character__text { - left: calc(100% + 10px); - justify-items: start; - text-align: left; - transform: translateY(0); -} - .story-character[data-layout-role="context"] .story-character__text { width: min(132px, calc(var(--node-size, 150px) + 34px)); left: 50%; - top: calc(100% + 7px); -} - -.story-character[data-label-side="left"] .story-character__text { - top: 50%; - left: auto; - right: calc(100% + 10px); - justify-items: end; - text-align: right; - transform: translateY(-50%); + top: calc(var(--node-size, 150px) + 7px); } .story-character__text strong { @@ -972,7 +993,7 @@ input:focus-visible { .story-exp-ribbon-wrap { display: grid; - grid-template-rows: minmax(104px, auto) 56px; + grid-template-rows: auto minmax(104px, auto) 56px; gap: 12px; min-width: 0; border: 1px solid rgba(151, 185, 218, 0.1); @@ -983,6 +1004,47 @@ input:focus-visible { backdrop-filter: blur(14px); } +.story-exp-replay-browser { + display: flex; + align-items: center; + justify-content: center; + flex-wrap: wrap; + gap: 8px; + min-width: 0; +} + +.story-exp-replay-browser button, +.story-exp-replay-browser select, +.story-exp-controls select { + border: 1px solid rgba(151, 185, 218, 0.18); + border-radius: 999px; + background: rgba(4, 12, 24, 0.84); + color: var(--story-soft); +} + +.story-exp-replay-browser button { + padding: 7px 12px; + font-size: 0.74rem; + font-weight: 800; +} + +.story-exp-replay-browser label { + display: inline-flex; + align-items: center; + gap: 7px; + min-width: 0; + color: var(--story-muted); + font-size: 0.72rem; + font-weight: 800; + text-transform: uppercase; +} + +.story-exp-replay-browser select { + max-width: min(300px, 36vw); + padding: 7px 24px 7px 10px; + font-size: 0.76rem; +} + .story-exp-ribbon { position: relative; display: flex; @@ -1117,43 +1179,44 @@ input:focus-visible { .story-exp-insight-list { display: grid; - gap: 9px; + gap: 5px; min-height: 0; - overflow: auto; - scrollbar-width: thin; + overflow: visible; } .story-exp-insight { display: grid; - gap: 4px; + gap: 2px; border-color: rgba(255, 255, 255, 0.055); background: linear-gradient(180deg, rgba(255, 255, 255, 0.038), rgba(255, 255, 255, 0.018)); box-shadow: 0 16px 38px rgba(0, 0, 0, 0.16); transition: opacity 560ms ease, transform 700ms cubic-bezier(.16, .84, .28, 1); + min-width: 0; + overflow-wrap: anywhere; } .story-exp-panel--compact .story-exp-insight { - padding: 8px 9px; + padding: 6px 7px; } .story-exp-panel--compact .story-exp-insight p { display: -webkit-box; overflow: hidden; margin: 0; - font-size: 0.76rem; - line-height: 1.25; + font-size: 0.72rem; + line-height: 1.16; -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; + border-radius: 8px; + padding: 5px 7px; background: rgba(255, 255, 255, 0.035); color: var(--story-soft); - font-size: 0.78rem; + font-size: 0.72rem; font-weight: 800; text-align: left; } @@ -1165,10 +1228,12 @@ input:focus-visible { .story-exp-insight strong { color: var(--story-text); - font-size: 0.92rem; + font-size: 0.82rem; + line-height: 1.12; } .story-exp-insight span { + display: none; color: var(--story-muted); font-size: 0.78rem; font-weight: 800; @@ -1211,8 +1276,56 @@ input:focus-visible { text-transform: uppercase; } -.story-exp-controls input { - width: 104px; +.story-exp-controls select { + padding: 5px 20px 5px 9px; + font-size: 0.76rem; +} + +.story-exp-detail { + position: fixed; + right: 24px; + top: 96px; + z-index: 12; + width: min(360px, calc(100vw - 48px)); + border: 1px solid rgba(151, 185, 218, 0.16); + border-radius: 16px; + padding: 16px; + background: rgba(4, 10, 20, 0.9); + box-shadow: var(--story-shadow); + color: var(--story-soft); + backdrop-filter: blur(16px); +} + +.story-exp-detail button { + float: right; + border: 1px solid rgba(151, 185, 218, 0.16); + border-radius: 999px; + padding: 5px 10px; + background: rgba(255, 255, 255, 0.045); + color: var(--story-soft); + font-size: 0.72rem; + font-weight: 800; +} + +.story-exp-detail span { + display: block; + margin-bottom: 9px; + color: var(--story-muted); + font-size: 0.72rem; + font-weight: 800; + text-transform: uppercase; +} + +.story-exp-detail h2 { + margin: 0 0 8px; + color: var(--story-text); + font-size: 1.08rem; +} + +.story-exp-detail p { + margin: 0; + line-height: 1.45; + overflow-wrap: anywhere; } .story-exp-diagnostics { diff --git a/PlotLine/wwwroot/images/story-intelligence/prototype/asset-bag-wallet.svg b/PlotLine/wwwroot/images/story-intelligence/prototype/asset-bag-wallet.svg new file mode 100644 index 0000000..8dcdb7d --- /dev/null +++ b/PlotLine/wwwroot/images/story-intelligence/prototype/asset-bag-wallet.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/PlotLine/wwwroot/images/story-intelligence/prototype/asset-bench.svg b/PlotLine/wwwroot/images/story-intelligence/prototype/asset-bench.svg new file mode 100644 index 0000000..8ba65f3 --- /dev/null +++ b/PlotLine/wwwroot/images/story-intelligence/prototype/asset-bench.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/PlotLine/wwwroot/images/story-intelligence/prototype/asset-book.svg b/PlotLine/wwwroot/images/story-intelligence/prototype/asset-book.svg new file mode 100644 index 0000000..d8979e9 --- /dev/null +++ b/PlotLine/wwwroot/images/story-intelligence/prototype/asset-book.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/PlotLine/wwwroot/images/story-intelligence/prototype/asset-bottle.svg b/PlotLine/wwwroot/images/story-intelligence/prototype/asset-bottle.svg new file mode 100644 index 0000000..6cbb45b --- /dev/null +++ b/PlotLine/wwwroot/images/story-intelligence/prototype/asset-bottle.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/PlotLine/wwwroot/images/story-intelligence/prototype/asset-clock.svg b/PlotLine/wwwroot/images/story-intelligence/prototype/asset-clock.svg new file mode 100644 index 0000000..80f1783 --- /dev/null +++ b/PlotLine/wwwroot/images/story-intelligence/prototype/asset-clock.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/PlotLine/wwwroot/images/story-intelligence/prototype/asset-document.svg b/PlotLine/wwwroot/images/story-intelligence/prototype/asset-document.svg new file mode 100644 index 0000000..7691ce3 --- /dev/null +++ b/PlotLine/wwwroot/images/story-intelligence/prototype/asset-document.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/PlotLine/wwwroot/images/story-intelligence/prototype/asset-envelope.svg b/PlotLine/wwwroot/images/story-intelligence/prototype/asset-envelope.svg new file mode 100644 index 0000000..456c600 --- /dev/null +++ b/PlotLine/wwwroot/images/story-intelligence/prototype/asset-envelope.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/PlotLine/wwwroot/images/story-intelligence/prototype/asset-environment.svg b/PlotLine/wwwroot/images/story-intelligence/prototype/asset-environment.svg new file mode 100644 index 0000000..875472e --- /dev/null +++ b/PlotLine/wwwroot/images/story-intelligence/prototype/asset-environment.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/PlotLine/wwwroot/images/story-intelligence/prototype/asset-parcel.svg b/PlotLine/wwwroot/images/story-intelligence/prototype/asset-parcel.svg new file mode 100644 index 0000000..6c98266 --- /dev/null +++ b/PlotLine/wwwroot/images/story-intelligence/prototype/asset-parcel.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/PlotLine/wwwroot/images/story-intelligence/prototype/asset-photo.svg b/PlotLine/wwwroot/images/story-intelligence/prototype/asset-photo.svg new file mode 100644 index 0000000..e75c524 --- /dev/null +++ b/PlotLine/wwwroot/images/story-intelligence/prototype/asset-photo.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/PlotLine/wwwroot/images/story-intelligence/prototype/asset-traffic-sign.svg b/PlotLine/wwwroot/images/story-intelligence/prototype/asset-traffic-sign.svg new file mode 100644 index 0000000..edb186c --- /dev/null +++ b/PlotLine/wwwroot/images/story-intelligence/prototype/asset-traffic-sign.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/PlotLine/wwwroot/images/story-intelligence/prototype/location-bathroom.svg b/PlotLine/wwwroot/images/story-intelligence/prototype/location-bathroom.svg new file mode 100644 index 0000000..9dc5976 --- /dev/null +++ b/PlotLine/wwwroot/images/story-intelligence/prototype/location-bathroom.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/PlotLine/wwwroot/images/story-intelligence/prototype/location-building.svg b/PlotLine/wwwroot/images/story-intelligence/prototype/location-building.svg new file mode 100644 index 0000000..121641f --- /dev/null +++ b/PlotLine/wwwroot/images/story-intelligence/prototype/location-building.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/PlotLine/wwwroot/images/story-intelligence/prototype/location-driving-test-centre.svg b/PlotLine/wwwroot/images/story-intelligence/prototype/location-driving-test-centre.svg new file mode 100644 index 0000000..c236fa2 --- /dev/null +++ b/PlotLine/wwwroot/images/story-intelligence/prototype/location-driving-test-centre.svg @@ -0,0 +1,9 @@ + + + + + + + + TEST + diff --git a/PlotLine/wwwroot/images/story-intelligence/prototype/location-garage-workshop.svg b/PlotLine/wwwroot/images/story-intelligence/prototype/location-garage-workshop.svg new file mode 100644 index 0000000..6c4a579 --- /dev/null +++ b/PlotLine/wwwroot/images/story-intelligence/prototype/location-garage-workshop.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/PlotLine/wwwroot/images/story-intelligence/prototype/location-kitchen.svg b/PlotLine/wwwroot/images/story-intelligence/prototype/location-kitchen.svg new file mode 100644 index 0000000..ab2547a --- /dev/null +++ b/PlotLine/wwwroot/images/story-intelligence/prototype/location-kitchen.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/PlotLine/wwwroot/images/story-intelligence/prototype/location-road.svg b/PlotLine/wwwroot/images/story-intelligence/prototype/location-road.svg new file mode 100644 index 0000000..4ec3032 --- /dev/null +++ b/PlotLine/wwwroot/images/story-intelligence/prototype/location-road.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/PlotLine/wwwroot/images/story-intelligence/prototype/location-vehicle-interior.svg b/PlotLine/wwwroot/images/story-intelligence/prototype/location-vehicle-interior.svg new file mode 100644 index 0000000..97753fd --- /dev/null +++ b/PlotLine/wwwroot/images/story-intelligence/prototype/location-vehicle-interior.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/PlotLine/wwwroot/images/story-intelligence/prototype/location-waiting-room.svg b/PlotLine/wwwroot/images/story-intelligence/prototype/location-waiting-room.svg new file mode 100644 index 0000000..1ac5428 --- /dev/null +++ b/PlotLine/wwwroot/images/story-intelligence/prototype/location-waiting-room.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/PlotLine/wwwroot/js/story-intelligence-experience-prototype.js b/PlotLine/wwwroot/js/story-intelligence-experience-prototype.js index 6839f62..d759a7b 100644 --- a/PlotLine/wwwroot/js/story-intelligence-experience-prototype.js +++ b/PlotLine/wwwroot/js/story-intelligence-experience-prototype.js @@ -8,7 +8,9 @@ const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; const realMode = root.dataset.mode === "real"; + const replayMode = root.dataset.mode === "replay"; const simulationMode = root.dataset.mode === "simulation"; + const controllableMode = replayMode || simulationMode; const snapshotUrl = root.dataset.snapshotUrl || ""; const failedImageUrls = new Set(); const preloadedImageUrls = new Set(); @@ -73,7 +75,14 @@ diagnosticsRefresh: root.querySelector("[data-diagnostics-refresh]"), diagnosticsBuild: root.querySelector("[data-diagnostics-build]"), diagnosticsOperations: root.querySelector("[data-diagnostics-operations]"), - diagnosticValues: root.querySelectorAll("[data-diagnostic-key]") + diagnosticValues: root.querySelectorAll("[data-diagnostic-key]"), + replayChapter: root.querySelector("[data-replay-chapter]"), + replayScene: root.querySelector("[data-replay-scene]"), + detailPanel: root.querySelector("[data-detail-panel]"), + detailClose: root.querySelector("[data-detail-close]"), + detailTone: root.querySelector("[data-detail-tone]"), + detailTitle: root.querySelector("[data-detail-title]"), + detailBody: root.querySelector("[data-detail-body]") }; if (!scenes.length) { @@ -97,6 +106,7 @@ updateProgress(current); updateSceneCopy(current); + updateReplayBrowser(current); const layout = sceneLayout(current); reconcileCharacters(current, previous, layout); reconcileLocation(current, previous); @@ -149,21 +159,75 @@ } function updateProgress(scene) { - text(dom.stageLabel, scene.stage); + const replayText = replayMode ? replayStatusText(scene) : scene.stage; + text(dom.stageLabel, replayText); text(dom.currentScene, scene.sceneNumber); animateNumber(dom.progressPercent, scene.progressPercent, "%"); text(dom.elapsedTime, scene.elapsedTime || "Calculating"); text(dom.estimatedRemaining, scene.estimatedRemaining); text(dom.chapterCount, `${scene.chaptersAnalysed} of ${scene.chapterTotal}`); text(dom.sceneCount, `${scene.scenesAnalysed} of ${scene.sceneTotal}`); - text(dom.currentStage, scene.stage); + text(dom.currentStage, replayText); if (dom.progressRing) dom.progressRing.style.setProperty("--progress-value", `${scene.progressPercent * 3.6}deg`); } + function replayStatusText(scene) { + if (!scene) return "Replay"; + const stateText = state.playing ? "Replaying" : "Paused at"; + return `${stateText} ${scene.chapterLabel} Scene ${scene.sceneNumber}`; + } + function updateSceneCopy(scene) { text(dom.sceneSummary, scene.summary); } + function setupReplayBrowser() { + if (!replayMode || !dom.replayChapter || !dom.replayScene) return; + const chapters = []; + const seen = new Set(); + const failedChapters = (root.dataset.failedChapters || "") + .split(";") + .map((value) => value.trim()) + .filter(Boolean); + scenes.forEach((scene) => { + const key = replayChapterKey(scene); + if (seen.has(key)) return; + seen.add(key); + chapters.push({ key, label: scene.chapterLabel || `Chapter ${scene.chapterNumber || ""}` }); + }); + + const replayOptions = chapters + .map((chapter) => ``); + const gapOptions = failedChapters + .map((chapter, index) => ``); + dom.replayChapter.innerHTML = replayOptions.concat(gapOptions).join(""); + populateReplayScenes(chapters[0]?.key || ""); + } + + function populateReplayScenes(chapterKey) { + if (!dom.replayScene) return; + const options = scenes + .map((scene, index) => ({ scene, index })) + .filter((item) => replayChapterKey(item.scene) === chapterKey); + dom.replayScene.innerHTML = options + .map((item) => ``) + .join(""); + } + + function updateReplayBrowser(scene) { + if (!replayMode || !scene || !dom.replayChapter || !dom.replayScene) return; + const key = replayChapterKey(scene); + if (dom.replayChapter.value !== key) { + dom.replayChapter.value = key; + populateReplayScenes(key); + } + dom.replayScene.value = String(state.index); + } + + function replayChapterKey(scene) { + return String(scene?.chapterNumber || scene?.chapterLabel || "unknown"); + } + function setEntityImage(image, url, resolution, altText) { if (!image) return; image.alt = altText; @@ -255,7 +319,6 @@ node.style.setProperty("--opacity", String(position.opacity)); node.style.setProperty("--z", String(position.z)); node.dataset.layoutRole = position.layoutRole; - node.dataset.labelSide = position.labelSide || ""; setEntityImage(node.querySelector("img"), character.imagePath, character.imageResolution, `${character.name} prototype portrait`); text(node.querySelector("[data-character-name]"), character.name); const label = conciseCharacterLabel(character, character.id === scene.povCharacterId); @@ -356,8 +419,10 @@ } function updateKnowledgeThreads(scene) { - renderInsights(dom.knowledge, scene.knowledgeThreads); - renderInsights(dom.observations, scene.observations); + renderInsights(dom.knowledge, (scene.knowledgeThreads || []).slice(0, 3)); + renderOverflowSummary(dom.knowledge, (scene.knowledgeThreads || []).length - 3, "thread"); + renderInsights(dom.observations, (scene.observations || []).slice(0, 2)); + renderOverflowSummary(dom.observations, (scene.observations || []).length - 2, "observation"); } function updateSceneRibbon(scene) { @@ -427,8 +492,8 @@ } function updateFeeds(scene) { - renderList(dom.discoveries, scene.latestDiscoveries); - renderList(dom.activityFeed, scene.activityFeed); + renderList(dom.discoveries, (scene.latestDiscoveries || []).slice(0, 4)); + renderList(dom.activityFeed, (scene.activityFeed || []).slice(0, 4)); } function renderInsights(container, items) { @@ -439,15 +504,23 @@ node = document.createElement("article"); node.className = "story-exp-insight is-entering"; node.dataset.insightId = item.id; - node.innerHTML = `

`; + node.innerHTML = `

`; container.append(node); settleEntering(node); } node.dataset.tone = item.tone || ""; - text(node.querySelector("span"), item.tone || "Signal"); text(node.querySelector("strong"), item.title); text(node.querySelector("p"), item.detail); + node.title = `${item.title || ""}\n${item.detail || ""}`.trim(); + node.tabIndex = 0; + node.onclick = () => openDetail(item); + node.onkeydown = (event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + openDetail(item); + } + }; }); container.querySelectorAll("[data-insight-id]").forEach((node) => { @@ -459,6 +532,18 @@ }); } + function openDetail(item) { + if (!dom.detailPanel) return; + text(dom.detailTone, item.tone || "Story detail"); + text(dom.detailTitle, item.title || "Story detail"); + text(dom.detailBody, item.detail || ""); + dom.detailPanel.hidden = false; + } + + function closeDetail() { + if (dom.detailPanel) dom.detailPanel.hidden = true; + } + function renderOverflowSummary(container, hiddenCount, noun) { if (!container) return; let node = container.querySelector("[data-insight-overflow]"); @@ -690,7 +775,7 @@ opacity: 1, z: 7, layoutRole: "pov", - labelSide: "pov" + labelSide: "below" }); } @@ -721,7 +806,7 @@ opacity: contextual ? 0.76 : 0.94, z: contextual ? 3 : 5, layoutRole: contextual ? "context" : slot.role, - labelSide: contextual && pov && resolved.x < positions.get(pov.id).x ? "left" : slot.role === "partner" || slot.role === "support" ? "right" : "below" + labelSide: "below" }; placed.push(position); positions.set(character.id, position); @@ -1096,10 +1181,15 @@ function startTimer() { if (!scenes.length) return; stopTimer(); - if (!state.playing || !simulationMode) return; + if (!state.playing || !controllableMode) return; state.timer = window.setInterval(() => { - const next = state.index >= scenes.length - 1 ? 0 : state.index + 1; + const next = state.index >= scenes.length - 1 ? (replayMode ? scenes.length - 1 : 0) : state.index + 1; transitionToNextState(next); + if (replayMode && next >= scenes.length - 1) { + state.playing = false; + text(dom.play, "Play"); + stopTimer(); + } }, state.intervalMs); } @@ -1307,6 +1397,7 @@ if (key === "pollingActive") return state.pollTimer ? "active" : "stopped"; if (key === "pollingStoppedReason") return state.pollingStoppedReason || "none"; if (key === "currentPollingInterval") return realMode ? `${document.hidden ? 15000 : 7000}ms` : `${state.intervalMs}ms`; + if (key === "replayIndex") return replayMode ? state.index + 1 : diagnostics[key]; if (key === "latestSnapshotAge") return latestSnapshotAge(); const value = diagnostics[key]; return value === null || value === undefined || value === "" ? "none" : value; @@ -1394,37 +1485,67 @@ return window.CSS?.escape ? window.CSS.escape(value) : String(value).replace(/[^a-zA-Z0-9_-]/g, "\\$&"); } - root.querySelector("[data-control='restart']")?.addEventListener("click", () => { - if (!simulationMode) return; + function goToReplayIndex(index, restartPlayback) { + if (!scenes.length) return; + transitionToNextState(Math.max(0, Math.min(scenes.length - 1, index))); + if (restartPlayback) startTimer(); + } + + root.querySelectorAll("[data-control='restart']").forEach((button) => button.addEventListener("click", () => { + if (!controllableMode) return; transitionToNextState(0); startTimer(); - }); + })); - root.querySelector("[data-control='previous']")?.addEventListener("click", () => { - if (!simulationMode) return; - transitionToNextState(state.index <= 0 ? scenes.length - 1 : state.index - 1); + root.querySelectorAll("[data-control='previous']").forEach((button) => button.addEventListener("click", () => { + if (!controllableMode) return; + transitionToNextState(state.index <= 0 ? (replayMode ? 0 : scenes.length - 1) : state.index - 1); startTimer(); - }); + })); - root.querySelector("[data-control='next']")?.addEventListener("click", () => { - if (!simulationMode) return; - transitionToNextState(state.index >= scenes.length - 1 ? 0 : state.index + 1); + root.querySelectorAll("[data-control='next']").forEach((button) => button.addEventListener("click", () => { + if (!controllableMode) return; + transitionToNextState(state.index >= scenes.length - 1 ? (replayMode ? scenes.length - 1 : 0) : state.index + 1); startTimer(); - }); + })); + + root.querySelectorAll("[data-control='first']").forEach((button) => button.addEventListener("click", () => { + if (!replayMode) return; + goToReplayIndex(0, false); + })); + + root.querySelectorAll("[data-control='last']").forEach((button) => button.addEventListener("click", () => { + if (!replayMode) return; + goToReplayIndex(scenes.length - 1, false); + })); dom.play?.addEventListener("click", () => { - if (!simulationMode) return; + if (!controllableMode) return; state.playing = !state.playing; text(dom.play, state.playing ? "Pause" : "Play"); if (state.playing) startTimer(); else stopTimer(); + if (replayMode) updateProgress(loadSceneState(state.index)); }); dom.speed?.addEventListener("input", () => { - if (!simulationMode) return; + if (!controllableMode) return; state.intervalMs = Number.parseInt(dom.speed.value, 10) || 6200; startTimer(); }); + dom.detailClose?.addEventListener("click", closeDetail); + + dom.replayChapter?.addEventListener("change", () => { + if (!replayMode) return; + populateReplayScenes(dom.replayChapter.value); + goToReplayIndex(Number.parseInt(dom.replayScene.value, 10) || 0, false); + }); + + dom.replayScene?.addEventListener("change", () => { + if (!replayMode) return; + goToReplayIndex(Number.parseInt(dom.replayScene.value, 10) || 0, false); + }); + document.addEventListener("visibilitychange", () => { if (!realMode || state.terminal) return; startPolling(); @@ -1454,6 +1575,7 @@ } if (scenes.length) { + setupReplayBrowser(); transitionToNextState(realMode ? authoritativeSceneIndex(scenes) : 0); startTimer(); } else {