Phase 21J.3 - Illustration Style and Prototype Final Polish

This commit is contained in:
Nick Beckley 2026-07-13 19:23:44 +00:00
parent 69e4ac8201
commit ca73c9e6f3
9 changed files with 226 additions and 66 deletions

View File

@ -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

View File

@ -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")
}

View File

@ -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<StoryIntelligenceExperienceSceneState>();
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<RunSnapshot> 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<RunSnapshot> 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;

View File

@ -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:%');

View File

@ -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; }

View File

@ -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";
<article class="project-card">
<div class="project-card__body">
<div class="d-flex justify-content-between gap-2 align-items-start">
@ -137,6 +144,10 @@
<dd><code>@item.StableCode</code></dd>
<dt>Provider</dt>
<dd>@(item.Provider ?? "Not generated") @(item.Model is null ? string.Empty : $" / {item.Model}")</dd>
<dt>Template</dt>
<dd>@(item.PromptTemplateVersion ?? "Not generated")</dd>
<dt>Source</dt>
<dd>@generationSource</dd>
<dt>Updated</dt>
<dd>@item.UpdatedUtc.ToString("yyyy-MM-dd HH:mm") UTC</dd>
@if (item.ApprovedUtc.HasValue)

View File

@ -212,6 +212,22 @@
<dt>Overall book progress</dt>
<dd data-diagnostic-key="overallBookProgress">@Model.Diagnostics.OverallBookProgress</dd>
</div>
<div>
<dt>Authoritative import progress</dt>
<dd data-diagnostic-key="authoritativeImportProgress">@Model.Diagnostics.AuthoritativeImportProgress</dd>
</div>
<div>
<dt>Calculated visualisation progress</dt>
<dd data-diagnostic-key="calculatedVisualisationProgress">@Model.Diagnostics.CalculatedVisualisationProgress</dd>
</div>
<div>
<dt>Total chapters</dt>
<dd data-diagnostic-key="totalChapters">@Model.Diagnostics.TotalChapters</dd>
</div>
<div>
<dt>Completed chapters</dt>
<dd data-diagnostic-key="completedChapters">@Model.Diagnostics.CompletedChapters</dd>
</div>
<div>
<dt>Current chapter</dt>
<dd data-diagnostic-key="currentChapter">@Model.Diagnostics.CurrentChapter</dd>
@ -228,6 +244,18 @@
<dt>Total scenes</dt>
<dd data-diagnostic-key="totalScenes">@Model.Diagnostics.TotalScenes</dd>
</div>
<div>
<dt>Parsed scenes</dt>
<dd data-diagnostic-key="parsedScenes">@Model.Diagnostics.ParsedScenes</dd>
</div>
<div>
<dt>Terminal run/session status</dt>
<dd data-diagnostic-key="terminalRunSessionStatus">@Model.Diagnostics.TerminalRunSessionStatus</dd>
</div>
<div>
<dt>Displayed progress reason</dt>
<dd data-diagnostic-key="displayedProgressReason">@Model.Diagnostics.DisplayedProgressReason</dd>
</div>
<div>
<dt>Progress source</dt>
<dd data-diagnostic-key="progressSource">@Model.Diagnostics.ProgressSource</dd>

View File

@ -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 {

View File

@ -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) {