Add deterministic manuscript text statistics
This commit is contained in:
parent
7d33323de7
commit
ea41ec3aac
@ -98,6 +98,20 @@ var tests = new (string Name, Action Test)[]
|
||||
("Story Intelligence starts from core import complete", StoryIntelligenceStartsFromCoreImportComplete),
|
||||
("Generated scene titles persist with provenance", GeneratedSceneTitlesPersistWithProvenance),
|
||||
("Scene title display keeps scene numbers visible", SceneTitleDisplayKeepsSceneNumbersVisible),
|
||||
("Manuscript text statistics handles pure prose", ManuscriptTextStatisticsHandlesPureProse),
|
||||
("Manuscript text statistics handles pure dialogue", ManuscriptTextStatisticsHandlesPureDialogue),
|
||||
("Manuscript text statistics splits narrative before dialogue", ManuscriptTextStatisticsSplitsNarrativeBeforeDialogue),
|
||||
("Manuscript text statistics splits dialogue before narrative", ManuscriptTextStatisticsSplitsDialogueBeforeNarrative),
|
||||
("Manuscript text statistics handles multiple dialogue passages", ManuscriptTextStatisticsHandlesMultipleDialoguePassages),
|
||||
("Manuscript text statistics supports smart quotes", ManuscriptTextStatisticsSupportsSmartQuotes),
|
||||
("Manuscript text statistics supports straight quotes", ManuscriptTextStatisticsSupportsStraightQuotes),
|
||||
("Manuscript text statistics carries multi-paragraph dialogue", ManuscriptTextStatisticsCarriesMultiParagraphDialogue),
|
||||
("Manuscript text statistics returns to prose after continued dialogue", ManuscriptTextStatisticsReturnsToProseAfterContinuedDialogue),
|
||||
("Manuscript text statistics ignores empty paragraphs", ManuscriptTextStatisticsIgnoresEmptyParagraphs),
|
||||
("Manuscript text statistics reconciles scene totals", ManuscriptTextStatisticsReconcilesSceneTotals),
|
||||
("Manuscript text statistics aggregates chapter scenes", ManuscriptTextStatisticsAggregatesChapterScenes),
|
||||
("Manuscript text statistics recalculates existing scene sources", ManuscriptTextStatisticsRecalculatesExistingSceneSources),
|
||||
("Manuscript text statistics does not invoke AI", ManuscriptTextStatisticsDoesNotInvokeAi),
|
||||
("Phase 21T repairs durable character understanding", Phase21TRepairsDurableCharacterUnderstanding),
|
||||
("Phase 21U extracts mandatory character appearance fixtures", Phase21UExtractsMandatoryCharacterAppearanceFixtures),
|
||||
("Character sex hints are conservative and reviewable", CharacterSexHintsAreConservativeAndReviewable),
|
||||
@ -2633,6 +2647,136 @@ static void SceneTitleDisplayKeepsSceneNumbersVisible()
|
||||
Assert(SceneTitleDisplay.Format(7, "Behind the Fire Exit") == "Scene 7 \u00b7 Behind the Fire Exit", "Meaningful titles should render after the scene number.");
|
||||
}
|
||||
|
||||
static void ManuscriptTextStatisticsHandlesPureProse()
|
||||
{
|
||||
var stats = CreateTextStatisticsService().Calculate("Beth crossed the car park and kept her phone hidden in her sleeve.");
|
||||
|
||||
AssertStats(stats, total: 13, dialogue: 0, prose: 13, paragraphs: 1, dialogueParagraphs: 0, proseParagraphs: 1);
|
||||
}
|
||||
|
||||
static void ManuscriptTextStatisticsHandlesPureDialogue()
|
||||
{
|
||||
var stats = CreateTextStatisticsService().Calculate("\u201cI know exactly what happened after the party.\u201d");
|
||||
|
||||
AssertStats(stats, total: 8, dialogue: 8, prose: 0, paragraphs: 1, dialogueParagraphs: 1, proseParagraphs: 0);
|
||||
}
|
||||
|
||||
static void ManuscriptTextStatisticsSplitsNarrativeBeforeDialogue()
|
||||
{
|
||||
var stats = CreateTextStatisticsService().Calculate("Beth looked at Rosie. \u201cYou're not seriously suggesting that?\u201d");
|
||||
|
||||
AssertStats(stats, total: 9, dialogue: 5, prose: 4, paragraphs: 1, dialogueParagraphs: 1, proseParagraphs: 1);
|
||||
}
|
||||
|
||||
static void ManuscriptTextStatisticsSplitsDialogueBeforeNarrative()
|
||||
{
|
||||
var stats = CreateTextStatisticsService().Calculate("\u201cI'm completely serious,\u201d Rosie said, folding her arms.");
|
||||
|
||||
AssertStats(stats, total: 8, dialogue: 3, prose: 5, paragraphs: 1, dialogueParagraphs: 1, proseParagraphs: 1);
|
||||
}
|
||||
|
||||
static void ManuscriptTextStatisticsHandlesMultipleDialoguePassages()
|
||||
{
|
||||
var stats = CreateTextStatisticsService().Calculate("\u201cI'm completely serious,\u201d Rosie said. \u201cAnd you know I'm right.\u201d");
|
||||
|
||||
AssertStats(stats, total: 10, dialogue: 8, prose: 2, paragraphs: 1, dialogueParagraphs: 1, proseParagraphs: 1);
|
||||
}
|
||||
|
||||
static void ManuscriptTextStatisticsSupportsSmartQuotes()
|
||||
{
|
||||
var stats = CreateTextStatisticsService().Calculate("Judy whispered, \u201cThe office light is still on.\u201d");
|
||||
|
||||
AssertStats(stats, total: 8, dialogue: 6, prose: 2, paragraphs: 1, dialogueParagraphs: 1, proseParagraphs: 1);
|
||||
}
|
||||
|
||||
static void ManuscriptTextStatisticsSupportsStraightQuotes()
|
||||
{
|
||||
var stats = CreateTextStatisticsService().Calculate("Judy whispered, \"The office light is still on.\"");
|
||||
|
||||
AssertStats(stats, total: 8, dialogue: 6, prose: 2, paragraphs: 1, dialogueParagraphs: 1, proseParagraphs: 1);
|
||||
}
|
||||
|
||||
static void ManuscriptTextStatisticsCarriesMultiParagraphDialogue()
|
||||
{
|
||||
var stats = CreateTextStatisticsService().Calculate("""
|
||||
"This is the first paragraph of a long speech.
|
||||
"This is the second paragraph.
|
||||
"And this is the final paragraph."
|
||||
""");
|
||||
|
||||
AssertStats(stats, total: 20, dialogue: 20, prose: 0, paragraphs: 3, dialogueParagraphs: 3, proseParagraphs: 0);
|
||||
}
|
||||
|
||||
static void ManuscriptTextStatisticsReturnsToProseAfterContinuedDialogue()
|
||||
{
|
||||
var stats = CreateTextStatisticsService().Calculate("""
|
||||
"This is the first paragraph of a long speech.
|
||||
"This is the final paragraph."
|
||||
Rosie shut the notebook and waited.
|
||||
""");
|
||||
|
||||
AssertStats(stats, total: 20, dialogue: 14, prose: 6, paragraphs: 3, dialogueParagraphs: 2, proseParagraphs: 1);
|
||||
}
|
||||
|
||||
static void ManuscriptTextStatisticsIgnoresEmptyParagraphs()
|
||||
{
|
||||
var stats = CreateTextStatisticsService().Calculate("""
|
||||
Beth waited.
|
||||
|
||||
|
||||
"Say something," Rosie said.
|
||||
""");
|
||||
|
||||
AssertStats(stats, total: 6, dialogue: 2, prose: 4, paragraphs: 2, dialogueParagraphs: 1, proseParagraphs: 2);
|
||||
}
|
||||
|
||||
static void ManuscriptTextStatisticsReconcilesSceneTotals()
|
||||
{
|
||||
var stats = CreateTextStatisticsService().Calculate("Beth shook her head. \u201cAbsolutely not.\u201d Rosie smiled anyway.");
|
||||
|
||||
Assert(stats.DialogueWordCount + stats.ProseWordCount == stats.TotalWordCount, "Dialogue and prose words should reconcile to total words.");
|
||||
AssertStats(stats, total: 9, dialogue: 2, prose: 7, paragraphs: 1, dialogueParagraphs: 1, proseParagraphs: 1);
|
||||
}
|
||||
|
||||
static void ManuscriptTextStatisticsAggregatesChapterScenes()
|
||||
{
|
||||
var service = CreateTextStatisticsService();
|
||||
var sceneOne = service.Calculate("Beth stood by the door. \u201cWe leave now.\u201d");
|
||||
var sceneTwo = service.Calculate("The corridor was empty and cold.");
|
||||
var aggregate = service.Aggregate([sceneOne, sceneTwo]);
|
||||
|
||||
AssertStats(aggregate, total: 14, dialogue: 3, prose: 11, paragraphs: 2, dialogueParagraphs: 1, proseParagraphs: 2);
|
||||
}
|
||||
|
||||
static void ManuscriptTextStatisticsRecalculatesExistingSceneSources()
|
||||
{
|
||||
var repository = new FakeManuscriptTextStatisticsRepository("Beth waited. \u201cTell me the truth.\u201d");
|
||||
var service = new ManuscriptTextStatisticsService(repository, null!);
|
||||
|
||||
service.RecalculateSceneAsync(42).GetAwaiter().GetResult();
|
||||
|
||||
Assert(repository.SavedSceneID == 42, "Recalculation should save statistics for the requested SceneID.");
|
||||
Assert(repository.SavedStatistics is not null, "Recalculation should persist calculated statistics.");
|
||||
AssertStats(repository.SavedStatistics!, total: 6, dialogue: 4, prose: 2, paragraphs: 1, dialogueParagraphs: 1, proseParagraphs: 1);
|
||||
}
|
||||
|
||||
static void ManuscriptTextStatisticsDoesNotInvokeAi()
|
||||
{
|
||||
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
||||
var service = File.ReadAllText(Path.Combine(root, "Services/ManuscriptTextStatisticsService.cs"));
|
||||
var onboarding = File.ReadAllText(Path.Combine(root, "Services/OnboardingService.cs"));
|
||||
var repository = File.ReadAllText(Path.Combine(root, "Data/ManuscriptTextStatisticsRepository.cs"));
|
||||
var migration = File.ReadAllText(Path.Combine(root, "Sql/164_Phase25I_SceneTextStatistics.sql"));
|
||||
|
||||
Assert(!service.Contains("IStoryIntelligence", StringComparison.Ordinal), "Text statistics service should not depend on Story Intelligence.");
|
||||
Assert(!service.Contains("OpenAI", StringComparison.Ordinal), "Text statistics service should not reference OpenAI.");
|
||||
Assert(!repository.Contains("CommandType.Text", StringComparison.Ordinal), "Text statistics repository should remain stored-procedure backed.");
|
||||
Assert(onboarding.Contains("manuscriptTextStatistics.RecalculateSceneAsync(sceneId)", StringComparison.Ordinal), "Core import should calculate stats after canonical source persistence.");
|
||||
Assert(migration.Contains("CREATE TABLE dbo.SceneTextStatistics", StringComparison.Ordinal), "Scene text statistics should be persisted.");
|
||||
Assert(migration.Contains("CREATE OR ALTER PROCEDURE dbo.SceneTextStatistics_Save", StringComparison.Ordinal), "Scene text statistics should be saved by stored procedure.");
|
||||
Assert(migration.Contains("ON DELETE CASCADE", StringComparison.Ordinal), "Scene text statistics should not block permanent scene deletion.");
|
||||
}
|
||||
|
||||
static void Phase21TRepairsDurableCharacterUnderstanding()
|
||||
{
|
||||
var maggie = StoryMemoryService.ResolveCharacterEvidenceFromAttributes(
|
||||
@ -3366,6 +3510,27 @@ static JsonSerializerOptions JsonOptions()
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
static ManuscriptTextStatisticsService CreateTextStatisticsService()
|
||||
=> new(new FakeManuscriptTextStatisticsRepository(string.Empty), null!);
|
||||
|
||||
static void AssertStats(
|
||||
ManuscriptTextStatistics stats,
|
||||
int total,
|
||||
int dialogue,
|
||||
int prose,
|
||||
int paragraphs,
|
||||
int dialogueParagraphs,
|
||||
int proseParagraphs)
|
||||
{
|
||||
Assert(stats.TotalWordCount == total, $"Expected {total:N0} total words, got {stats.TotalWordCount:N0}.");
|
||||
Assert(stats.DialogueWordCount == dialogue, $"Expected {dialogue:N0} dialogue words, got {stats.DialogueWordCount:N0}.");
|
||||
Assert(stats.ProseWordCount == prose, $"Expected {prose:N0} prose words, got {stats.ProseWordCount:N0}.");
|
||||
Assert(stats.TotalParagraphCount == paragraphs, $"Expected {paragraphs:N0} paragraphs, got {stats.TotalParagraphCount:N0}.");
|
||||
Assert(stats.DialogueParagraphCount == dialogueParagraphs, $"Expected {dialogueParagraphs:N0} dialogue paragraphs, got {stats.DialogueParagraphCount:N0}.");
|
||||
Assert(stats.ProseParagraphCount == proseParagraphs, $"Expected {proseParagraphs:N0} prose paragraphs, got {stats.ProseParagraphCount:N0}.");
|
||||
Assert(stats.DialogueWordCount + stats.ProseWordCount == stats.TotalWordCount, "Dialogue and prose words should reconcile to total words.");
|
||||
}
|
||||
|
||||
static void Assert(bool condition, string message)
|
||||
{
|
||||
if (!condition)
|
||||
@ -3379,3 +3544,32 @@ internal sealed class CaptureImageRequestHandler(Func<HttpRequestMessage, HttpRe
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(respond(request));
|
||||
}
|
||||
|
||||
internal sealed class FakeManuscriptTextStatisticsRepository(string sourceText) : IManuscriptTextStatisticsRepository
|
||||
{
|
||||
public int? SavedSceneID { get; private set; }
|
||||
public ManuscriptTextStatistics? SavedStatistics { get; private set; }
|
||||
|
||||
public Task SaveAsync(int sceneId, ManuscriptTextStatistics statistics)
|
||||
{
|
||||
SavedSceneID = sceneId;
|
||||
SavedStatistics = statistics;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<ManuscriptTextStatistics?> GetBySceneAsync(int sceneId)
|
||||
=> Task.FromResult<ManuscriptTextStatistics?>(SavedStatistics);
|
||||
|
||||
public Task<IReadOnlyDictionary<int, ManuscriptTextStatistics>> ListByChapterAsync(int chapterId)
|
||||
=> Task.FromResult<IReadOnlyDictionary<int, ManuscriptTextStatistics>>(new Dictionary<int, ManuscriptTextStatistics>());
|
||||
|
||||
public Task<SceneManuscriptSourceRecord?> GetSourceBySceneAsync(int sceneId)
|
||||
=> Task.FromResult<SceneManuscriptSourceRecord?>(new SceneManuscriptSourceRecord
|
||||
{
|
||||
SceneID = sceneId,
|
||||
ProjectID = 10,
|
||||
BookID = 20,
|
||||
ChapterID = 30,
|
||||
SourceText = sourceText
|
||||
});
|
||||
}
|
||||
|
||||
81
PlotLine/Data/ManuscriptTextStatisticsRepository.cs
Normal file
81
PlotLine/Data/ManuscriptTextStatisticsRepository.cs
Normal file
@ -0,0 +1,81 @@
|
||||
using System.Data;
|
||||
using Dapper;
|
||||
using PlotLine.Models;
|
||||
|
||||
namespace PlotLine.Data;
|
||||
|
||||
public interface IManuscriptTextStatisticsRepository
|
||||
{
|
||||
Task SaveAsync(int sceneId, ManuscriptTextStatistics statistics);
|
||||
Task<ManuscriptTextStatistics?> GetBySceneAsync(int sceneId);
|
||||
Task<IReadOnlyDictionary<int, ManuscriptTextStatistics>> ListByChapterAsync(int chapterId);
|
||||
Task<SceneManuscriptSourceRecord?> GetSourceBySceneAsync(int sceneId);
|
||||
}
|
||||
|
||||
public sealed class ManuscriptTextStatisticsRepository(ISqlConnectionFactory connectionFactory) : IManuscriptTextStatisticsRepository
|
||||
{
|
||||
public async Task SaveAsync(int sceneId, ManuscriptTextStatistics statistics)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
await connection.ExecuteAsync(
|
||||
"dbo.SceneTextStatistics_Save",
|
||||
new
|
||||
{
|
||||
SceneID = sceneId,
|
||||
statistics.TotalWordCount,
|
||||
statistics.DialogueWordCount,
|
||||
statistics.ProseWordCount,
|
||||
statistics.TotalParagraphCount,
|
||||
statistics.DialogueParagraphCount,
|
||||
statistics.ProseParagraphCount
|
||||
},
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
public async Task<ManuscriptTextStatistics?> GetBySceneAsync(int sceneId)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
return await connection.QuerySingleOrDefaultAsync<ManuscriptTextStatistics>(
|
||||
"dbo.SceneTextStatistics_GetByScene",
|
||||
new { SceneID = sceneId },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyDictionary<int, ManuscriptTextStatistics>> ListByChapterAsync(int chapterId)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
var rows = await connection.QueryAsync<SceneTextStatisticsRow>(
|
||||
"dbo.SceneTextStatistics_ListByChapter",
|
||||
new { ChapterID = chapterId },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
return rows.ToDictionary(row => row.SceneID, row => new ManuscriptTextStatistics
|
||||
{
|
||||
TotalWordCount = row.TotalWordCount,
|
||||
DialogueWordCount = row.DialogueWordCount,
|
||||
ProseWordCount = row.ProseWordCount,
|
||||
TotalParagraphCount = row.TotalParagraphCount,
|
||||
DialogueParagraphCount = row.DialogueParagraphCount,
|
||||
ProseParagraphCount = row.ProseParagraphCount
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<SceneManuscriptSourceRecord?> GetSourceBySceneAsync(int sceneId)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
return await connection.QuerySingleOrDefaultAsync<SceneManuscriptSourceRecord>(
|
||||
"dbo.SceneManuscriptSource_GetByScene",
|
||||
new { SceneID = sceneId },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
private sealed class SceneTextStatisticsRow
|
||||
{
|
||||
public int SceneID { get; init; }
|
||||
public int TotalWordCount { get; init; }
|
||||
public int DialogueWordCount { get; init; }
|
||||
public int ProseWordCount { get; init; }
|
||||
public int TotalParagraphCount { get; init; }
|
||||
public int DialogueParagraphCount { get; init; }
|
||||
public int ProseParagraphCount { get; init; }
|
||||
}
|
||||
}
|
||||
@ -470,6 +470,7 @@ public sealed class Scene
|
||||
public int PendingCharacterSuggestionCount { get; set; }
|
||||
public int PendingAssetSuggestionCount { get; set; }
|
||||
public int PendingLocationSuggestionCount { get; set; }
|
||||
public ManuscriptTextStatistics? TextStatistics { get; set; }
|
||||
|
||||
public string TimeLabel
|
||||
{
|
||||
@ -485,6 +486,25 @@ public sealed class Scene
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ManuscriptTextStatistics
|
||||
{
|
||||
public int TotalWordCount { get; set; }
|
||||
public int DialogueWordCount { get; set; }
|
||||
public int ProseWordCount { get; set; }
|
||||
public int TotalParagraphCount { get; set; }
|
||||
public int DialogueParagraphCount { get; set; }
|
||||
public int ProseParagraphCount { get; set; }
|
||||
public decimal DialoguePercentage => TotalWordCount <= 0
|
||||
? 0m
|
||||
: Math.Round(DialogueWordCount * 100m / TotalWordCount, 1);
|
||||
public decimal? AverageDialogueParagraphLength => DialogueParagraphCount <= 0
|
||||
? null
|
||||
: Math.Round(DialogueWordCount * 1m / DialogueParagraphCount, 1);
|
||||
public decimal? AverageProseParagraphLength => ProseParagraphCount <= 0
|
||||
? null
|
||||
: Math.Round(ProseWordCount * 1m / ProseParagraphCount, 1);
|
||||
}
|
||||
|
||||
public sealed class RevisionStatus
|
||||
{
|
||||
public int RevisionStatusID { get; set; }
|
||||
|
||||
@ -145,6 +145,7 @@ public class Program
|
||||
builder.Services.AddScoped<IWordCompanionRepository, WordCompanionRepository>();
|
||||
builder.Services.AddScoped<IOnboardingRepository, OnboardingRepository>();
|
||||
builder.Services.AddScoped<IOnboardingBuildRepository, OnboardingBuildRepository>();
|
||||
builder.Services.AddScoped<IManuscriptTextStatisticsRepository, ManuscriptTextStatisticsRepository>();
|
||||
builder.Services.AddScoped<IStoryIntelligenceRepository, StoryIntelligenceRepository>();
|
||||
builder.Services.AddScoped<IStoryIntelligenceResultRepository, StoryIntelligenceResultRepository>();
|
||||
builder.Services.AddScoped<IStoryIntelligenceSourceRepository, StoryIntelligenceSourceRepository>();
|
||||
@ -217,6 +218,7 @@ public class Program
|
||||
builder.Services.AddScoped<IChapterStructureDryRunService, ChapterStructureDryRunService>();
|
||||
builder.Services.AddScoped<IChapterStoryIntelligenceDryRunService, ChapterStoryIntelligenceDryRunService>();
|
||||
builder.Services.AddScoped<IManuscriptStructureAnalysisService, ManuscriptStructureAnalysisService>();
|
||||
builder.Services.AddScoped<IManuscriptTextStatisticsService, ManuscriptTextStatisticsService>();
|
||||
builder.Services.AddScoped<ICoreImportCharacterMappingService, CoreImportCharacterMappingService>();
|
||||
builder.Services.AddScoped<IStoryIntelligenceResultPersistenceService, StoryIntelligenceResultPersistenceService>();
|
||||
builder.Services.AddScoped<IStoryIntelligenceExistingChapterQueueService, StoryIntelligenceExistingChapterQueueService>();
|
||||
|
||||
@ -1378,6 +1378,8 @@ public sealed class ChapterService(
|
||||
ILookupRepository lookups,
|
||||
ISubscriptionService subscriptions,
|
||||
IProjectActivityService activity,
|
||||
IManuscriptTextStatisticsRepository manuscriptTextStatisticsRepository,
|
||||
IManuscriptTextStatisticsService manuscriptTextStatistics,
|
||||
ICurrentUserService currentUser) : IChapterService
|
||||
{
|
||||
public async Task<ChapterDetailViewModel?> GetDetailAsync(int chapterId)
|
||||
@ -1396,6 +1398,12 @@ public sealed class ChapterService(
|
||||
}
|
||||
|
||||
var sceneRows = await scenes.ListByChapterAsync(chapterId);
|
||||
var textStatisticsByScene = await manuscriptTextStatisticsRepository.ListByChapterAsync(chapterId);
|
||||
foreach (var scene in sceneRows)
|
||||
{
|
||||
scene.TextStatistics = textStatisticsByScene.GetValueOrDefault(scene.SceneID);
|
||||
}
|
||||
|
||||
if (currentUser.UserId is int userId)
|
||||
{
|
||||
var suggestionCounts = await characters.CountSceneCharacterSuggestionsByChapterAsync(chapterId, userId);
|
||||
@ -1418,6 +1426,7 @@ public sealed class ChapterService(
|
||||
Book = book,
|
||||
Chapter = chapter,
|
||||
Scenes = sceneRows,
|
||||
TextStatistics = manuscriptTextStatistics.Aggregate(sceneRows.Select(scene => scene.TextStatistics)),
|
||||
PendingCharacterSuggestionCount = sceneRows.Sum(x => x.PendingCharacterSuggestionCount),
|
||||
PendingAssetSuggestionCount = sceneRows.Sum(x => x.PendingAssetSuggestionCount),
|
||||
PendingLocationSuggestionCount = sceneRows.Sum(x => x.PendingLocationSuggestionCount)
|
||||
@ -1545,6 +1554,7 @@ public sealed class SceneService(
|
||||
IProjectActivityService activity,
|
||||
IStorageService storage,
|
||||
IStoryStateService storyState,
|
||||
IManuscriptTextStatisticsRepository manuscriptTextStatistics,
|
||||
ICurrentUserService currentUser) : ISceneService
|
||||
{
|
||||
public async Task<SceneEditViewModel?> GetCreateAsync(int chapterId)
|
||||
@ -1586,6 +1596,7 @@ public sealed class SceneService(
|
||||
}
|
||||
|
||||
var lookupData = await lookups.GetAllAsync();
|
||||
var textStatistics = await manuscriptTextStatistics.GetBySceneAsync(sceneId);
|
||||
return await PopulateWorkspaceDataAsync(PopulateLists(new SceneEditViewModel
|
||||
{
|
||||
SceneID = scene.SceneID,
|
||||
@ -1609,6 +1620,7 @@ public sealed class SceneService(
|
||||
PrimaryLocationID = scene.PrimaryLocationID,
|
||||
FloorPlanID = scene.FloorPlanID,
|
||||
InitialFloorPlanFloorID = scene.InitialFloorPlanFloorID,
|
||||
TextStatistics = textStatistics,
|
||||
SelectedPurposeTypeIds = scene.SelectedPurposeTypeIds,
|
||||
Metrics = scene.Metrics.Select(metric => new SceneMetricInputViewModel
|
||||
{
|
||||
|
||||
215
PlotLine/Services/ManuscriptTextStatisticsService.cs
Normal file
215
PlotLine/Services/ManuscriptTextStatisticsService.cs
Normal file
@ -0,0 +1,215 @@
|
||||
using PlotLine.Data;
|
||||
using PlotLine.Models;
|
||||
|
||||
namespace PlotLine.Services;
|
||||
|
||||
public interface IManuscriptTextStatisticsService
|
||||
{
|
||||
ManuscriptTextStatistics Calculate(string? sourceText);
|
||||
ManuscriptTextStatistics Aggregate(IEnumerable<ManuscriptTextStatistics?> statistics);
|
||||
Task RecalculateSceneAsync(int sceneId);
|
||||
Task RecalculateChapterAsync(int chapterId);
|
||||
Task RecalculateBookAsync(int bookId);
|
||||
}
|
||||
|
||||
public sealed class ManuscriptTextStatisticsService(
|
||||
IManuscriptTextStatisticsRepository repository,
|
||||
IOnboardingBuildRepository manuscriptSources) : IManuscriptTextStatisticsService
|
||||
{
|
||||
public ManuscriptTextStatistics Calculate(string? sourceText)
|
||||
{
|
||||
var paragraphs = StoryIntelligenceParagraphs.Split(sourceText);
|
||||
var totalWords = 0;
|
||||
var dialogueWords = 0;
|
||||
var proseWords = 0;
|
||||
var dialogueParagraphs = 0;
|
||||
var proseParagraphs = 0;
|
||||
var inDialogue = false;
|
||||
|
||||
foreach (var paragraph in paragraphs)
|
||||
{
|
||||
var paragraphStartInDialogue = inDialogue;
|
||||
var paragraphHasDialogue = paragraphStartInDialogue;
|
||||
var paragraphHasProse = false;
|
||||
var firstContentIndex = FirstNonWhitespaceIndex(paragraph);
|
||||
var segmentStart = 0;
|
||||
|
||||
for (var index = 0; index < paragraph.Length; index++)
|
||||
{
|
||||
var quote = QuoteKind(paragraph[index]);
|
||||
if (quote == ManuscriptQuoteKind.None)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inDialogue && IsContinuationOpeningQuote(quote, index, firstContentIndex))
|
||||
{
|
||||
AddSegment(paragraph, segmentStart, index, inDialogue, ref totalWords, ref dialogueWords, ref proseWords, ref paragraphHasDialogue, ref paragraphHasProse);
|
||||
segmentStart = index + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
AddSegment(paragraph, segmentStart, index, inDialogue, ref totalWords, ref dialogueWords, ref proseWords, ref paragraphHasDialogue, ref paragraphHasProse);
|
||||
|
||||
if (quote == ManuscriptQuoteKind.SmartOpen)
|
||||
{
|
||||
inDialogue = true;
|
||||
paragraphHasDialogue = true;
|
||||
}
|
||||
else if (quote == ManuscriptQuoteKind.SmartClose)
|
||||
{
|
||||
inDialogue = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
inDialogue = !inDialogue;
|
||||
if (inDialogue)
|
||||
{
|
||||
paragraphHasDialogue = true;
|
||||
}
|
||||
}
|
||||
|
||||
segmentStart = index + 1;
|
||||
}
|
||||
|
||||
AddSegment(paragraph, segmentStart, paragraph.Length, inDialogue, ref totalWords, ref dialogueWords, ref proseWords, ref paragraphHasDialogue, ref paragraphHasProse);
|
||||
|
||||
if (paragraphHasDialogue)
|
||||
{
|
||||
dialogueParagraphs++;
|
||||
}
|
||||
|
||||
if (paragraphHasProse)
|
||||
{
|
||||
proseParagraphs++;
|
||||
}
|
||||
}
|
||||
|
||||
return new ManuscriptTextStatistics
|
||||
{
|
||||
TotalWordCount = totalWords,
|
||||
DialogueWordCount = dialogueWords,
|
||||
ProseWordCount = proseWords,
|
||||
TotalParagraphCount = paragraphs.Count,
|
||||
DialogueParagraphCount = dialogueParagraphs,
|
||||
ProseParagraphCount = proseParagraphs
|
||||
};
|
||||
}
|
||||
|
||||
public ManuscriptTextStatistics Aggregate(IEnumerable<ManuscriptTextStatistics?> statistics)
|
||||
{
|
||||
var rows = statistics.Where(row => row is not null).Select(row => row!).ToList();
|
||||
return new ManuscriptTextStatistics
|
||||
{
|
||||
TotalWordCount = rows.Sum(row => row.TotalWordCount),
|
||||
DialogueWordCount = rows.Sum(row => row.DialogueWordCount),
|
||||
ProseWordCount = rows.Sum(row => row.ProseWordCount),
|
||||
TotalParagraphCount = rows.Sum(row => row.TotalParagraphCount),
|
||||
DialogueParagraphCount = rows.Sum(row => row.DialogueParagraphCount),
|
||||
ProseParagraphCount = rows.Sum(row => row.ProseParagraphCount)
|
||||
};
|
||||
}
|
||||
|
||||
public async Task RecalculateSceneAsync(int sceneId)
|
||||
{
|
||||
var source = await repository.GetSourceBySceneAsync(sceneId);
|
||||
if (source is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await repository.SaveAsync(source.SceneID, Calculate(source.SourceText));
|
||||
}
|
||||
|
||||
public async Task RecalculateChapterAsync(int chapterId)
|
||||
{
|
||||
var sources = await manuscriptSources.ListSceneManuscriptSourcesByChapterAsync(chapterId);
|
||||
foreach (var source in sources)
|
||||
{
|
||||
await repository.SaveAsync(source.SceneID, Calculate(source.SourceText));
|
||||
}
|
||||
}
|
||||
|
||||
public async Task RecalculateBookAsync(int bookId)
|
||||
{
|
||||
var sources = await manuscriptSources.ListSceneManuscriptSourcesByBookAsync(bookId);
|
||||
foreach (var source in sources)
|
||||
{
|
||||
await repository.SaveAsync(source.SceneID, Calculate(source.SourceText));
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddSegment(
|
||||
string paragraph,
|
||||
int start,
|
||||
int end,
|
||||
bool isDialogue,
|
||||
ref int totalWords,
|
||||
ref int dialogueWords,
|
||||
ref int proseWords,
|
||||
ref bool paragraphHasDialogue,
|
||||
ref bool paragraphHasProse)
|
||||
{
|
||||
if (end <= start)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var words = CountWords(paragraph[start..end]);
|
||||
if (words == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
totalWords += words;
|
||||
if (isDialogue)
|
||||
{
|
||||
dialogueWords += words;
|
||||
paragraphHasDialogue = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
proseWords += words;
|
||||
paragraphHasProse = true;
|
||||
}
|
||||
}
|
||||
|
||||
private static int CountWords(string value)
|
||||
=> string.IsNullOrWhiteSpace(value)
|
||||
? 0
|
||||
: value.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Length;
|
||||
|
||||
private static int FirstNonWhitespaceIndex(string paragraph)
|
||||
{
|
||||
for (var index = 0; index < paragraph.Length; index++)
|
||||
{
|
||||
if (!char.IsWhiteSpace(paragraph[index]))
|
||||
{
|
||||
return index;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static bool IsContinuationOpeningQuote(ManuscriptQuoteKind quote, int index, int firstContentIndex)
|
||||
=> index == firstContentIndex
|
||||
&& (quote == ManuscriptQuoteKind.SmartOpen || quote == ManuscriptQuoteKind.Straight);
|
||||
|
||||
private static ManuscriptQuoteKind QuoteKind(char value)
|
||||
=> value switch
|
||||
{
|
||||
'"' => ManuscriptQuoteKind.Straight,
|
||||
'\u201c' => ManuscriptQuoteKind.SmartOpen,
|
||||
'\u201d' => ManuscriptQuoteKind.SmartClose,
|
||||
_ => ManuscriptQuoteKind.None
|
||||
};
|
||||
|
||||
private enum ManuscriptQuoteKind
|
||||
{
|
||||
None,
|
||||
Straight,
|
||||
SmartOpen,
|
||||
SmartClose
|
||||
}
|
||||
}
|
||||
@ -40,6 +40,7 @@ public sealed class OnboardingService(
|
||||
ICharacterRepository characters,
|
||||
IOnboardingBuildRepository builds,
|
||||
IManuscriptStructureAnalysisService structureAnalysis,
|
||||
IManuscriptTextStatisticsService manuscriptTextStatistics,
|
||||
ICoreImportCharacterMappingService coreImportCharacterMapping,
|
||||
ICurrentUserService currentUser) : IOnboardingService
|
||||
{
|
||||
@ -745,6 +746,8 @@ public sealed class OnboardingService(
|
||||
WordEndParagraphIndex = scene.WordEndPosition
|
||||
});
|
||||
|
||||
await manuscriptTextStatistics.RecalculateSceneAsync(sceneId);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(scene.StructuralSummary))
|
||||
{
|
||||
await builds.SaveSceneStructuralSummaryAsync(sceneId, scene.StructuralSummary);
|
||||
|
||||
125
PlotLine/Sql/164_Phase25I_SceneTextStatistics.sql
Normal file
125
PlotLine/Sql/164_Phase25I_SceneTextStatistics.sql
Normal file
@ -0,0 +1,125 @@
|
||||
SET ANSI_NULLS ON;
|
||||
GO
|
||||
SET QUOTED_IDENTIFIER ON;
|
||||
GO
|
||||
|
||||
IF OBJECT_ID(N'dbo.SceneTextStatistics', N'U') IS NULL
|
||||
BEGIN
|
||||
CREATE TABLE dbo.SceneTextStatistics
|
||||
(
|
||||
SceneID int NOT NULL CONSTRAINT PK_SceneTextStatistics PRIMARY KEY,
|
||||
TotalWordCount int NOT NULL CONSTRAINT DF_SceneTextStatistics_TotalWordCount DEFAULT 0,
|
||||
DialogueWordCount int NOT NULL CONSTRAINT DF_SceneTextStatistics_DialogueWordCount DEFAULT 0,
|
||||
ProseWordCount int NOT NULL CONSTRAINT DF_SceneTextStatistics_ProseWordCount DEFAULT 0,
|
||||
TotalParagraphCount int NOT NULL CONSTRAINT DF_SceneTextStatistics_TotalParagraphCount DEFAULT 0,
|
||||
DialogueParagraphCount int NOT NULL CONSTRAINT DF_SceneTextStatistics_DialogueParagraphCount DEFAULT 0,
|
||||
ProseParagraphCount int NOT NULL CONSTRAINT DF_SceneTextStatistics_ProseParagraphCount DEFAULT 0,
|
||||
CalculatedUtc datetime2 NOT NULL CONSTRAINT DF_SceneTextStatistics_CalculatedUtc DEFAULT SYSUTCDATETIME(),
|
||||
UpdatedUtc datetime2 NOT NULL CONSTRAINT DF_SceneTextStatistics_UpdatedUtc DEFAULT SYSUTCDATETIME(),
|
||||
CONSTRAINT CK_SceneTextStatistics_NonNegative CHECK
|
||||
(
|
||||
TotalWordCount >= 0
|
||||
AND DialogueWordCount >= 0
|
||||
AND ProseWordCount >= 0
|
||||
AND TotalParagraphCount >= 0
|
||||
AND DialogueParagraphCount >= 0
|
||||
AND ProseParagraphCount >= 0
|
||||
),
|
||||
CONSTRAINT CK_SceneTextStatistics_WordReconciliation CHECK (DialogueWordCount + ProseWordCount = TotalWordCount),
|
||||
CONSTRAINT FK_SceneTextStatistics_Scenes FOREIGN KEY (SceneID) REFERENCES dbo.Scenes(SceneID) ON DELETE CASCADE
|
||||
);
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.SceneTextStatistics_Save
|
||||
@SceneID int,
|
||||
@TotalWordCount int,
|
||||
@DialogueWordCount int,
|
||||
@ProseWordCount int,
|
||||
@TotalParagraphCount int,
|
||||
@DialogueParagraphCount int,
|
||||
@ProseParagraphCount int
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
IF @DialogueWordCount + @ProseWordCount <> @TotalWordCount
|
||||
THROW 51000, 'Dialogue and prose word counts must reconcile to the total word count.', 1;
|
||||
|
||||
MERGE dbo.SceneTextStatistics AS target
|
||||
USING (SELECT @SceneID AS SceneID) AS source
|
||||
ON target.SceneID = source.SceneID
|
||||
WHEN MATCHED THEN
|
||||
UPDATE SET TotalWordCount = @TotalWordCount,
|
||||
DialogueWordCount = @DialogueWordCount,
|
||||
ProseWordCount = @ProseWordCount,
|
||||
TotalParagraphCount = @TotalParagraphCount,
|
||||
DialogueParagraphCount = @DialogueParagraphCount,
|
||||
ProseParagraphCount = @ProseParagraphCount,
|
||||
CalculatedUtc = SYSUTCDATETIME(),
|
||||
UpdatedUtc = SYSUTCDATETIME()
|
||||
WHEN NOT MATCHED THEN
|
||||
INSERT
|
||||
(
|
||||
SceneID, TotalWordCount, DialogueWordCount, ProseWordCount,
|
||||
TotalParagraphCount, DialogueParagraphCount, ProseParagraphCount
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
@SceneID, @TotalWordCount, @DialogueWordCount, @ProseWordCount,
|
||||
@TotalParagraphCount, @DialogueParagraphCount, @ProseParagraphCount
|
||||
);
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.SceneTextStatistics_GetByScene
|
||||
@SceneID int
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
SELECT TotalWordCount, DialogueWordCount, ProseWordCount,
|
||||
TotalParagraphCount, DialogueParagraphCount, ProseParagraphCount
|
||||
FROM dbo.SceneTextStatistics
|
||||
WHERE SceneID = @SceneID;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.SceneTextStatistics_ListByChapter
|
||||
@ChapterID int
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
SELECT stats.SceneID, stats.TotalWordCount, stats.DialogueWordCount, stats.ProseWordCount,
|
||||
stats.TotalParagraphCount, stats.DialogueParagraphCount, stats.ProseParagraphCount
|
||||
FROM dbo.SceneTextStatistics stats
|
||||
INNER JOIN dbo.Scenes scene ON scene.SceneID = stats.SceneID
|
||||
WHERE scene.ChapterID = @ChapterID
|
||||
AND scene.IsArchived = 0
|
||||
ORDER BY scene.SortOrder, scene.SceneNumber, scene.SceneID;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.SceneTextStatistics_ListMissingByBook
|
||||
@BookID int
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
SELECT source.SceneID, source.ProjectID, source.BookID, source.ChapterID,
|
||||
source.SourceWordCount, source.SourceCharacterCount, source.SourceKind,
|
||||
source.CreatedUtc, source.UpdatedUtc
|
||||
FROM dbo.SceneManuscriptSources source
|
||||
INNER JOIN dbo.Scenes scene ON scene.SceneID = source.SceneID
|
||||
WHERE source.BookID = @BookID
|
||||
AND scene.IsArchived = 0
|
||||
AND NOT EXISTS
|
||||
(
|
||||
SELECT 1
|
||||
FROM dbo.SceneTextStatistics stats
|
||||
WHERE stats.SceneID = source.SceneID
|
||||
)
|
||||
ORDER BY source.ChapterID, scene.SortOrder, scene.SceneNumber, scene.SceneID;
|
||||
END;
|
||||
GO
|
||||
@ -292,6 +292,7 @@ public sealed class ChapterDetailViewModel
|
||||
public Book Book { get; set; } = new();
|
||||
public Chapter Chapter { get; set; } = new();
|
||||
public IReadOnlyList<Scene> Scenes { get; set; } = [];
|
||||
public ManuscriptTextStatistics? TextStatistics { get; set; }
|
||||
public int PendingCharacterSuggestionCount { get; set; }
|
||||
public int PendingAssetSuggestionCount { get; set; }
|
||||
public int PendingLocationSuggestionCount { get; set; }
|
||||
@ -357,6 +358,8 @@ public sealed class SceneEditViewModel
|
||||
[Display(Name = "Initial floor")]
|
||||
public int? InitialFloorPlanFloorID { get; set; }
|
||||
|
||||
public ManuscriptTextStatistics? TextStatistics { get; set; }
|
||||
|
||||
[Display(Name = "Scene purposes")]
|
||||
public List<int> SelectedPurposeTypeIds { get; set; } = [];
|
||||
|
||||
|
||||
@ -76,6 +76,10 @@
|
||||
|
||||
<section class="list-section">
|
||||
<h2>Scenes <help-icon key="chapters.scenes" /></h2>
|
||||
@if (Model.TextStatistics is not null && Model.TextStatistics.TotalWordCount > 0)
|
||||
{
|
||||
<partial name="~/Views/Shared/_ManuscriptTextBalance.cshtml" model="Model.TextStatistics" />
|
||||
}
|
||||
@if (!Model.Scenes.Any())
|
||||
{
|
||||
<p class="muted">No scenes yet.</p>
|
||||
|
||||
@ -54,6 +54,11 @@
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
@if (Model.TextStatistics is not null && Model.TextStatistics.TotalWordCount > 0)
|
||||
{
|
||||
<partial name="~/Views/Shared/_ManuscriptTextBalance.cshtml" model="Model.TextStatistics" />
|
||||
}
|
||||
|
||||
<div class="scene-inspector-v2-summary scene-overview-v2-summary">
|
||||
<span>Synopsis</span>
|
||||
@if (string.IsNullOrWhiteSpace(Model.Summary))
|
||||
|
||||
48
PlotLine/Views/Shared/_ManuscriptTextBalance.cshtml
Normal file
48
PlotLine/Views/Shared/_ManuscriptTextBalance.cshtml
Normal file
@ -0,0 +1,48 @@
|
||||
@model PlotLine.Models.ManuscriptTextStatistics
|
||||
@{
|
||||
var dialoguePercent = Math.Clamp(Model.DialoguePercentage, 0m, 100m);
|
||||
var balanceLabel = BalanceLabel(dialoguePercent);
|
||||
}
|
||||
|
||||
<div class="manuscript-balance" aria-label="Dialogue and prose balance">
|
||||
<div class="manuscript-balance__heading">
|
||||
<span>@balanceLabel</span>
|
||||
<strong>@dialoguePercent.ToString("0.#")% dialogue</strong>
|
||||
</div>
|
||||
<div class="manuscript-balance__scale" aria-hidden="true">
|
||||
<span>Prose-heavy</span>
|
||||
<div class="manuscript-balance__track">
|
||||
<i style="left: @dialoguePercent.ToString("0.#")%"></i>
|
||||
</div>
|
||||
<span>Dialogue-heavy</span>
|
||||
</div>
|
||||
<p>
|
||||
@Model.TotalWordCount.ToString("N0") words · @Model.DialogueWordCount.ToString("N0") dialogue · @Model.ProseWordCount.ToString("N0") prose
|
||||
@if (Model.TotalParagraphCount > 0)
|
||||
{
|
||||
<span> · @Model.TotalParagraphCount.ToString("N0") paragraphs · @Model.DialogueParagraphCount.ToString("N0") dialogue</span>
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@functions {
|
||||
private static string BalanceLabel(decimal dialoguePercent)
|
||||
{
|
||||
if (dialoguePercent < 25m)
|
||||
{
|
||||
return "Very prose-heavy";
|
||||
}
|
||||
|
||||
if (dialoguePercent < 45m)
|
||||
{
|
||||
return "Prose-heavy";
|
||||
}
|
||||
|
||||
if (dialoguePercent <= 55m)
|
||||
{
|
||||
return "Balanced";
|
||||
}
|
||||
|
||||
return dialoguePercent <= 75m ? "Dialogue-heavy" : "Very dialogue-heavy";
|
||||
}
|
||||
}
|
||||
@ -5468,6 +5468,73 @@ body.dragging-location [data-drag-type="location"] {
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.manuscript-balance {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
border: 1px solid rgba(47, 111, 99, 0.13);
|
||||
border-radius: 8px;
|
||||
background: rgba(47, 111, 99, 0.045);
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.manuscript-balance__heading,
|
||||
.manuscript-balance__scale {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.manuscript-balance__heading span {
|
||||
color: var(--plotline-accent-dark);
|
||||
font-size: 0.84rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.manuscript-balance__heading strong {
|
||||
color: var(--plotline-ink);
|
||||
font-size: 0.92rem;
|
||||
font-weight: 900;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.manuscript-balance__scale span {
|
||||
flex: 0 0 auto;
|
||||
color: var(--plotline-muted);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.manuscript-balance__track {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
min-width: 72px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(90deg, rgba(97, 107, 88, 0.55), rgba(47, 111, 99, 0.66));
|
||||
}
|
||||
|
||||
.manuscript-balance__track i {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid var(--plotline-surface-elevated);
|
||||
border-radius: 999px;
|
||||
background: var(--plotline-accent-dark);
|
||||
box-shadow: 0 2px 5px rgba(36, 31, 27, 0.2);
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.manuscript-balance p {
|
||||
margin: 0;
|
||||
color: var(--plotline-muted);
|
||||
font-size: 0.84rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.scene-inspector-editor-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user