Phase 21T - Story Intelligence Visual Clarity and Semantics

This commit is contained in:
Nick Beckley 2026-07-16 21:08:55 +00:00
parent f8e320d867
commit 08a014e2ce
24 changed files with 589 additions and 103 deletions

View File

@ -59,6 +59,7 @@ var tests = new (string Name, Action Test)[]
("Phase 21R replay uses persisted data only", Phase21RReplayUsesPersistedDataOnly), ("Phase 21R replay uses persisted data only", Phase21RReplayUsesPersistedDataOnly),
("Phase 21R scene browser and panels are replay friendly", Phase21RSceneBrowserAndPanelsAreReplayFriendly), ("Phase 21R scene browser and panels are replay friendly", Phase21RSceneBrowserAndPanelsAreReplayFriendly),
("Phase 21S uses durable Story Memory assignments", Phase21SUsesDurableStoryMemoryAssignments), ("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), ("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),
@ -559,7 +560,7 @@ static void StoryIntelligenceEvidenceExtractionPreventsObservedAttributeLeakage(
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("money") == "Money", "Money must not classify as Car."); 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("bottle") == "Bottle", "Bottle must not classify as LockedBox.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("scarves") == "Clothing", "Scarves must not classify as Car."); 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("ring") == "Jewellery", "Ring should classify as Jewellery.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("gun") == "Weapon", "Gun should classify as Weapon."); 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("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("biscuit tin") == "Tin", "Biscuit tin should not classify as Photograph.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("parcel") == "Parcel", "Parcel should not classify as Letter."); 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("pass certificate") == "Certificate", "Pass certificate should classify as Certificate.");
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("thin cotton blouse") == "Clothing", "Blouse should classify as Clothing."); 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."); 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."); 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() static JsonSerializerOptions JsonOptions()
=> new() => new()
{ {

View File

@ -1,10 +1,13 @@
using System.Text.Json; using System.Text.Json;
using System.Text.RegularExpressions;
using PlotLine.Models; using PlotLine.Models;
namespace PlotLine.Services; namespace PlotLine.Services;
public static class StoryIntelligenceIllustrationCompatibility 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 static class AgeBands
{ {
public const string Child = "Child"; public const string Child = "Child";
@ -247,6 +250,10 @@ public static class StoryIntelligenceIllustrationCompatibility
public static string LocationType(string? value) public static string LocationType(string? value)
{ {
var text = (value ?? string.Empty).ToLowerInvariant(); 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 (ContainsAny(text, "bathroom", "toilet", "washroom")) return "Bathroom";
if (text.Contains("kitchen", StringComparison.OrdinalIgnoreCase)) return "Kitchen"; if (text.Contains("kitchen", StringComparison.OrdinalIgnoreCase)) return "Kitchen";
if (text.Contains("bedroom", StringComparison.OrdinalIgnoreCase)) return "Bedroom"; 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 (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, "hospital", "derelict hospital")) return "Hospital"; 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, "pub", "public house")) return "Pub";
if (ContainsAny(text, "beach", "seafront")) return "Beach"; if (ContainsAny(text, "beach", "seafront")) return "Beach";
if (ContainsAny(text, "railway station", "train station", "station platform")) return "RailwayStation"; if (ContainsAny(text, "railway station", "train station", "station platform")) return "RailwayStation";
if (ContainsAny(text, "canal", "towpath")) return "Canal"; 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, "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", "path", "track")) return "Road"; if (ContainsAny(text, "car park", "parking", "forecourt")) return "CarPark";
if (ContainsAny(text, "car park", "parking")) 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 (text.Contains("shop", StringComparison.OrdinalIgnoreCase)) return "Shop";
if (ContainsAny(text, "bench", "bus stop")) return "StreetFurniture"; if (ContainsAny(text, "bench", "bus stop")) return "StreetFurniture";
if (ContainsAny(text, "flat", "apartment", "room")) return "FlatInterior"; if (ContainsAny(text, "flat", "apartment", "room")) return "FlatInterior";
@ -275,13 +291,32 @@ public static class StoryIntelligenceIllustrationCompatibility
{ {
var text = (value ?? string.Empty).ToLowerInvariant(); var text = (value ?? string.Empty).ToLowerInvariant();
var padded = $" {text} "; 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("ambulance", StringComparison.OrdinalIgnoreCase)) return "Ambulance";
if (text.Contains("stretcher", StringComparison.OrdinalIgnoreCase)) return "Stretcher"; if (text.Contains("stretcher", StringComparison.OrdinalIgnoreCase)) return "Stretcher";
if (text.Contains("body bag", StringComparison.OrdinalIgnoreCase)) return "BodyBag"; if (text.Contains("body bag", StringComparison.OrdinalIgnoreCase)) return "BodyBag";
if (text.Contains("bottle", StringComparison.OrdinalIgnoreCase)) return "Bottle"; if (text.Contains("bottle", StringComparison.OrdinalIgnoreCase)) return "Bottle";
if (ContainsAny(text, "money", "cash", "coins", "banknote")) return "Money"; if (ContainsAny(text, "money", "cash", "coins", "banknote")) return "Money";
if (ContainsAny(text, "scarf", "scarves")) return "Clothing"; 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, "ring", "necklace", "jewellery", "jewelry")) return "Jewellery";
if (ContainsAny(text, "gun", "pistol", "rifle", "weapon")) return "Weapon"; if (ContainsAny(text, "gun", "pistol", "rifle", "weapon")) return "Weapon";
if (ContainsAny(padded, " hatchback ", " vw golf ", " volkswagen golf ", " golf gti ", " ford fiesta ", " fiesta ")) return "Hatchback"; if (ContainsAny(padded, " hatchback ", " vw golf ", " volkswagen golf ", " golf gti ", " ford fiesta ", " fiesta ")) return "Hatchback";

