Refine onboarding review wizard steps

This commit is contained in:
Nick Beckley 2026-08-14 18:31:27 +00:00
parent bfe729e8c0
commit 54bcd56df4
7 changed files with 115 additions and 45 deletions

View File

@ -1275,7 +1275,8 @@ static void ScanReviewAliasesFeedStoryIntelligenceKnownCharacterContext()
new() { TemporaryCharacterKey = "c-maggie", Name = "Maggie", MentionCount = 8, QualityScore = 94, Category = "ProbableCharacter" }, new() { TemporaryCharacterKey = "c-maggie", Name = "Maggie", MentionCount = 8, QualityScore = 94, Category = "ProbableCharacter" },
new() { TemporaryCharacterKey = "c-margaret", Name = "Margaret", MentionCount = 3, QualityScore = 88, Category = "ProbableCharacter" }, new() { TemporaryCharacterKey = "c-margaret", Name = "Margaret", MentionCount = 3, QualityScore = 88, Category = "ProbableCharacter" },
new() { TemporaryCharacterKey = "c-mags", Name = "Mags", MentionCount = 2, QualityScore = 78, Category = "PossibleCharacter" }, new() { TemporaryCharacterKey = "c-mags", Name = "Mags", MentionCount = 2, QualityScore = 78, Category = "PossibleCharacter" },
new() { TemporaryCharacterKey = "c-kevin", Name = "Kevin", MentionCount = 4, QualityScore = 90, Category = "ProbableCharacter" } new() { TemporaryCharacterKey = "c-kevin", Name = "Kevin", MentionCount = 4, QualityScore = 90, Category = "ProbableCharacter" },
new() { TemporaryCharacterKey = "c-road", Name = "Road", MentionCount = 10, QualityScore = 15, Category = "Excluded" }
] ]
}; };
var review = new ManuscriptScanReviewDecision var review = new ManuscriptScanReviewDecision
@ -1285,7 +1286,8 @@ static void ScanReviewAliasesFeedStoryIntelligenceKnownCharacterContext()
new() { TemporaryCharacterKey = "c-maggie", Include = true, Name = "Maggie", Category = "ProbableCharacter", ExistingCharacterID = 123 }, new() { TemporaryCharacterKey = "c-maggie", Include = true, Name = "Maggie", Category = "ProbableCharacter", ExistingCharacterID = 123 },
new() { TemporaryCharacterKey = "c-margaret", Include = true, Name = "Margaret", Category = "ProbableCharacter", AliasTargetTemporaryCharacterKey = "c-maggie" }, new() { TemporaryCharacterKey = "c-margaret", Include = true, Name = "Margaret", Category = "ProbableCharacter", AliasTargetTemporaryCharacterKey = "c-maggie" },
new() { TemporaryCharacterKey = "c-mags", Include = true, Name = "Mags", Category = "PossibleCharacter", AliasTargetTemporaryCharacterKey = "c-maggie" }, new() { TemporaryCharacterKey = "c-mags", Include = true, Name = "Mags", Category = "PossibleCharacter", AliasTargetTemporaryCharacterKey = "c-maggie" },
new() { TemporaryCharacterKey = "c-kevin", Include = true, Name = "Kevin", Category = "ProbableCharacter" } new() { TemporaryCharacterKey = "c-kevin", Include = true, Name = "Kevin", Category = "ProbableCharacter" },
new() { TemporaryCharacterKey = "c-road", Include = true, Name = "Road", Category = "Excluded" }
] ]
}; };
@ -1300,6 +1302,7 @@ static void ScanReviewAliasesFeedStoryIntelligenceKnownCharacterContext()
Assert(maggie.MentionCount == 13, $"Alias mention counts should accumulate, got {maggie.MentionCount}."); Assert(maggie.MentionCount == 13, $"Alias mention counts should accumulate, got {maggie.MentionCount}.");
Assert(context.Any(item => item.CanonicalName == "Kevin"), "Other included canonical candidates should remain available."); Assert(context.Any(item => item.CanonicalName == "Kevin"), "Other included canonical candidates should remain available.");
Assert(!context.Any(item => item.CanonicalName == "Margaret"), "Alias candidates should not become separate canonical context records."); Assert(!context.Any(item => item.CanonicalName == "Margaret"), "Alias candidates should not become separate canonical context records.");
Assert(!context.Any(item => item.CanonicalName == "Road"), "Excluded scan candidates should not guide Story Intelligence.");
} }
static JsonSerializerOptions JsonOptions() static JsonSerializerOptions JsonOptions()

