From ca73c9e6f311ef20a0d9c6523855be8b4802c21b Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Mon, 13 Jul 2026 19:23:44 +0000 Subject: [PATCH] Phase 21J.3 - Illustration Style and Prototype Final Polish --- .../Services/IllustrationLibraryServices.cs | 14 ++-- ...IntelligenceIllustrationMatchingService.cs | 10 ++- ...ntelligenceVisualisationSnapshotService.cs | 84 ++++++++++++++++--- ...ase21J3_DemandIllustrationStyleMarkers.sql | 28 +++++++ ...telligenceExperiencePrototypeViewModels.cs | 7 ++ ...toryIntelligenceIllustrationLibrary.cshtml | 11 +++ .../StoryIntelligenceExperience.cshtml | 28 +++++++ ...tory-intelligence-experience-prototype.css | 78 ++++++++++------- ...story-intelligence-experience-prototype.js | 32 ++++--- 9 files changed, 226 insertions(+), 66 deletions(-) create mode 100644 PlotLine/Sql/140_Phase21J3_DemandIllustrationStyleMarkers.sql diff --git a/PlotLine/Services/IllustrationLibraryServices.cs b/PlotLine/Services/IllustrationLibraryServices.cs index 5a830cc..bb021e1 100644 --- a/PlotLine/Services/IllustrationLibraryServices.cs +++ b/PlotLine/Services/IllustrationLibraryServices.cs @@ -16,7 +16,7 @@ public interface IIllustrationPromptBuilder public sealed class IllustrationPromptBuilder : IIllustrationPromptBuilder { - public const string CurrentTemplateVersion = "21D.2"; + public const string CurrentTemplateVersion = "21J.3"; private static readonly JsonSerializerOptions JsonOptions = new() { @@ -73,14 +73,15 @@ public sealed class IllustrationPromptBuilder : IIllustrationPromptBuilder Requirements: - representative visual identifier only, not a canonical depiction of any manuscript - modern cinematic digital illustration with premium narrative game concept art quality - - vivid natural colour, contemporary polished rendering, realistic anatomy and believable proportions - - bright balanced lighting, clear facial illumination where faces are present, and strong local contrast + - subdued atmospheric background treatment with contemporary polished rendering, realistic anatomy and believable proportions + - balanced cinematic lighting, clear facial illumination where faces are present, and strong local contrast - crisp subject definition with strong foreground-background separation and clean readable silhouettes - - natural but richer colour with bright blues, reds, greens, and warm skin tones where appropriate + - natural controlled colour with rich but restrained accents and warm skin tones where appropriate - polished promotional artwork for a premium story-driven adventure game, not a literal photograph - subtle depth of field, clean modern rendering, and enough exposure to remain clear inside PlotDirector's dark navy interface - no visible text, captions, logos, watermarks, UI, signatures, or typography - no anime, cartoon, Pixar-like, caricature, celebrity likeness, or photoreal identity claim + - no bright flat studio backdrop, no plain colour-block background, no stock-photo lighting, and no cartoon simplicity - avoid private manuscript details, real names, excerpts, or recognisable copyrighted characters - avoid muddy olive and brown grading, heavy sepia tones, underexposure, excessive darkness, low-contrast lighting, flat colour, antique oil-painting texture, distressed canvas texture, rough brushwork, overly sombre default expressions, backgrounds that merge into the subject, and monochrome or near-monochrome results - square composition that still reads clearly as a small thumbnail @@ -100,7 +101,7 @@ public sealed class IllustrationPromptBuilder : IIllustrationPromptBuilder private static string CategoryRules(string category) => category switch { - IllustrationLibraryCategories.Character => "Square image; head-and-shoulders or upper body; one person only; face-forward enough to read at small size; naturally expressive; cleanly lit face; distinct from background; suitable for circular cropping; avoid dark collars vanishing into black backgrounds.", + IllustrationLibraryCategories.Character => "Square image; head-and-shoulders or upper body; one person only; face-forward enough to read at small size; naturally expressive; cleanly lit face; distinct from a subtle atmospheric story background; suitable for circular cropping; avoid bright flat studio backgrounds, plain colour blocks, dark collars vanishing into black backgrounds, and photographic headshot styling.", IllustrationLibraryCategories.Location => "One clear focal environment; vivid and readable; clearly lit and visually inviting even when dramatic; rich colour and depth; strong composition for circular and landscape crops; no prominent people; no text or readable signage; do not default every location to nighttime, rain-soaked, blue-black, ominous, or haunted-looking.", IllustrationLibraryCategories.Asset => "Square image; one dominant centred object; bright clean high-contrast presentation; crisp silhouette; clear separation from background; immediately recognisable at small size; entire object visible where practical.", _ => string.Empty @@ -219,7 +220,8 @@ public static class IllustrationStarterBatchDefinition { ["libraryPurpose"] = "Story Intelligence representative identifier", ["privacy"] = "generic-no-manuscript-data", - ["style"] = "editorial-stylised-realism" + ["style"] = "editorial-stylised-realism", + ["generationSource"] = "Starter batch" }; var common = new IllustrationGenerationSpecification diff --git a/PlotLine/Services/StoryIntelligenceIllustrationMatchingService.cs b/PlotLine/Services/StoryIntelligenceIllustrationMatchingService.cs index 8fb9180..dd45f30 100644 --- a/PlotLine/Services/StoryIntelligenceIllustrationMatchingService.cs +++ b/PlotLine/Services/StoryIntelligenceIllustrationMatchingService.cs @@ -238,23 +238,25 @@ public sealed class StoryIntelligenceIllustrationMatchingService( Title = $"{age} {presentation.ToLowerInvariant()} character", Description = "Demand-created reusable character illustration for Story Intelligence matching.", VisualSubject = $"{age} {presentation.ToLowerInvariant()} character with {hairLength.ToLowerInvariant()} {hairColour.ToLowerInvariant()} hair and {skinTone.ToLowerInvariant()} skin tone range.", - Composition = "clear head-and-shoulders portrait with bright readable face, clean background separation, suitable for circular cropping", - Mood = "neutral, story-ready, emotionally readable", + Composition = "clear head-and-shoulders cinematic portrait with readable face, subtle atmospheric story background, clean separation, suitable for circular cropping", + Mood = "neutral, story-ready, emotionally readable, subdued and cinematic", ApparentAgeBand = age, Presentation = presentation, HairColour = hairColour, HairLength = hairLength, SkinTone = skinTone, ClothingEra = "Contemporary", - ClothingStyle = "Plain reusable story wardrobe", + ClothingStyle = "Understated reusable story wardrobe, not studio fashion styling", Features = features, - Palette = ["clear blue", "warm skin tones", "fresh accent colour"], + Palette = ["dark navy atmosphere", "warm skin tones", "restrained amber accent"], MetadataTags = ["character", "demand-generated", "story-intelligence"], Metadata = new(StringComparer.OrdinalIgnoreCase) { ["libraryPurpose"] = "Story Intelligence demand-generated identifier", ["privacy"] = "generic-no-manuscript-data", ["style"] = "editorial-stylised-realism", + ["styleIntent"] = "match-approved-starter-library", + ["generationSource"] = "Demand generation", ["demandKey"] = demand.DemandKey, ["requestCount"] = demand.RequestCount.ToString("N0") } diff --git a/PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs b/PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs index a428f03..5dc8457 100644 --- a/PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs +++ b/PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs @@ -190,13 +190,21 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( parsedScenes); var sourceChapterCount = (await sources.ListChaptersAsync(session.BookID)).Count; var bookChapterTotal = Math.Max(1, Math.Max(sourceChapterCount, runSnapshots.Count)); - var overallProgressPercent = BookProgress(runSnapshots, bookChapterTotal, isTerminal: false); var completedChapterRuns = runSnapshots.Count(snapshot => IsSuccessfulTerminal(snapshot.Run.Status)); - var isTerminal = runSnapshots.All(snapshot => IsTerminal(snapshot.Run.Status)); - var isSuccessfullyComplete = runSnapshots.All(snapshot => IsSuccessfulTerminal(snapshot.Run.Status)); - overallProgressPercent = BookProgress(runSnapshots, bookChapterTotal, isSuccessfullyComplete); + var sessionSuccessfullyComplete = IsSuccessfulSession(session); + var allKnownRunsSuccessfullyComplete = runSnapshots.Count > 0 && runSnapshots.All(snapshot => IsSuccessfulTerminal(snapshot.Run.Status)); + var isSuccessfullyComplete = sessionSuccessfullyComplete || allKnownRunsSuccessfullyComplete; + var isTerminal = isSuccessfullyComplete || IsTerminalSession(session) || runSnapshots.All(snapshot => IsTerminal(snapshot.Run.Status)); + var calculatedVisualisationProgress = OverallProgress(parsedScenes, totalScenes, isSuccessfullyComplete); + var authoritativeImportProgress = BookProgress(runSnapshots, bookChapterTotal, isSuccessfullyComplete); + var displayedProgressReason = sessionSuccessfullyComplete + ? "Successful terminal import session" + : allKnownRunsSuccessfullyComplete + ? "All known chapter runs completed successfully" + : "Weighted chapter-run progress"; + var overallProgressPercent = authoritativeImportProgress; var overallElapsed = SessionElapsed(runSnapshots); - var overallRemaining = isTerminal ? "Ready to review" : EstimateSessionRemaining(runSnapshots, parsedScenes, totalScenes); + var overallRemaining = isSuccessfullyComplete ? "Ready to review" : isTerminal ? "Ready to review" : EstimateSessionRemaining(runSnapshots, parsedScenes, totalScenes); var overallStage = isSuccessfullyComplete ? "Analysis Complete" : isTerminal ? "Analysis stopped" : BackendStageDisplay(activeSnapshot.Run); var scenes = new List(); var nextSceneNumber = 1; @@ -240,7 +248,21 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( ChangeToken = SessionChangeToken(session, runSnapshots), IsTerminal = isTerminal, SnapshotMessage = SessionSnapshotMessage(session, runSnapshots), - Diagnostics = BuildSessionDiagnostics(session, runSnapshots, activeSnapshot, current, parsedScenes, totalPersistedResults, totalScenes, overallProgressPercent, storyMemory), + Diagnostics = BuildSessionDiagnostics( + session, + runSnapshots, + activeSnapshot, + current, + parsedScenes, + totalPersistedResults, + totalScenes, + overallProgressPercent, + authoritativeImportProgress, + calculatedVisualisationProgress, + bookChapterTotal, + completedChapterRuns, + displayedProgressReason, + storyMemory), ProjectName = DisplayTitle(session.ProjectName, "Untitled project"), BookTitle = DisplayTitle(session.BookDisplayTitle, "Untitled book"), Scenes = scenes @@ -761,6 +783,11 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( private static string SessionStatus(StoryIntelligenceBookPipelineState session, IReadOnlyList snapshots) { + if (IsSuccessfulSession(session)) + { + return StoryIntelligenceRunStatuses.Completed; + } + if (snapshots.Any(snapshot => !IsTerminal(snapshot.Run.Status))) { return StoryIntelligenceRunStatuses.Running; @@ -773,6 +800,11 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( private static string SessionSnapshotMessage(StoryIntelligenceBookPipelineState session, IReadOnlyList snapshots) { + if (IsSuccessfulSession(session)) + { + return "Book analysis complete and ready for review."; + } + var active = snapshots.FirstOrDefault(snapshot => !IsTerminal(snapshot.Run.Status)); if (active is not null) { @@ -1224,6 +1256,11 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( int totalPersistedResults, int totalScenes, int overallProgressPercent, + int authoritativeImportProgress, + int calculatedVisualisationProgress, + int totalChapters, + int completedChapters, + string displayedProgressReason, StoryMemory storyMemory) { var failed = snapshots.Sum(snapshot => snapshot.Parsed.Count(item => item.Scene is null)); @@ -1237,10 +1274,17 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( ActiveChapterRunId = activeSnapshot.Run.StoryIntelligenceRunID, SessionStatus = session.Status, OverallBookProgress = overallProgressPercent, + AuthoritativeImportProgress = authoritativeImportProgress, + CalculatedVisualisationProgress = calculatedVisualisationProgress, + TotalChapters = totalChapters, + CompletedChapters = completedChapters, CurrentChapter = activeSnapshot.Run.ChapterNumber.HasValue ? $"Chapter {activeSnapshot.Run.ChapterNumber:0.##}" : "Chapter unknown", CurrentScene = CurrentSceneText(activeSnapshot), CompletedScenes = parsedScenes, TotalScenes = totalScenes, + ParsedScenes = parsedScenes, + TerminalRunSessionStatus = $"{session.Status}/{SessionStatus(session, snapshots)}", + DisplayedProgressReason = displayedProgressReason, ProgressSource = "ImportSession", CurrentBackendStage = BackendStageDisplay(activeSnapshot.Run), StoryMemoryCharacterCount = storyMemory.Characters.Count, @@ -1281,19 +1325,30 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( var warning = failed.Count == 0 ? null : $"{failed.Count:N0} scene result(s) skipped because they could not be parsed."; + var totalScenes = Math.Max(run.TotalDetectedScenes ?? 0, results.Count); + var completedSceneCount = run.CompletedScenes ?? completed.Count; + var runProgress = Progress(run, completedSceneCount, totalScenes); + var calculatedProgress = OverallProgress(completed.Count, totalScenes, IsSuccessfulTerminal(run.Status)); return new() { RequestedMode = "real", ActiveChapterRunId = run.StoryIntelligenceRunID, SessionStatus = run.Status, - OverallBookProgress = Progress(run, run.CompletedScenes ?? completed.Count, Math.Max(run.TotalDetectedScenes ?? 0, results.Count)), + OverallBookProgress = runProgress, + AuthoritativeImportProgress = runProgress, + CalculatedVisualisationProgress = calculatedProgress, + TotalChapters = 1, + CompletedChapters = IsSuccessfulTerminal(run.Status) ? 1 : 0, CurrentChapter = run.ChapterNumber.HasValue ? $"Chapter {run.ChapterNumber:0.##}" : "Chapter unknown", CurrentScene = run.TotalDetectedScenes.HasValue - ? $"Scene {Math.Clamp((run.CompletedScenes ?? completed.Count) + (IsTerminal(run.Status) ? 0 : 1), 1, run.TotalDetectedScenes.Value):N0} of {run.TotalDetectedScenes.Value:N0}" + ? $"Scene {Math.Clamp(completedSceneCount + (IsTerminal(run.Status) ? 0 : 1), 1, run.TotalDetectedScenes.Value):N0} of {run.TotalDetectedScenes.Value:N0}" : (current is null ? "Waiting" : $"Scene {SceneSortNumber(current):0.##}"), - CompletedScenes = run.CompletedScenes ?? completed.Count, - TotalScenes = Math.Max(run.TotalDetectedScenes ?? 0, results.Count), + CompletedScenes = completedSceneCount, + TotalScenes = totalScenes, + ParsedScenes = completed.Count, + TerminalRunSessionStatus = run.Status, + DisplayedProgressReason = IsSuccessfulTerminal(run.Status) ? "Successful terminal chapter run" : "Chapter run scene progress", ProgressSource = "ChapterRun", CurrentBackendStage = BackendStageDisplay(run), StoryMemoryCharacterCount = storyMemory.Characters.Count, @@ -1397,6 +1452,15 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( private static bool IsSuccessfulTerminal(string status) => status is StoryIntelligenceRunStatuses.Completed or StoryIntelligenceRunStatuses.CompletedWithWarnings; + private static bool IsSuccessfulSession(StoryIntelligenceBookPipelineState session) + => string.Equals(session.Status, StoryIntelligencePipelineStatuses.Complete, StringComparison.OrdinalIgnoreCase) + && (string.Equals(session.LastCompletedStage, StoryIntelligencePipelineStages.KnowledgeImport, StringComparison.OrdinalIgnoreCase) + || string.Equals(session.CurrentStage, StoryIntelligencePipelineStages.Complete, StringComparison.OrdinalIgnoreCase) + || session.CompletedUtc.HasValue); + + private static bool IsTerminalSession(StoryIntelligenceBookPipelineState session) + => IsSuccessfulSession(session); + private static int SceneNumber(ParsedSceneResult item) => item.Scene?.SceneReference?.SceneNumber.HasValue == true ? Convert.ToInt32(item.Scene.SceneReference.SceneNumber.Value) : item.Result.TemporarySceneNumber; diff --git a/PlotLine/Sql/140_Phase21J3_DemandIllustrationStyleMarkers.sql b/PlotLine/Sql/140_Phase21J3_DemandIllustrationStyleMarkers.sql new file mode 100644 index 0000000..4631953 --- /dev/null +++ b/PlotLine/Sql/140_Phase21J3_DemandIllustrationStyleMarkers.sql @@ -0,0 +1,28 @@ +/* +Phase 21J.3 demand illustration style consistency marker. + +This does not queue generation or regenerate the library. It marks existing +demand-generated character illustrations produced before template 21J.3 so the +admin can deliberately regenerate them with the corrected art direction. +*/ + +UPDATE dbo.IllustrationLibraryItems +SET MetadataJson = JSON_MODIFY( + JSON_MODIFY( + JSON_MODIFY( + CASE WHEN ISJSON(MetadataJson) = 1 THEN MetadataJson ELSE N'{}' END, + '$.generationSource', + N'Demand generation'), + '$.styleReview', + N'Needs 21J.3 regeneration'), + '$.styleIntent', + N'match-approved-starter-library'), + AdminNotes = CONCAT( + COALESCE(NULLIF(AdminNotes, N''), N''), + CASE WHEN NULLIF(AdminNotes, N'') IS NULL THEN N'' ELSE N' ' END, + N'Phase 21J.3: demand-generated before the 21J.3 style template; regenerate with the current template before approval.'), + UpdatedUtc = SYSUTCDATETIME() +WHERE StableCode LIKE N'char-demand-%' + AND Status IN (N'Generated', N'Approved') + AND ISNULL(PromptTemplateVersion, N'') <> N'21J.3' + AND (AdminNotes IS NULL OR AdminNotes NOT LIKE N'%Phase 21J.3:%'); diff --git a/PlotLine/ViewModels/StoryIntelligenceExperiencePrototypeViewModels.cs b/PlotLine/ViewModels/StoryIntelligenceExperiencePrototypeViewModels.cs index e2483b0..4f1b9e6 100644 --- a/PlotLine/ViewModels/StoryIntelligenceExperiencePrototypeViewModels.cs +++ b/PlotLine/ViewModels/StoryIntelligenceExperiencePrototypeViewModels.cs @@ -26,10 +26,17 @@ public sealed class StoryIntelligenceExperienceSnapshotDiagnostics public int? ActiveChapterRunId { get; init; } public string SessionStatus { get; init; } = string.Empty; public int OverallBookProgress { get; init; } + public int AuthoritativeImportProgress { get; init; } + public int CalculatedVisualisationProgress { get; init; } + public int TotalChapters { get; init; } + public int CompletedChapters { get; init; } public string CurrentChapter { get; init; } = string.Empty; public string CurrentScene { get; init; } = string.Empty; public int CompletedScenes { get; init; } public int TotalScenes { get; init; } + public int ParsedScenes { get; init; } + public string TerminalRunSessionStatus { get; init; } = string.Empty; + public string DisplayedProgressReason { get; init; } = string.Empty; public string ProgressSource { get; init; } = string.Empty; public string CurrentBackendStage { get; init; } = string.Empty; public int StoryMemoryCharacterCount { get; init; } diff --git a/PlotLine/Views/Admin/StoryIntelligenceIllustrationLibrary.cshtml b/PlotLine/Views/Admin/StoryIntelligenceIllustrationLibrary.cshtml index 0fd294b..26f5a27 100644 --- a/PlotLine/Views/Admin/StoryIntelligenceIllustrationLibrary.cshtml +++ b/PlotLine/Views/Admin/StoryIntelligenceIllustrationLibrary.cshtml @@ -118,6 +118,13 @@ @foreach (var item in Model.Items) { var preview = !string.IsNullOrWhiteSpace(item.ThumbnailPath) ? item.ThumbnailPath : item.FilePath; + var generationSource = item.StableCode.StartsWith("char-demand-", StringComparison.OrdinalIgnoreCase) + ? "Demand generation" + : item.ParentItemID.HasValue + ? "Manual regeneration" + : IllustrationStarterBatchDefinition.StarterCodes.Contains(item.StableCode) + ? "Starter batch" + : "Manual library item";
@@ -137,6 +144,10 @@
@item.StableCode
Provider
@(item.Provider ?? "Not generated") @(item.Model is null ? string.Empty : $" / {item.Model}")
+
Template
+
@(item.PromptTemplateVersion ?? "Not generated")
+
Source
+
@generationSource
Updated
@item.UpdatedUtc.ToString("yyyy-MM-dd HH:mm") UTC
@if (item.ApprovedUtc.HasValue) diff --git a/PlotLine/Views/Development/StoryIntelligenceExperience.cshtml b/PlotLine/Views/Development/StoryIntelligenceExperience.cshtml index 4f6f1c1..93d36a4 100644 --- a/PlotLine/Views/Development/StoryIntelligenceExperience.cshtml +++ b/PlotLine/Views/Development/StoryIntelligenceExperience.cshtml @@ -212,6 +212,22 @@
Overall book progress
@Model.Diagnostics.OverallBookProgress
+
+
Authoritative import progress
+
@Model.Diagnostics.AuthoritativeImportProgress
+
+
+
Calculated visualisation progress
+
@Model.Diagnostics.CalculatedVisualisationProgress
+
+
+
Total chapters
+
@Model.Diagnostics.TotalChapters
+
+
+
Completed chapters
+
@Model.Diagnostics.CompletedChapters
+
Current chapter
@Model.Diagnostics.CurrentChapter
@@ -228,6 +244,18 @@
Total scenes
@Model.Diagnostics.TotalScenes
+
+
Parsed scenes
+
@Model.Diagnostics.ParsedScenes
+
+
+
Terminal run/session status
+
@Model.Diagnostics.TerminalRunSessionStatus
+
+
+
Displayed progress reason
+
@Model.Diagnostics.DisplayedProgressReason
+
Progress source
@Model.Diagnostics.ProgressSource
diff --git a/PlotLine/wwwroot/css/story-intelligence-experience-prototype.css b/PlotLine/wwwroot/css/story-intelligence-experience-prototype.css index 57a4da5..4bf20aa 100644 --- a/PlotLine/wwwroot/css/story-intelligence-experience-prototype.css +++ b/PlotLine/wwwroot/css/story-intelligence-experience-prototype.css @@ -124,7 +124,7 @@ input:focus-visible { } .story-exp-stage { - grid-template-rows: auto minmax(0, 1fr) 126px; + grid-template-rows: auto minmax(0, 1fr) 170px; } .story-exp-stage-copy { @@ -133,24 +133,28 @@ input:focus-visible { .story-exp-ribbon-wrap { gap: 8px; - padding: 10px; + grid-template-rows: minmax(92px, auto) 46px; + padding: 10px 12px 12px; } .story-exp-ribbon { - min-height: 70px; + gap: 12px; + min-height: 92px; } .story-scene-card { - flex-basis: 126px; - padding: 8px 10px; + flex-basis: clamp(132px, 14vw, 168px); + min-height: 84px; + padding: 10px 12px; } .story-scene-card.is-current { - flex-basis: 172px; + flex-basis: clamp(188px, 18vw, 224px); + min-height: 96px; } .story-exp-timeline { - min-height: 34px; + min-height: 46px; } .story-exp-controls { @@ -160,6 +164,20 @@ input:focus-visible { } } +@media (max-width: 1400px) and (min-width: 1181px) { + .story-exp-ribbon { + gap: 12px; + } + + .story-scene-card { + flex-basis: clamp(132px, 11vw, 160px); + } + + .story-scene-card.is-current { + flex-basis: clamp(188px, 15vw, 220px); + } +} + .story-exp-brand strong { font-size: 1.12rem; letter-spacing: 0.02em; @@ -482,7 +500,7 @@ input:focus-visible { .story-exp-stage { display: grid; - grid-template-rows: auto minmax(0, 1fr) 154px; + grid-template-rows: auto minmax(0, 1fr) 186px; gap: 8px; min-height: 0; } @@ -917,12 +935,12 @@ input:focus-visible { .story-exp-ribbon-wrap { display: grid; - grid-template-rows: minmax(76px, auto) 54px; - gap: 10px; + grid-template-rows: minmax(104px, auto) 56px; + gap: 12px; min-width: 0; border: 1px solid rgba(151, 185, 218, 0.1); border-radius: 24px; - padding: 12px 14px 14px; + padding: 14px 16px 16px; background: rgba(6, 16, 30, 0.54); box-shadow: 0 20px 58px rgba(0, 0, 0, 0.22); backdrop-filter: blur(14px); @@ -933,24 +951,25 @@ input:focus-visible { display: flex; align-items: stretch; justify-content: center; - gap: 12px; - min-height: 76px; + gap: 14px; + min-height: 104px; overflow: hidden; } .story-scene-card { - flex: 0 0 136px; + flex: 0 0 clamp(174px, 16vw, 218px); display: grid; grid-template-rows: auto minmax(0, 1fr) auto; align-content: start; - gap: 5px; + gap: 7px; border: 1px solid rgba(255, 255, 255, 0.07); - border-radius: 15px; - padding: 9px 10px; + min-height: 94px; + border-radius: 14px; + padding: 12px 14px; background: rgba(255, 255, 255, 0.032); color: var(--story-muted); opacity: 0.68; - transform: scale(0.91) translateY(4px); + transform: scale(0.96) translateY(3px); transition: transform 840ms cubic-bezier(.16, .84, .28, 1), opacity 760ms ease, background 760ms ease, border-color 760ms ease, box-shadow 760ms ease; } @@ -959,12 +978,13 @@ input:focus-visible { } .story-scene-card.is-current { - flex-basis: 186px; + flex-basis: clamp(224px, 20vw, 280px); + min-height: 106px; border-color: rgba(215, 168, 93, 0.64); background: linear-gradient(180deg, rgba(215, 168, 93, 0.18), rgba(109, 181, 255, 0.075)); color: var(--story-text); opacity: 1; - transform: scale(1.04) translateY(0); + transform: scale(1.02) translateY(0); box-shadow: 0 18px 42px rgba(215, 168, 93, 0.16); } @@ -972,28 +992,28 @@ input:focus-visible { display: -webkit-box; overflow: hidden; color: inherit; - font-size: 0.86rem; - line-height: 1.12; + font-size: 0.92rem; + line-height: 1.16; -webkit-box-orient: vertical; -webkit-line-clamp: 2; } .story-scene-card span { color: var(--story-muted); - font-size: 0.66rem; + font-size: 0.68rem; font-weight: 800; letter-spacing: 0.04em; text-transform: uppercase; } .story-scene-card small { - display: -webkit-box; + display: block; overflow: hidden; color: var(--story-muted); - font-size: 0.68rem; - line-height: 1.1; - -webkit-box-orient: vertical; - -webkit-line-clamp: 1; + font-size: 0.7rem; + line-height: 1.15; + text-overflow: ellipsis; + white-space: nowrap; } .story-exp-timeline { @@ -1001,7 +1021,7 @@ input:focus-visible { display: grid; grid-template-columns: repeat(var(--timeline-count, 1), minmax(0, 1fr)); gap: 0; - min-height: 54px; + min-height: 56px; } .story-exp-timeline::before { diff --git a/PlotLine/wwwroot/js/story-intelligence-experience-prototype.js b/PlotLine/wwwroot/js/story-intelligence-experience-prototype.js index bb9b0f6..cd92183 100644 --- a/PlotLine/wwwroot/js/story-intelligence-experience-prototype.js +++ b/PlotLine/wwwroot/js/story-intelligence-experience-prototype.js @@ -352,9 +352,7 @@ } function updateSceneRibbon(scene) { - const currentIndex = state.index; - const start = Math.max(0, Math.min(scenes.length - 7, currentIndex - 3)); - const visible = scenes.slice(start, start + 7); + const visible = visibleSceneWindow(); const activeIds = new Set(visible.map((item) => item.id)); visible.forEach((item) => { let card = dom.ribbon.querySelector(`[data-scene-id="${cssEscape(item.id)}"]`); @@ -367,19 +365,19 @@ } card.classList.toggle("is-current", item.id === scene.id); card.classList.toggle("is-complete", item.sceneNumber < scene.sceneNumber); - text(card.querySelector("span"), `${(item.chapterLabel || "Chapter").toUpperCase()} · SCENE ${item.sceneNumber}`); - text(card.querySelector("strong"), compactPhrase(cleanSceneTitle(item.title), item.id === scene.id ? 44 : 30)); - text(card.querySelector("small"), compactPhrase(item.timelineEvent, 34)); + text(card.querySelector("span"), `${abbreviateChapter(item.chapterLabel)} · Scene ${item.sceneNumber}`); + text(card.querySelector("strong"), compactPhrase(cleanSceneTitle(item.title), 54)); + text(card.querySelector("small"), compactPhrase(item.timelineEvent || item.summary, 48)); dom.ribbon.append(card); }); removeInactive(dom.ribbon, "[data-scene-id]", "sceneId", activeIds); } function updateTimeline(scene) { - const completed = scenes.slice(0, state.index + 1); - dom.timeline.style.setProperty("--timeline-count", String(completed.length)); - const activeIds = new Set(completed.map((item) => item.id)); - completed.forEach((item) => { + const visible = visibleSceneWindow(); + dom.timeline.style.setProperty("--timeline-count", String(visible.length)); + const activeIds = new Set(visible.map((item) => item.id)); + visible.forEach((item) => { let marker = dom.timeline.querySelector(`[data-timeline-id="${cssEscape(item.id)}"]`); if (!marker) { marker = document.createElement("div"); @@ -389,19 +387,19 @@ settleEntering(marker); } marker.classList.toggle("is-current", item.id === scene.id); - const shouldLabel = shouldShowTimelineLabel(completed, item, scene); - marker.classList.toggle("is-label-hidden", !shouldLabel); + marker.classList.toggle("is-complete", item.sceneNumber < scene.sceneNumber); + marker.classList.remove("is-label-hidden"); text(marker.querySelector("span"), shortTimelineLabel(item)); dom.timeline.append(marker); }); removeInactive(dom.timeline, "[data-timeline-id]", "timelineId", activeIds); } - function shouldShowTimelineLabel(items, item, scene) { - const index = items.findIndex((candidate) => candidate.id === item.id); - if (index === 0 || index === items.length - 1 || item.id === scene.id) return true; - const interval = items.length > 12 ? 5 : items.length > 8 ? 4 : 3; - return index % interval === 0 && Math.abs(item.sceneNumber - scene.sceneNumber) > 1; + function visibleSceneWindow() { + const visibleCount = Math.min(5, scenes.length); + const currentIndex = Math.max(0, Math.min(state.index, scenes.length - 1)); + const start = Math.max(0, Math.min(scenes.length - visibleCount, currentIndex - Math.floor(visibleCount / 2))); + return scenes.slice(start, start + visibleCount); } function shortTimelineLabel(item) {