View File

@ -2187,7 +2187,14 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
{ {
var value = $"{name} {location?.LocationType} {location?.GenericRoomType} {setting?.LocationType}".ToLowerInvariant(); var value = $"{name} {location?.LocationType} {location?.GenericRoomType} {setting?.LocationType}".ToLowerInvariant();
var type = StoryIntelligenceIllustrationCompatibility.LocationType(value); 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 is "Kitchen" or "Bedroom" or "LivingRoom" or "FlatInterior" or "Hallway") return "loc-small-flat-interior";
if (type == "Office") return "loc-domestic-desk"; if (type == "Office") return "loc-domestic-desk";
if (type == "Stairwell") return "loc-narrow-stairwell"; if (type == "Stairwell") return "loc-narrow-stairwell";
@ -2200,12 +2207,22 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
{ {
var value = $"{name} {asset.AssetType} {asset.Status}".ToLowerInvariant(); var value = $"{name} {asset.AssetType} {asset.Status}".ToLowerInvariant();
var type = StoryIntelligenceIllustrationCompatibility.AssetType(value); var type = StoryIntelligenceIllustrationCompatibility.AssetType(value);
if (type is "Letter" or "Note" or "Document") return "asset-folded-letter"; if (type is "Letter" or "Note" or "Document" or "Certificate" or "Passport" or "DrivingLicence") return "asset-document";
if (type is "Notebook" or "Book") return "asset-worn-notebook"; 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 == "Car") return "asset-red-classic-car";
if (type == "Keys") return "asset-old-keys"; if (type == "Keys") return "asset-old-keys";
if (type == "Telephone") return "asset-phone-recorder"; if (type == "Telephone") return "asset-phone-recorder";
if (type == "Photograph") return "asset-photo-print"; 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"; if (type == "Rucksack") return "asset-rucksack";
return string.Empty; return string.Empty;
} }
@ -2234,6 +2251,19 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
"loc-narrow-stairwell" => $"{FallbackRoot}/location-stairwell.svg", "loc-narrow-stairwell" => $"{FallbackRoot}/location-stairwell.svg",
"loc-wet-car-park" => $"{FallbackRoot}/location-car-park.svg", "loc-wet-car-park" => $"{FallbackRoot}/location-car-park.svg",
"loc-estate-house-exterior" => $"{FallbackRoot}/location-doweries.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" _ => $"{FallbackRoot}/location-flat.svg"
}; };
@ -2241,10 +2271,21 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
=> AssetLibraryCode(name, new SceneIntelligenceAsset()) switch => AssetLibraryCode(name, new SceneIntelligenceAsset()) switch
{ {
"asset-folded-letter" => $"{FallbackRoot}/asset-letter.svg", "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-worn-notebook" => $"{FallbackRoot}/asset-notebook.svg",
"asset-book" => $"{FallbackRoot}/asset-book.svg",
"asset-red-classic-car" => $"{FallbackRoot}/asset-red-tr6.svg", "asset-red-classic-car" => $"{FallbackRoot}/asset-red-tr6.svg",
"asset-old-keys" => $"{FallbackRoot}/asset-keys.svg", "asset-old-keys" => $"{FallbackRoot}/asset-keys.svg",
"asset-phone-recorder" => $"{FallbackRoot}/asset-phone.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" _ => $"{FallbackRoot}/asset-generic.svg"
}; };

View File