View File

@ -530,7 +530,7 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
} }
TempData["OnboardingReviewMessage"] = readyToImport TempData["OnboardingReviewMessage"] = readyToImport
? "Review saved. Next: prepare chapters and analyse manuscript." ? "Review saved. Next: story and portrait context."
: continueToCharacters ? "Chapter and scene review saved. Next: review detected characters." : continueToCharacters ? "Chapter and scene review saved. Next: review detected characters."
: continueCharacterReview || backCharacterReview ? "Character review choices saved." : continueCharacterReview || backCharacterReview ? "Character review choices saved."
: "Review choices saved."; : "Review choices saved.";
@ -552,13 +552,63 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
if (readyToImport || continueCharacterReview || backCharacterReview) if (readyToImport || continueCharacterReview || backCharacterReview)
{ {
ViewData["ScanReviewStep"] = "characters"; ViewData["ScanReviewStep"] = "characters";
ViewData["CharacterReviewPage"] = Math.Max(1, characterPage); ViewData["CharacterReviewPage"] = readyToImport
? FirstInvalidCharacterPage(model)
: Math.Max(1, characterPage);
} }
return View("ScanReview", model); return View("ScanReview", model);
} }
} }
private static int FirstInvalidCharacterPage(ManuscriptScanReviewViewModel model)
{
const int pageSize = 10;
var characters = model.CharacterCandidates
.Where(character => !string.Equals(character.Category, "Excluded", StringComparison.OrdinalIgnoreCase))
.OrderBy(character => character.ReviewName)
.ThenBy(character => character.Name)
.ToList();
var canonicalNames = characters
.Where(IsIncludedCanonicalCharacter)
.Select(character => character.ReviewName.Trim())
.Where(name => !string.IsNullOrWhiteSpace(name))
.GroupBy(name => name, StringComparer.OrdinalIgnoreCase)
.Where(group => group.Count() > 1)
.SelectMany(group => group)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
for (var index = 0; index < characters.Count; index++)
{
var character = characters[index];
if (character.Include && string.IsNullOrWhiteSpace(character.ReviewName))
{
return (index / pageSize) + 1;
}
if (IsIncludedCanonicalCharacter(character) && canonicalNames.Contains(character.ReviewName.Trim()))
{
return (index / pageSize) + 1;
}
if (character.Include && !string.IsNullOrWhiteSpace(character.AliasTargetTemporaryCharacterKey))
{
var target = characters.FirstOrDefault(candidate => string.Equals(candidate.TemporaryCharacterKey, character.AliasTargetTemporaryCharacterKey, StringComparison.Ordinal));
if (target is null || !IsIncludedCanonicalCharacter(target))
{
return (index / pageSize) + 1;
}
}
}
return 1;
}
private static bool IsIncludedCanonicalCharacter(ManuscriptScanReviewCharacterViewModel character)
=> character.Include
&& string.IsNullOrWhiteSpace(character.AliasTargetTemporaryCharacterKey)
&& !string.Equals(character.Category, "Excluded", StringComparison.OrdinalIgnoreCase);
[HttpPost("welcome")] [HttpPost("welcome")]
[ValidateAntiForgeryToken] [ValidateAntiForgeryToken]
public async Task<IActionResult> Welcome() public async Task<IActionResult> Welcome()

View File