@ -286,6 +286,48 @@ input:focus-visible {
transform: translateX(-50%); 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 { .story-exp-shell {
display: grid; display: grid;
grid-template-columns: minmax(180px, 0.46fr) minmax(820px, 2.72fr) minmax(188px, 0.48fr); grid-template-columns: minmax(180px, 0.46fr) minmax(820px, 2.72fr) minmax(188px, 0.48fr);
@ -301,7 +343,15 @@ input:focus-visible {
gap: 14px; gap: 14px;
max-height: calc(100vh - 126px); max-height: calc(100vh - 126px);
min-height: 0; 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 { .story-exp-left {
@ -331,18 +381,18 @@ input:focus-visible {
.story-exp-progress, .story-exp-progress,
.story-exp-panel { .story-exp-panel {
padding: 16px; padding: 11px;
} }
.story-exp-panel { .story-exp-panel {
display: grid; display: grid;
grid-template-rows: auto minmax(0, 1fr); grid-template-rows: auto auto;
min-height: 0; min-height: 0;
overflow: hidden; overflow: hidden;
} }
.story-exp-panel--compact { .story-exp-panel--compact {
padding: 12px; padding: 9px;
} }
.story-exp-progress-ring { .story-exp-progress-ring {
@ -454,38 +504,39 @@ input:focus-visible {
} }
.story-exp-panel h2 { .story-exp-panel h2 {
margin: 0 0 12px; margin: 0 0 7px;
font-size: 1rem; font-size: 0.88rem;
letter-spacing: 0.01em; letter-spacing: 0.01em;
} }
.story-exp-list, .story-exp-list,
.story-exp-feed { .story-exp-feed {
display: grid; display: grid;
gap: 8px; gap: 5px;
margin: 0; margin: 0;
padding: 0; padding: 0;
list-style: none; list-style: none;
min-height: 0; min-height: 0;
overflow: auto; overflow: visible;
scrollbar-width: thin;
} }
.story-exp-list li, .story-exp-list li,
.story-exp-feed li, .story-exp-feed li,
.story-exp-insight { .story-exp-insight {
border: 1px solid rgba(255, 255, 255, 0.065); border: 1px solid rgba(255, 255, 255, 0.065);
border-radius: 14px; border-radius: 9px;
padding: 10px 11px; padding: 7px 8px;
background: rgba(255, 255, 255, 0.035); background: rgba(255, 255, 255, 0.035);
color: var(--story-soft); color: var(--story-soft);
min-width: 0;
overflow-wrap: anywhere;
} }
.story-exp-list li { .story-exp-list li {
display: grid; display: grid;
grid-template-columns: 18px 1fr; grid-template-columns: 14px 1fr;
align-items: start; align-items: start;
gap: 8px; gap: 6px;
} }
.story-exp-list li span { .story-exp-list li span {
@ -701,7 +752,7 @@ input:focus-visible {
.story-exp-characters { .story-exp-characters {
position: relative; position: relative;
z-index: 90; z-index: 120;
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
@ -713,13 +764,13 @@ 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) + 90); z-index: calc(var(--z, 3) + 120);
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;
} }
.story-character.is-pov { .story-character.is-pov {
z-index: 48; z-index: 190;
} }
.story-character.is-context .story-character__image { .story-character.is-context .story-character__image {
@ -760,58 +811,28 @@ input:focus-visible {
.story-character__text { .story-character__text {
position: absolute; position: absolute;
z-index: 180; z-index: 180;
top: calc(var(--node-size, 150px) + 9px); top: calc(var(--node-size, 150px) + 8px);
left: 50%; left: 50%;
display: grid; display: grid;
justify-items: center; justify-items: center;
gap: 2px; gap: 1px;
width: min(188px, calc(var(--node-size, 150px) + 58px)); width: min(188px, calc(var(--node-size, 150px) + 58px));
max-width: calc(100vw - 32px); max-width: calc(100vw - 32px);
margin-left: 0; margin-left: 0;
border: 1px solid rgba(151, 185, 218, 0.1); border: 1px solid rgba(151, 185, 218, 0.16);
border-radius: 10px; border-radius: 9px;
padding: 5px 7px 6px; 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); 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%);
} }
.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 { .story-character[data-layout-role="context"] .story-character__text {
width: min(132px, calc(var(--node-size, 150px) + 34px)); width: min(132px, calc(var(--node-size, 150px) + 34px));
left: 50%; left: 50%;
top: calc(100% + 7px); top: calc(var(--node-size, 150px) + 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%);
} }
.story-character__text strong { .story-character__text strong {
@ -972,7 +993,7 @@ input:focus-visible {
.story-exp-ribbon-wrap { .story-exp-ribbon-wrap {
display: grid; display: grid;
grid-template-rows: minmax(104px, auto) 56px; grid-template-rows: auto minmax(104px, auto) 56px;
gap: 12px; gap: 12px;
min-width: 0; min-width: 0;
border: 1px solid rgba(151, 185, 218, 0.1); border: 1px solid rgba(151, 185, 218, 0.1);
@ -983,6 +1004,47 @@ input:focus-visible {
backdrop-filter: blur(14px); 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 { .story-exp-ribbon {
position: relative; position: relative;
display: flex; display: flex;
@ -1117,43 +1179,44 @@ input:focus-visible {
.story-exp-insight-list { .story-exp-insight-list {
display: grid; display: grid;
gap: 9px; gap: 5px;
min-height: 0; min-height: 0;
overflow: auto; overflow: visible;
scrollbar-width: thin;
} }
.story-exp-insight { .story-exp-insight {
display: grid; display: grid;
gap: 4px; gap: 2px;
border-color: rgba(255, 255, 255, 0.055); border-color: rgba(255, 255, 255, 0.055);
background: background:
linear-gradient(180deg, rgba(255, 255, 255, 0.038), rgba(255, 255, 255, 0.018)); 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); box-shadow: 0 16px 38px rgba(0, 0, 0, 0.16);
transition: opacity 560ms ease, transform 700ms cubic-bezier(.16, .84, .28, 1); 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 { .story-exp-panel--compact .story-exp-insight {
padding: 8px 9px; padding: 6px 7px;
} }
.story-exp-panel--compact .story-exp-insight p { .story-exp-panel--compact .story-exp-insight p {
display: -webkit-box; display: -webkit-box;
overflow: hidden; overflow: hidden;
margin: 0; margin: 0;
font-size: 0.76rem; font-size: 0.72rem;
line-height: 1.25; line-height: 1.16;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
-webkit-line-clamp: 2; -webkit-line-clamp: 2;
} }
.story-exp-more { .story-exp-more {
border: 1px solid rgba(151, 185, 218, 0.12); border: 1px solid rgba(151, 185, 218, 0.12);
border-radius: 10px; border-radius: 8px;
padding: 7px 9px; padding: 5px 7px;
background: rgba(255, 255, 255, 0.035); background: rgba(255, 255, 255, 0.035);
color: var(--story-soft); color: var(--story-soft);
font-size: 0.78rem; font-size: 0.72rem;
font-weight: 800; font-weight: 800;
text-align: left; text-align: left;
} }
@ -1165,10 +1228,12 @@ input:focus-visible {
.story-exp-insight strong { .story-exp-insight strong {
color: var(--story-text); color: var(--story-text);
font-size: 0.92rem; font-size: 0.82rem;
line-height: 1.12;
} }
.story-exp-insight span { .story-exp-insight span {
display: none;
color: var(--story-muted); color: var(--story-muted);
font-size: 0.78rem; font-size: 0.78rem;
font-weight: 800; font-weight: 800;
@ -1211,8 +1276,56 @@ input:focus-visible {
text-transform: uppercase; text-transform: uppercase;
} }
.story-exp-controls input { .story-exp-controls select {
width: 104px; 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 { .story-exp-diagnostics {

View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 180" role="img" aria-label="Bag or wallet">
<rect width="180" height="180" rx="28" fill="#081524"/>
<rect x="38" y="68" width="104" height="70" rx="12" fill="#473326" stroke="#d7a85d" stroke-width="5"/>
<path d="M65 70c0-24 50-24 50 0" fill="none" stroke="#d9edf5" stroke-width="7" stroke-linecap="round"/>
<path d="M100 102h28" stroke="#f2c46b" stroke-width="7" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 460 B

View File

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 180" role="img" aria-label="Bench">
<rect width="180" height="180" rx="28" fill="#081524"/>
<path d="M36 82h108M46 108h88" stroke="#d7a85d" stroke-width="13" stroke-linecap="round"/>
<path d="M52 108v36M128 108v36M40 144h104" stroke="#7dc1ff" stroke-width="7" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 355 B

View File

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 180" role="img" aria-label="Book">
<rect width="180" height="180" rx="28" fill="#071321"/>
<path d="M46 28h78c8 0 14 6 14 14v110H58c-7 0-12-5-12-12z" fill="#23384c"/>
<path d="M58 28v124" stroke="#d7a85d" stroke-width="8"/>
<path d="M76 56h42M76 76h32" stroke="#d9edf5" stroke-width="6" stroke-linecap="round"/>
<path d="M58 152h80" stroke="#6fa8cc" stroke-width="6" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 466 B

View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 180" role="img" aria-label="Bottle">
<rect width="180" height="180" rx="28" fill="#081524"/>
<path d="M76 30h28v36c15 8 23 21 23 40v36c0 8-7 14-15 14H68c-8 0-15-6-15-14v-36c0-19 8-32 23-40z" fill="#2c6f78" stroke="#7dc1ff" stroke-width="5"/>
<path d="M74 100h32" stroke="#d7a85d" stroke-width="13" stroke-linecap="round"/>
<path d="M74 31h32" stroke="#d9edf5" stroke-width="7" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 474 B

View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 180" role="img" aria-label="Clock">
<rect width="180" height="180" rx="28" fill="#081524"/>
<circle cx="90" cy="90" r="55" fill="#d9edf5" stroke="#d7a85d" stroke-width="7"/>
<path d="M90 56v36l24 16" stroke="#20384d" stroke-width="7" stroke-linecap="round"/>
<circle cx="90" cy="90" r="5" fill="#20384d"/>
</svg>

After

Width:  |  Height:  |  Size: 378 B

View File

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 180" role="img" aria-label="Document">
<rect width="180" height="180" rx="28" fill="#091625"/>
<rect x="44" y="28" width="92" height="124" rx="8" fill="#d9edf5"/>
<path d="M112 28v30h24" fill="#b9ced8"/>
<path d="M58 74h64M58 94h52M58 114h60" stroke="#20384d" stroke-width="6" stroke-linecap="round"/>
<rect x="58" y="42" width="32" height="20" rx="4" fill="#d7a85d"/>
</svg>

After

Width:  |  Height:  |  Size: 443 B

View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 180" role="img" aria-label="Envelope">
<rect width="180" height="180" rx="28" fill="#081524"/>
<rect x="28" y="50" width="124" height="82" rx="8" fill="#d9edf5"/>
<path d="M31 56 90 99l59-43M31 128l42-40M149 128l-42-40" fill="none" stroke="#20384d" stroke-width="6" stroke-linecap="round"/>
<path d="M112 72h22" stroke="#d7a85d" stroke-width="7" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 443 B

View File

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 180" role="img" aria-label="Environmental object">
<rect width="180" height="180" rx="28" fill="#071321"/>
<path d="M42 134h96" stroke="#d7a85d" stroke-width="8" stroke-linecap="round"/>
<path d="M69 134V74h42v60" fill="#142b3f" stroke="#7dc1ff" stroke-width="6"/>
<path d="M54 76c10-34 62-34 72 0" fill="#2f6f53"/>
<path d="M90 46v34" stroke="#8a6841" stroke-width="8" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 469 B

View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 180" role="img" aria-label="Parcel">
<rect width="180" height="180" rx="28" fill="#071321"/>
<rect x="38" y="50" width="104" height="92" rx="8" fill="#8a6841" stroke="#d7a85d" stroke-width="5"/>
<path d="M90 51v90M39 84h102" stroke="#f2d28a" stroke-width="7"/>
<path d="M58 114h32" stroke="#20384d" stroke-width="6" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 413 B

View File

@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 180" role="img" aria-label="Photograph">
<rect width="180" height="180" rx="28" fill="#071321"/>
<rect x="34" y="36" width="112" height="108" rx="8" fill="#d9edf5"/>
<rect x="46" y="50" width="88" height="68" rx="4" fill="#18344e"/>
<circle cx="112" cy="68" r="10" fill="#d7a85d"/>
<path d="M50 116 80 88l20 18 14-13 20 23" fill="#6fa8cc"/>
<path d="M58 132h64" stroke="#20384d" stroke-width="5" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 497 B

View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 180" role="img" aria-label="Traffic sign">
<rect width="180" height="180" rx="28" fill="#071321"/>
<path d="M90 28 144 82l-54 54-54-54z" fill="#d7a85d" stroke="#f2d28a" stroke-width="5"/>
<path d="M90 65v36M72 83h36" stroke="#20384d" stroke-width="9" stroke-linecap="round"/>
<path d="M90 136v26" stroke="#d9edf5" stroke-width="8" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 428 B

View File

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 240" role="img" aria-label="Bathroom">
<rect width="240" height="240" fill="#0b1d2d"/>
<path d="M36 72h168v124H36z" fill="#142d42"/>
<path d="M58 124h90v34c0 22-18 40-40 40H98c-22 0-40-18-40-40z" fill="#d9edf5" opacity=".92"/>
<path d="M52 116h102" stroke="#f2c46b" stroke-width="7" stroke-linecap="round"/>
<path d="M164 96h32v86h-32z" fill="#c4d8df"/>
<path d="M170 91h20" stroke="#7dc1ff" stroke-width="6" stroke-linecap="round"/>
<path d="M58 57h55v40H58z" fill="#203d55" stroke="#7dc1ff" stroke-width="3"/>
</svg>

After

Width:  |  Height:  |  Size: 590 B

View File

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 240" role="img" aria-label="Building">
<rect width="240" height="240" fill="#081524"/>
<path d="M48 70h144v126H48z" fill="#172b3f" stroke="#6fa8cc" stroke-width="3"/>
<path d="M72 92h28v28H72zm70 0h28v28h-28zM72 140h28v28H72zm70 0h28v28h-28z" fill="#d7a85d" opacity=".68"/>
<path d="M38 70h164L120 36z" fill="#233c52"/>
<path d="M108 196v-42h24v42" fill="#0b1726"/>
</svg>

After

Width:  |  Height:  |  Size: 440 B

View File

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 240" role="img" aria-label="Driving test centre">
<rect width="240" height="240" fill="#071321"/>
<path d="M38 70h164v92H38z" fill="#183149" stroke="#6fa8cc" stroke-width="3"/>
<path d="M62 98h45v32H62zm70 0h45v32h-45z" fill="#0d1d2c"/>
<path d="M48 162h144v28H48z" fill="#26384a"/>
<path d="M70 190c14-20 86-20 100 0" stroke="#d7a85d" stroke-width="6" fill="none" stroke-linecap="round"/>
<path d="M82 55h76" stroke="#f2c46b" stroke-width="8" stroke-linecap="round"/>
<text x="120" y="153" text-anchor="middle" fill="#d9edf5" font-family="Arial, sans-serif" font-size="18" font-weight="700">TEST</text>
</svg>

After

Width:  |  Height:  |  Size: 682 B

View File

@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 240" role="img" aria-label="Garage workshop">
<rect width="240" height="240" fill="#0a1420"/>
<path d="M34 72h172v116H34z" fill="#172738"/>
<path d="M54 104h132v84H54z" fill="#101b28" stroke="#6fa8cc" stroke-width="3"/>
<path d="M54 126h132M54 148h132M54 170h132" stroke="#294863" stroke-width="4"/>
<path d="M66 92h58" stroke="#d7a85d" stroke-width="7" stroke-linecap="round"/>
<path d="M144 88l26 26M170 88l-26 26" stroke="#d9edf5" stroke-width="6" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 552 B

View File

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 240" role="img" aria-label="Kitchen">
<rect width="240" height="240" fill="#0b1726"/>
<path d="M30 66h180v126H30z" fill="#172b3b"/>
<path d="M46 86h54v90H46zm116 0h32v90h-32z" fill="#d7a85d" opacity=".65"/>
<path d="M103 86h56v90h-56z" fill="#20384c"/>
<path d="M112 98h38v30h-38z" fill="#0d1c2a" stroke="#7dc1ff" stroke-width="3"/>
<path d="M42 176h156M56 63v-20h75v20" stroke="#d9edf5" stroke-width="5" stroke-linecap="round"/>
<circle cx="177" cy="116" r="5" fill="#f2c46b"/>
</svg>

After

Width:  |  Height:  |  Size: 557 B

View File

@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 240" role="img" aria-label="Road">
<defs><linearGradient id="sky" x1="0" x2="0" y1="0" y2="1"><stop stop-color="#153454"/><stop offset="1" stop-color="#071323"/></linearGradient></defs>
<rect width="240" height="240" fill="url(#sky)"/>
<path d="M0 155h240v85H0z" fill="#203142"/>
<path d="M92 240 114 112h12l24 128z" fill="#303946"/>
<path d="M120 126v26M120 174v30M120 225v15" stroke="#f2c46b" stroke-width="4" stroke-linecap="round"/>
<path d="M0 155c42-12 75-14 116-16 52-2 83-9 124-24v40c-50 14-92 18-137 19-40 1-69 5-103 16z" fill="#2f4f4f" opacity=".55"/>
<path d="M24 119h42v30H24zm145-20h39v34h-39z" fill="#101e30" stroke="#6fa8cc" stroke-width="2"/>
<path d="M31 149h171" stroke="#d7a85d" stroke-width="2" opacity=".55"/>
</svg>

After

Width:  |  Height:  |  Size: 811 B

View File

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 240" role="img" aria-label="Vehicle interior">
<rect width="240" height="240" fill="#071321"/>
<path d="M24 76c18-24 174-24 192 0l-18 65H42z" fill="#12263b" stroke="#6fa8cc" stroke-width="3"/>
<path d="M52 88h136l-13 39H65z" fill="#203a52"/>
<path d="M28 141h184v54H28z" fill="#0e1d2c"/>
<circle cx="82" cy="165" r="25" fill="none" stroke="#d7a85d" stroke-width="7"/>
<path d="M132 155h52M132 174h38M55 113l-24 17M185 113l24 17" stroke="#7dc1ff" stroke-width="5" stroke-linecap="round"/>
<path d="M70 207h100" stroke="#304d68" stroke-width="10" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 648 B

View File

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 240" role="img" aria-label="Waiting room">
<rect width="240" height="240" fill="#081524"/>
<path d="M32 64h176v130H32z" fill="#14283a"/>
<path d="M54 122h132v42H54z" fill="#233b50"/>
<path d="M62 96h38v68M110 96h38v68M158 96h20v68" stroke="#7dc1ff" stroke-width="7" stroke-linecap="round"/>
<circle cx="178" cy="67" r="24" fill="#101e30" stroke="#d7a85d" stroke-width="4"/>
<path d="M178 52v17l12 8" stroke="#d9edf5" stroke-width="4" stroke-linecap="round"/>
<path d="M48 194h144" stroke="#d7a85d" stroke-width="4" opacity=".7"/>
</svg>

After

Width:  |  Height:  |  Size: 608 B

View File

@ -8,7 +8,9 @@
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const realMode = root.dataset.mode === "real"; const realMode = root.dataset.mode === "real";
const replayMode = root.dataset.mode === "replay";
const simulationMode = root.dataset.mode === "simulation"; const simulationMode = root.dataset.mode === "simulation";
const controllableMode = replayMode || simulationMode;
const snapshotUrl = root.dataset.snapshotUrl || ""; const snapshotUrl = root.dataset.snapshotUrl || "";
const failedImageUrls = new Set(); const failedImageUrls = new Set();
const preloadedImageUrls = new Set(); const preloadedImageUrls = new Set();
@ -73,7 +75,14 @@
diagnosticsRefresh: root.querySelector("[data-diagnostics-refresh]"), diagnosticsRefresh: root.querySelector("[data-diagnostics-refresh]"),
diagnosticsBuild: root.querySelector("[data-diagnostics-build]"), diagnosticsBuild: root.querySelector("[data-diagnostics-build]"),
diagnosticsOperations: root.querySelector("[data-diagnostics-operations]"), 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) { if (!scenes.length) {
@ -97,6 +106,7 @@
updateProgress(current); updateProgress(current);
updateSceneCopy(current); updateSceneCopy(current);
updateReplayBrowser(current);
const layout = sceneLayout(current); const layout = sceneLayout(current);
reconcileCharacters(current, previous, layout); reconcileCharacters(current, previous, layout);
reconcileLocation(current, previous); reconcileLocation(current, previous);
@ -149,21 +159,75 @@
} }
function updateProgress(scene) { function updateProgress(scene) {
text(dom.stageLabel, scene.stage); const replayText = replayMode ? replayStatusText(scene) : scene.stage;
text(dom.stageLabel, replayText);
text(dom.currentScene, scene.sceneNumber); text(dom.currentScene, scene.sceneNumber);
animateNumber(dom.progressPercent, scene.progressPercent, "%"); animateNumber(dom.progressPercent, scene.progressPercent, "%");
text(dom.elapsedTime, scene.elapsedTime || "Calculating"); text(dom.elapsedTime, scene.elapsedTime || "Calculating");
text(dom.estimatedRemaining, scene.estimatedRemaining); text(dom.estimatedRemaining, scene.estimatedRemaining);
text(dom.chapterCount, `${scene.chaptersAnalysed} of ${scene.chapterTotal}`); text(dom.chapterCount, `${scene.chaptersAnalysed} of ${scene.chapterTotal}`);
text(dom.sceneCount, `${scene.scenesAnalysed} of ${scene.sceneTotal}`); 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`); 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) { function updateSceneCopy(scene) {
text(dom.sceneSummary, scene.summary); 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) => `<option value="${escapeHtml(chapter.key)}">${escapeHtml(chapter.label)}</option>`);
const gapOptions = failedChapters
.map((chapter, index) => `<option value="failed-${index}" disabled>${escapeHtml(chapter)}</option>`);
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) => `<option value="${item.index}">Scene ${item.scene.sceneNumber} - ${escapeHtml(compactPhrase(item.scene.title || item.scene.summary || "Saved scene", 58))}</option>`)
.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) { function setEntityImage(image, url, resolution, altText) {
if (!image) return; if (!image) return;
image.alt = altText; image.alt = altText;
@ -255,7 +319,6 @@
node.style.setProperty("--opacity", String(position.opacity)); node.style.setProperty("--opacity", String(position.opacity));
node.style.setProperty("--z", String(position.z)); node.style.setProperty("--z", String(position.z));
node.dataset.layoutRole = position.layoutRole; node.dataset.layoutRole = position.layoutRole;
node.dataset.labelSide = position.labelSide || "";
setEntityImage(node.querySelector("img"), character.imagePath, character.imageResolution, `${character.name} prototype portrait`); setEntityImage(node.querySelector("img"), character.imagePath, character.imageResolution, `${character.name} prototype portrait`);
text(node.querySelector("[data-character-name]"), character.name); text(node.querySelector("[data-character-name]"), character.name);
const label = conciseCharacterLabel(character, character.id === scene.povCharacterId); const label = conciseCharacterLabel(character, character.id === scene.povCharacterId);
@ -356,8 +419,10 @@
} }
function updateKnowledgeThreads(scene) { function updateKnowledgeThreads(scene) {
renderInsights(dom.knowledge, scene.knowledgeThreads); renderInsights(dom.knowledge, (scene.knowledgeThreads || []).slice(0, 3));
renderInsights(dom.observations, scene.observations); 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) { function updateSceneRibbon(scene) {
@ -427,8 +492,8 @@
} }
function updateFeeds(scene) { function updateFeeds(scene) {
renderList(dom.discoveries, scene.latestDiscoveries); renderList(dom.discoveries, (scene.latestDiscoveries || []).slice(0, 4));
renderList(dom.activityFeed, scene.activityFeed); renderList(dom.activityFeed, (scene.activityFeed || []).slice(0, 4));
} }
function renderInsights(container, items) { function renderInsights(container, items) {
@ -439,15 +504,23 @@
node = document.createElement("article"); node = document.createElement("article");
node.className = "story-exp-insight is-entering"; node.className = "story-exp-insight is-entering";
node.dataset.insightId = item.id; node.dataset.insightId = item.id;
node.innerHTML = `<span></span><strong></strong><p></p>`; node.innerHTML = `<strong></strong><p></p>`;
container.append(node); container.append(node);
settleEntering(node); settleEntering(node);
} }
node.dataset.tone = item.tone || ""; node.dataset.tone = item.tone || "";
text(node.querySelector("span"), item.tone || "Signal");
text(node.querySelector("strong"), item.title); text(node.querySelector("strong"), item.title);
text(node.querySelector("p"), item.detail); 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) => { 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) { function renderOverflowSummary(container, hiddenCount, noun) {
if (!container) return; if (!container) return;
let node = container.querySelector("[data-insight-overflow]"); let node = container.querySelector("[data-insight-overflow]");
@ -690,7 +775,7 @@
opacity: 1, opacity: 1,
z: 7, z: 7,
layoutRole: "pov", layoutRole: "pov",
labelSide: "pov" labelSide: "below"
}); });
} }
@ -721,7 +806,7 @@
opacity: contextual ? 0.76 : 0.94, opacity: contextual ? 0.76 : 0.94,
z: contextual ? 3 : 5, z: contextual ? 3 : 5,
layoutRole: contextual ? "context" : slot.role, 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); placed.push(position);
positions.set(character.id, position); positions.set(character.id, position);
@ -1096,10 +1181,15 @@
function startTimer() { function startTimer() {
if (!scenes.length) return; if (!scenes.length) return;
stopTimer(); stopTimer();
if (!state.playing || !simulationMode) return; if (!state.playing || !controllableMode) return;
state.timer = window.setInterval(() => { 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); transitionToNextState(next);
if (replayMode && next >= scenes.length - 1) {
state.playing = false;
text(dom.play, "Play");
stopTimer();
}
}, state.intervalMs); }, state.intervalMs);
} }
@ -1307,6 +1397,7 @@
if (key === "pollingActive") return state.pollTimer ? "active" : "stopped"; if (key === "pollingActive") return state.pollTimer ? "active" : "stopped";
if (key === "pollingStoppedReason") return state.pollingStoppedReason || "none"; if (key === "pollingStoppedReason") return state.pollingStoppedReason || "none";
if (key === "currentPollingInterval") return realMode ? `${document.hidden ? 15000 : 7000}ms` : `${state.intervalMs}ms`; 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(); if (key === "latestSnapshotAge") return latestSnapshotAge();
const value = diagnostics[key]; const value = diagnostics[key];
return value === null || value === undefined || value === "" ? "none" : value; 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, "\\$&"); return window.CSS?.escape ? window.CSS.escape(value) : String(value).replace(/[^a-zA-Z0-9_-]/g, "\\$&");
} }
root.querySelector("[data-control='restart']")?.addEventListener("click", () => { function goToReplayIndex(index, restartPlayback) {
if (!simulationMode) return; 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); transitionToNextState(0);
startTimer(); startTimer();
}); }));
root.querySelector("[data-control='previous']")?.addEventListener("click", () => { root.querySelectorAll("[data-control='previous']").forEach((button) => button.addEventListener("click", () => {
if (!simulationMode) return; if (!controllableMode) return;
transitionToNextState(state.index <= 0 ? scenes.length - 1 : state.index - 1); transitionToNextState(state.index <= 0 ? (replayMode ? 0 : scenes.length - 1) : state.index - 1);
startTimer(); startTimer();
}); }));
root.querySelector("[data-control='next']")?.addEventListener("click", () => { root.querySelectorAll("[data-control='next']").forEach((button) => button.addEventListener("click", () => {
if (!simulationMode) return; if (!controllableMode) return;
transitionToNextState(state.index >= scenes.length - 1 ? 0 : state.index + 1); transitionToNextState(state.index >= scenes.length - 1 ? (replayMode ? scenes.length - 1 : 0) : state.index + 1);
startTimer(); 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", () => { dom.play?.addEventListener("click", () => {
if (!simulationMode) return; if (!controllableMode) return;
state.playing = !state.playing; state.playing = !state.playing;
text(dom.play, state.playing ? "Pause" : "Play"); text(dom.play, state.playing ? "Pause" : "Play");
if (state.playing) startTimer(); else stopTimer(); if (state.playing) startTimer(); else stopTimer();
if (replayMode) updateProgress(loadSceneState(state.index));
}); });
dom.speed?.addEventListener("input", () => { dom.speed?.addEventListener("input", () => {
if (!simulationMode) return; if (!controllableMode) return;
state.intervalMs = Number.parseInt(dom.speed.value, 10) || 6200; state.intervalMs = Number.parseInt(dom.speed.value, 10) || 6200;
startTimer(); 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", () => { document.addEventListener("visibilitychange", () => {
if (!realMode || state.terminal) return; if (!realMode || state.terminal) return;
startPolling(); startPolling();
@ -1454,6 +1575,7 @@
} }
if (scenes.length) { if (scenes.length) {
setupReplayBrowser();
transitionToNextState(realMode ? authoritativeSceneIndex(scenes) : 0); transitionToNextState(realMode ? authoritativeSceneIndex(scenes) : 0);
startTimer(); startTimer();
} else { } else {