@ -67,9 +67,7 @@ public sealed class OnboardingStoryIntelligenceService(
var existingBatch = await batchStore.GetLatestForPreviewAsync(userId, preview.PreviewID); var existingBatch = await batchStore.GetLatestForPreviewAsync(userId, preview.PreviewID);
var missingTextCount = included.Count(chapter => string.IsNullOrWhiteSpace(chapter.Preview.ChapterText)); var missingTextCount = included.Count(chapter => string.IsNullOrWhiteSpace(chapter.Preview.ChapterText));
var primaryCharacterCount = preview.CharacterCandidates.Count(candidate => !string.Equals(candidate.Category, "Excluded", StringComparison.OrdinalIgnoreCase)); var primaryCharacterCount = preview.CharacterCandidates.Count(candidate => !string.Equals(candidate.Category, "Excluded", StringComparison.OrdinalIgnoreCase));
var excludedCharacterCount = preview.CharacterCandidates.Count(candidate => string.Equals(candidate.Category, "Excluded", StringComparison.OrdinalIgnoreCase)); var characterPageCount = Math.Max(1, (int)Math.Ceiling(primaryCharacterCount / 10d));
var characterPageCount = Math.Max(1, (int)Math.Ceiling(primaryCharacterCount / 10d))
+ (excludedCharacterCount > 0 ? (int)Math.Ceiling(excludedCharacterCount / 10d) : 0);
var totalWizardSteps = 7 + characterPageCount + 2; var totalWizardSteps = 7 + characterPageCount + 2;
return new StoryIntelligenceOverviewViewModel return new StoryIntelligenceOverviewViewModel
{ {

View File

@ -10,37 +10,47 @@
var relationshipTitles = Model.CharacterCandidates var relationshipTitles = Model.CharacterCandidates
.Where(candidate => string.Equals(candidate.Category, "RelationshipTitle", StringComparison.OrdinalIgnoreCase)) .Where(candidate => string.Equals(candidate.Category, "RelationshipTitle", StringComparison.OrdinalIgnoreCase))
.ToList(); .ToList();
var excludedCharacters = Model.CharacterCandidates
.Where(candidate => string.Equals(candidate.Category, "Excluded", StringComparison.OrdinalIgnoreCase))
.ToList();
var primaryCharacters = probableCharacters var primaryCharacters = probableCharacters
.Concat(possibleCharacters) .Concat(possibleCharacters)
.Concat(relationshipTitles) .Concat(relationshipTitles)
.OrderBy(candidate => candidate.ReviewName)
.ThenBy(candidate => candidate.Name)
.ToList(); .ToList();
var detectedAliasTargets = Model.CharacterCandidates var detectedAliasTargets = primaryCharacters
.Where(candidate => !string.Equals(candidate.Category, "Excluded", StringComparison.OrdinalIgnoreCase))
.OrderBy(candidate => candidate.ReviewName) .OrderBy(candidate => candidate.ReviewName)
.ToList(); .ToList();
var visibleCharacterCount = probableCharacters.Count + possibleCharacters.Count + relationshipTitles.Count; var visibleCharacterCount = probableCharacters.Count + possibleCharacters.Count + relationshipTitles.Count;
var isCharacterStep = string.Equals(ViewData["ScanReviewStep"] as string, "characters", StringComparison.OrdinalIgnoreCase); var isCharacterStep = string.Equals(ViewData["ScanReviewStep"] as string, "characters", StringComparison.OrdinalIgnoreCase);
const int characterPageSize = 10; const int characterPageSize = 10;
var primaryCharacterPageCount = Math.Max(1, (int)Math.Ceiling(primaryCharacters.Count / (double)characterPageSize)); var primaryCharacterPageCount = Math.Max(1, (int)Math.Ceiling(primaryCharacters.Count / (double)characterPageSize));
var excludedCharacterPageCount = excludedCharacters.Any() var characterReviewPageCount = primaryCharacterPageCount;
? (int)Math.Ceiling(excludedCharacters.Count / (double)characterPageSize)
: 0;
var characterReviewPageCount = primaryCharacterPageCount + excludedCharacterPageCount;
var requestedCharacterPage = ViewData["CharacterReviewPage"] is int requestedPage ? requestedPage : 1; var requestedCharacterPage = ViewData["CharacterReviewPage"] is int requestedPage ? requestedPage : 1;
var characterPage = Math.Clamp(requestedCharacterPage, 1, characterReviewPageCount); var characterPage = Math.Clamp(requestedCharacterPage, 1, characterReviewPageCount);
var isExcludedCharacterPage = excludedCharacterPageCount > 0 && characterPage > primaryCharacterPageCount; var currentCharacters = primaryCharacters.Skip((characterPage - 1) * characterPageSize).Take(characterPageSize).ToList();
var excludedPageIndex = isExcludedCharacterPage ? characterPage - primaryCharacterPageCount : 0;
var currentCharacters = isExcludedCharacterPage
? excludedCharacters.Skip((excludedPageIndex - 1) * characterPageSize).Take(characterPageSize).ToList()
: primaryCharacters.Skip((characterPage - 1) * characterPageSize).Take(characterPageSize).ToList();
var currentStep = isCharacterStep ? 7 + characterPage : 7; var currentStep = isCharacterStep ? 7 + characterPage : 7;
var totalSteps = 7 + characterReviewPageCount + 2; var totalSteps = 7 + characterReviewPageCount + 2;
var characterPageTitle = isExcludedCharacterPage var characterPageTitle = $"Review Characters {characterPage} of {primaryCharacterPageCount}";
? "Review Excluded Characters" var duplicateCanonicalNames = primaryCharacters
: $"Review Characters {characterPage} of {primaryCharacterPageCount}"; .Where(candidate => candidate.Include && string.IsNullOrWhiteSpace(candidate.AliasTargetTemporaryCharacterKey))
.Select(candidate => candidate.ReviewName.Trim())
.Where(name => !string.IsNullOrWhiteSpace(name))
.GroupBy(name => name, StringComparer.OrdinalIgnoreCase)
.Where(group => group.Count() > 1)
.Select(group => group.Key)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var includedPrimaryKeys = primaryCharacters
.Where(candidate => candidate.Include && string.IsNullOrWhiteSpace(candidate.AliasTargetTemporaryCharacterKey))
.Select(candidate => candidate.TemporaryCharacterKey)
.ToHashSet(StringComparer.Ordinal);
var invalidCharacterKeys = primaryCharacters
.Where(candidate =>
(candidate.Include && string.IsNullOrWhiteSpace(candidate.ReviewName))
|| (candidate.Include && string.IsNullOrWhiteSpace(candidate.AliasTargetTemporaryCharacterKey) && duplicateCanonicalNames.Contains(candidate.ReviewName.Trim()))
|| (candidate.Include
&& !string.IsNullOrWhiteSpace(candidate.AliasTargetTemporaryCharacterKey)
&& !includedPrimaryKeys.Contains(candidate.AliasTargetTemporaryCharacterKey)))
.Select(candidate => candidate.TemporaryCharacterKey)
.ToHashSet(StringComparer.Ordinal);
} }
<section class="onboarding-shell" aria-labelledby="scan-review-title"> <section class="onboarding-shell" aria-labelledby="scan-review-title">
@ -79,7 +89,7 @@
</div> </div>
<div> <div>
<span>Characters</span> <span>Characters</span>
<strong>@Model.SelectedCharacterCount / @Model.CharacterCandidateCount</strong> <strong>@Model.SelectedCharacterCount / @visibleCharacterCount</strong>
</div> </div>
<div> <div>
<span>Words</span> <span>Words</span>
@ -114,7 +124,7 @@
} }
@foreach (var chapter in Model.Chapters) @foreach (var chapter in Model.Chapters)
{ {
<details class="onboarding-review-chapter" open> <details class="onboarding-review-chapter">
<summary> <summary>
<span>Chapter @chapter.ChapterNumber</span> <span>Chapter @chapter.ChapterNumber</span>
<strong>@chapter.ReviewTitle</strong> <strong>@chapter.ReviewTitle</strong>
@ -187,9 +197,7 @@
<div> <div>
<h2>@characterPageTitle</h2> <h2>@characterPageTitle</h2>
<p> <p>
@(isExcludedCharacterPage Character candidates page @characterPage of @primaryCharacterPageCount.
? $"Excluded candidates page {excludedPageIndex} of {excludedCharacterPageCount}."
: $"Character candidates page {characterPage} of {primaryCharacterPageCount}.")
</p> </p>
</div> </div>
<strong>@currentCharacters.Count character@(currentCharacters.Count == 1 ? string.Empty : "s") on this step</strong> <strong>@currentCharacters.Count character@(currentCharacters.Count == 1 ? string.Empty : "s") on this step</strong>
@ -200,10 +208,6 @@
<span>Primary candidates</span> <span>Primary candidates</span>
<strong>@visibleCharacterCount.ToString("N0")</strong> <strong>@visibleCharacterCount.ToString("N0")</strong>
</div> </div>
<div>
<span>Excluded</span>
<strong>@excludedCharacters.Count.ToString("N0")</strong>
</div>
<div> <div>
<span>Wizard pages</span> <span>Wizard pages</span>
<strong>@characterReviewPageCount.ToString("N0")</strong> <strong>@characterReviewPageCount.ToString("N0")</strong>
@ -211,19 +215,22 @@
</div> </div>
@{ @{
var allCharacters = Model.CharacterCandidates.ToList(); var allCharacters = primaryCharacters.ToList();
} }
@if (!currentCharacters.Any()) @if (!currentCharacters.Any())
{ {
<p class="onboarding-empty-state">No character candidates were found for this step.</p> <p class="onboarding-empty-state">No character candidates were found for this step.</p>
} }
@if (invalidCharacterKeys.Any(key => currentCharacters.Any(candidate => string.Equals(candidate.TemporaryCharacterKey, key, StringComparison.Ordinal))))
{
<p class="onboarding-empty-state onboarding-empty-state--error">The item that needs attention is highlighted below.</p>
}
<div class="onboarding-character-groups"> <div class="onboarding-character-groups">
@foreach (var group in new[] @foreach (var group in new[]
{ {
new { Title = "Probable characters", Candidates = currentCharacters.Where(candidate => string.Equals(candidate.Category, "ProbableCharacter", StringComparison.OrdinalIgnoreCase)).ToList() }, new { Title = "Probable characters", Candidates = currentCharacters.Where(candidate => string.Equals(candidate.Category, "ProbableCharacter", StringComparison.OrdinalIgnoreCase)).ToList() },
new { Title = "Possible characters", Candidates = currentCharacters.Where(candidate => string.Equals(candidate.Category, "PossibleCharacter", StringComparison.OrdinalIgnoreCase)).ToList() }, new { Title = "Possible characters", Candidates = currentCharacters.Where(candidate => string.Equals(candidate.Category, "PossibleCharacter", StringComparison.OrdinalIgnoreCase)).ToList() },
new { Title = "Relationship titles", Candidates = currentCharacters.Where(candidate => string.Equals(candidate.Category, "RelationshipTitle", StringComparison.OrdinalIgnoreCase)).ToList() }, new { Title = "Relationship titles", Candidates = currentCharacters.Where(candidate => string.Equals(candidate.Category, "RelationshipTitle", StringComparison.OrdinalIgnoreCase)).ToList() }
new { Title = "Excluded candidates", Candidates = currentCharacters.Where(candidate => string.Equals(candidate.Category, "Excluded", StringComparison.OrdinalIgnoreCase)).ToList() }
}) })
{ {
if (!group.Candidates.Any()) if (!group.Candidates.Any())
@ -237,7 +244,7 @@
@foreach (var candidate in group.Candidates) @foreach (var candidate in group.Candidates)
{ {
var currentCharacterIndex = allCharacters.FindIndex(item => string.Equals(item.TemporaryCharacterKey, candidate.TemporaryCharacterKey, StringComparison.Ordinal)); var currentCharacterIndex = allCharacters.FindIndex(item => string.Equals(item.TemporaryCharacterKey, candidate.TemporaryCharacterKey, StringComparison.Ordinal));
<li> <li class="@(invalidCharacterKeys.Contains(candidate.TemporaryCharacterKey) ? "is-invalid" : string.Empty)">
<input type="hidden" name="Characters[@currentCharacterIndex].TemporaryCharacterKey" value="@candidate.TemporaryCharacterKey" /> <input type="hidden" name="Characters[@currentCharacterIndex].TemporaryCharacterKey" value="@candidate.TemporaryCharacterKey" />
<input type="hidden" name="Characters[@currentCharacterIndex].Category" value="@candidate.Category" /> <input type="hidden" name="Characters[@currentCharacterIndex].Category" value="@candidate.Category" />
<label class="onboarding-review-toggle"> <label class="onboarding-review-toggle">

View File

@ -1,6 +1,6 @@
@model StoryIntelligenceOverviewViewModel @model StoryIntelligenceOverviewViewModel
@{ @{
ViewData["Title"] = "Analyse manuscript"; ViewData["Title"] = "Story and portrait context";
} }
<section class="onboarding-shell" aria-labelledby="story-intelligence-title"> <section class="onboarding-shell" aria-labelledby="story-intelligence-title">
@ -8,8 +8,8 @@
<partial name="_OnboardingJourneyHeader" model="@(new OnboardingJourneyHeaderViewModel { CurrentStep = Model.WizardCurrentStep, TotalSteps = Model.WizardTotalSteps })" /> <partial name="_OnboardingJourneyHeader" model="@(new OnboardingJourneyHeaderViewModel { CurrentStep = Model.WizardCurrentStep, TotalSteps = Model.WizardTotalSteps })" />
<div class="onboarding-copy"> <div class="onboarding-copy">
<p class="eyebrow">Step @Model.WizardCurrentStep of @Model.WizardTotalSteps</p> <p class="eyebrow">Step @Model.WizardCurrentStep of @Model.WizardTotalSteps</p>
<h1 id="story-intelligence-title">Analyse Manuscript</h1> <h1 id="story-intelligence-title">Story and Portrait Context</h1>
<p>Set the analysis context, then PlotDirector will read the approved chapters and prepare scenes, characters, locations, assets, relationships and knowledge for review.</p> <p>Set the context choices for this manuscript before the analysis starts.</p>
</div> </div>
@if (TempData["OnboardingStoryIntelligenceError"] is string error) @if (TempData["OnboardingStoryIntelligenceError"] is string error)
@ -73,8 +73,8 @@
<form asp-action="StartStoryIntelligence" method="post" class="onboarding-analysis-setup"> <form asp-action="StartStoryIntelligence" method="post" class="onboarding-analysis-setup">
<div class="onboarding-analysis-setup__header"> <div class="onboarding-analysis-setup__header">
<div> <div>
<p class="eyebrow">Analysis setup</p> <p class="eyebrow">Pre-analysis setup</p>
<h2>Story and portrait context</h2> <h2>Context choices</h2>
<p>Explicit manuscript evidence always wins. These choices guide unresolved generated portraits and broad story context.</p> <p>Explicit manuscript evidence always wins. These choices guide unresolved generated portraits and broad story context.</p>
</div> </div>
<strong>Required before analysis</strong> <strong>Required before analysis</strong>
@ -144,7 +144,7 @@
<div class="onboarding-actions"> <div class="onboarding-actions">
<a class="btn btn-outline-secondary" asp-action="ScanReview" asp-route-previewId="@Model.PreviewID">Back to scan review</a> <a class="btn btn-outline-secondary" asp-action="ScanReview" asp-route-previewId="@Model.PreviewID">Back to scan review</a>
<button class="btn btn-primary" type="submit" disabled="@(!Model.CanStart)">Analyse manuscript</button> <button class="btn btn-primary" type="submit" disabled="@(!Model.CanStart)">Save context and analyse manuscript</button>
</div> </div>
</form> </form>

View File

@ -38,7 +38,7 @@
new("Connect Word", 6), new("Connect Word", 6),
new("Review Chapters", 7), new("Review Chapters", 7),
new("Review Characters", 8), new("Review Characters", 8),
new("Analyse Manuscript", 9), new("Story Context", 9),
new("Complete", 10) new("Complete", 10)
]; ];
@ -60,7 +60,7 @@
steps.Add(new HeaderStep($"Characters {index}", 7 + index)); steps.Add(new HeaderStep($"Characters {index}", 7 + index));
} }
steps.Add(new HeaderStep("Analyse Manuscript", totalSteps - 1)); steps.Add(new HeaderStep("Story Context", totalSteps - 1));
steps.Add(new HeaderStep("Complete", totalSteps)); steps.Add(new HeaderStep("Complete", totalSteps));
return steps; return steps;
} }

View File

@ -530,6 +530,12 @@
background: rgba(255, 255, 255, .55); background: rgba(255, 255, 255, .55);
} }
.onboarding-empty-state--error {
border-color: rgba(176, 54, 54, .38);
color: #8f2f2f;
background: rgba(176, 54, 54, .08);
}
.onboarding-review-chapters { .onboarding-review-chapters {
display: grid; display: grid;
gap: .85rem; gap: .85rem;
@ -574,6 +580,12 @@
background: rgba(255, 255, 255, .64); background: rgba(255, 255, 255, .64);
} }
.onboarding-character-candidates li.is-invalid {
border-color: rgba(176, 54, 54, .48);
box-shadow: inset 0 0 0 1px rgba(176, 54, 54, .16);
background: rgba(176, 54, 54, .06);
}
.onboarding-review-chapter li { .onboarding-review-chapter li {
display: grid; display: grid;
gap: .45rem; gap: .45rem;