Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions .squad/agents/kaylee/history.md
Original file line number Diff line number Diff line change
Expand Up @@ -298,3 +298,64 @@ private async Task OnInitialSyncCompleted()

**For future Blazor work:**
If you encounter "my JS component initialized on nav-back but not on first-load," check for async mode flags + firstRender gates. This pattern resolves it.

## Learnings — 2026-05-02 — Vocab Quiz UI cluster (Stream A) — PR #196

Shipped four UI fixes in `src/SentenceStudio.UI/Pages/VocabQuiz.razor` as a single PR.
- #190 (distractor scope), #192 (Submit button), #193 (prompt audio direction), #194 (anti-cheat info panel).
- New field: `distractorScope: List<VocabularyWord>`. Populated in `LoadVocabulary` after dedup. Source of truth for MC distractor sampling.
- Helpers added: `GetPromptAudioText` / `GetPromptAudioLanguage` (line ~1559). Switch on `promptUsesNativeLanguage`. They are the canonical way to ask "what should the prompt-side audio say." `GetTargetAudioText` / `GetTargetAudioLanguage` are LEFT in place but are dead code — safe to delete in a follow-up.
- Method split: `CreateChoiceOption(item)` is now a thin wrapper over `CreateChoiceOptionForWord(VocabularyWord)`. The Word-level overload is what allows distractor sampling from raw scope.
- Resource keys live at `src/SentenceStudio.Shared/Resources/Strings/AppResources*.resx`. Key prefix `VocabQuiz_` is the convention. `Designer.cs` MUST be committed alongside resx changes.
- Aspire dev loop gotcha: restarting `webapp-rkmtvzgr` does NOT recompile. To pick up Razor changes, you MUST `dotnet build src/SentenceStudio.WebApp/SentenceStudio.WebApp.csproj` first, then `resource-stop` + `resource-start` the webapp resource. A `resource-restart` while the app is in `Unknown` state throws "Unhandled exception" — use start instead. The `webapp-rebuilder-ntjtbzbg` resource has no commands exposed from the MCP perspective.
- Verifying the right binary is loaded: `strings src/SentenceStudio.WebApp/bin/Debug/net10.0/SentenceStudio.UI.dll | grep -E "GetPromptAudioText|distractorScope"` is a fast sanity check before re-clicking through Playwright.
- Direct nav to `/vocab-quiz?...` after a webapp restart redirects to `/`. Always re-enter through the dashboard's "Vocabulary Review" tile so the planItem context attaches.
- Korean: "Submit" → "제출" is standard (Naver, gov forms). Used directly without a localize agent round-trip.

### 2026-04-29 — #189 follow-up appended to PR #196

Jayne flagged service-side is clean (her repro tests pass on `main`); the
"2 attempts / 50% accuracy" confusion in #189 is purely UI rendering.

**Panel change (`VocabQuiz.razor` ~line 412–448):** Replaced the two stat
grids with one streak-truth grid. Stripped legacy metadata readouts:
`IsKnown`, `IsUserDeclared`, `VerificationState`. Added `EffectiveStreak`
(was missing from rendering despite being on the model as a computed
prop: `CurrentStreak + ProductionInStreak * 0.5f`). Final allowlist:
TotalAttempts, CorrectAttempts, Accuracy, CurrentStreak,
ProductionInStreak, EffectiveStreak, MasteryScore, status badge.

Schema fields on `VocabularyProgress` (incl. `RecognitionAttempts`,
`ProductionAttempts`, `IsKnown`, `VerificationState`) untouched —
sync/back-compat preserved.

**Attempt-recording audit:** Inspected all four call sites in
`VocabQuiz.razor`:
- L980 `RecordAttemptAsync` — sentence-shortcut, intentionally records
one attempt per credited sentence (loop body). Correct.
- L1282 `RecordPendingAttemptAsync` in `NextItem` — records pending
attempt as-is. Correct.
- L1394 `RecordPendingAttemptAsync` in override-to-correct — mutates
`pendingAttempt.WasCorrect = true` then records. Correct.
- L1525 `RecordPendingAttemptAsync` in `DisposeAsync` — final flush.
Correct.

The method is idempotent: `if (pendingAttempt == null) return;` guard +
`pendingAttempt = null;` after the snapshot. Second call no-ops. The
override-to-correct path then NextItem path is the worst case; second
record is the no-op. **No double-invocation.**

**Convention learned:** Resource keys can become orphans when fields are
stripped from a panel (`VocabQuiz_IsKnown` is now unused). I noted this
in PR description under "Out of scope" — low-priority cleanup, not worth
churn on resx files. Future janitor pass should grep for unreferenced
`VocabQuiz_*` keys.

**Build verify only — no e2e re-run.** Justification: the change is
mechanical rearrangement on the same offcanvas that I already screen-
shotted for #194, all rendered values are existing properties on
`p` (the progress reference), and the grid layout is identical bootstrap
class structure. Risk of visual regression is negligible.

PR #196 body amended via `gh pr edit` to add `Closes #189` and a #189
section explaining the panel cleanup + audit outcome.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -2778,6 +2778,10 @@
<value>답을 입력하세요</value>
<comment>Input placeholder</comment>
</data>
<data name="VocabQuiz_SubmitAnswer" xml:space="preserve">
<value>제출</value>
<comment>Submit button for text-entry quiz answers</comment>
</data>
<data name="VocabQuiz_TypeCorrect" xml:space="preserve">
<value>계속하려면 정답을 입력하세요</value>
<comment>Retry placeholder</comment>
Expand Down
4 changes: 4 additions & 0 deletions src/SentenceStudio.Shared/Resources/Strings/AppResources.resx
Original file line number Diff line number Diff line change
Expand Up @@ -3022,6 +3022,10 @@ For example: {0} ...</value>
<value>Type your answer</value>
<comment>Input placeholder</comment>
</data>
<data name="VocabQuiz_SubmitAnswer" xml:space="preserve">
<value>Submit</value>
<comment>Submit button for text-entry quiz answers</comment>
</data>
<data name="VocabQuiz_TypeCorrect" xml:space="preserve">
<value>Type the correct answer to continue</value>
<comment>Retry placeholder</comment>
Expand Down
110 changes: 73 additions & 37 deletions src/SentenceStudio.UI/Pages/VocabQuiz.razor
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,16 @@ else if (currentItem != null)
@bind="userInput"
disabled="@(showAnswer && !requireCorrectTyping)"
placeholder='@(requireCorrectTyping ? Localize["VocabQuiz_TypeCorrect"] : Localize["VocabQuiz_TypeAnswer"])' />
@* #192 — explicit Submit button for text-entry mode (mirrors Enter key). Hidden once feedback is shown
in non-correction mode; in requireCorrectTyping mode it still drives the submit handler. *@
@if (!showAnswer || requireCorrectTyping)
{
<div class="text-center mt-3">
<button type="submit" class="btn btn-ss-primary px-5" disabled="@(string.IsNullOrWhiteSpace(userInput))">
@Localize["VocabQuiz_SubmitAnswer"] <i class="bi bi-check2 ms-1"></i>
</button>
</div>
}
</form>
}

Expand Down Expand Up @@ -396,34 +406,30 @@ else
_ => "bg-secondary"
};
}
@* #194 anti-cheat: Do NOT render TargetLanguageTerm or NativeLanguageTerm here —
this panel is opened mid-quiz and showing either side leaks the answer.
Only stats, status, and metadata may appear in the Learning Details panel. *@
<div class="mb-3">
<h6 class="ss-body1 fw-bold mb-1" style="color: var(--bs-primary);">@currentItem.Word.TargetLanguageTerm</h6>
<span class="ss-body2 text-secondary-ss">@currentItem.Word.NativeLanguageTerm</span>
<span class="badge @statusBadge ms-2">@statusText</span>
<span class="badge @statusBadge">@statusText</span>
</div>

@* #189: Render only the streak-based truth fields. Legacy/obsolete metadata
(IsKnown, IsUserDeclared, VerificationState, ProductionAttempts, RecognitionAttempts)
is intentionally NOT shown — those readouts misled users about quiz state.
Schema fields remain on VocabularyProgress for backward compat. *@
<div class="row g-2 mb-3">
<div class="col-6">
<small class="text-secondary-ss d-block">@Localize["VocabQuiz_IsKnown"]</small>
<span class="ss-body2 fw-bold">@(p?.IsKnown == true ? Localize["VocabQuiz_Yes"] : Localize["VocabQuiz_No"])</span>
</div>
<div class="col-6">
<small class="text-secondary-ss d-block">IsUserDeclared</small>
<span class="ss-body2 fw-bold">@(p?.IsUserDeclared == true ? Localize["VocabQuiz_Yes"] : Localize["VocabQuiz_No"])</span>
<div class="col-4">
<small class="text-secondary-ss d-block">TotalAttempts</small>
<span class="ss-body2 fw-bold">@(p?.TotalAttempts ?? 0)</span>
</div>
<div class="col-6">
<small class="text-secondary-ss d-block">VerificationState</small>
<span class="ss-body2 fw-bold">@(p?.VerificationState.ToString() ?? "None")</span>
<div class="col-4">
<small class="text-secondary-ss d-block">CorrectAttempts</small>
<span class="ss-body2 fw-bold">@(p?.CorrectAttempts ?? 0)</span>
</div>
<div class="col-6">
<small class="text-secondary-ss d-block">MasteryScore</small>
<span class="ss-body2 fw-bold">@((p?.MasteryScore ?? 0f).ToString("P0"))</span>
<div class="col-4">
<small class="text-secondary-ss d-block">Accuracy</small>
<span class="ss-body2 fw-bold">@((p?.Accuracy ?? 0f).ToString("P0"))</span>
</div>
</div>

<hr class="border-secondary my-2" />

<div class="row g-2 mb-3">
<div class="col-4">
<small class="text-secondary-ss d-block">CurrentStreak</small>
<span class="ss-body2 fw-bold">@(p?.CurrentStreak.ToString("F1") ?? "0")</span>
Expand All @@ -433,16 +439,12 @@ else
<span class="ss-body2 fw-bold">@(p?.ProductionInStreak ?? 0) / 2</span>
</div>
<div class="col-4">
<small class="text-secondary-ss d-block">TotalAttempts</small>
<span class="ss-body2 fw-bold">@(p?.TotalAttempts ?? 0)</span>
<small class="text-secondary-ss d-block">EffectiveStreak</small>
<span class="ss-body2 fw-bold">@((p?.EffectiveStreak ?? 0f).ToString("F1"))</span>
</div>
<div class="col-4">
<small class="text-secondary-ss d-block">CorrectAttempts</small>
<span class="ss-body2 fw-bold">@(p?.CorrectAttempts ?? 0)</span>
</div>
<div class="col-4">
<small class="text-secondary-ss d-block">Accuracy</small>
<span class="ss-body2 fw-bold">@((p?.Accuracy ?? 0f).ToString("P0"))</span>
<div class="col-12">
<small class="text-secondary-ss d-block">MasteryScore</small>
<span class="ss-body2 fw-bold">@((p?.MasteryScore ?? 0f).ToString("P0"))</span>
</div>
</div>

Expand Down Expand Up @@ -578,6 +580,9 @@ else
private List<VocabularyQuizItem> vocabItems = new();
private List<VocabularyQuizItem> sessionItems = new();
private List<VocabularyQuizItem> roundWordOrder = new();
// #190 — full vocabulary scope used as the distractor pool, so MC always has 4 options
// even when only 1 word remains in the active round. Populated in LoadVocabulary after dedup.
private List<VocabularyWord> distractorScope = new();
private VocabularyQuizItem? currentItem;

private int currentTurnInRound;
Expand Down Expand Up @@ -666,6 +671,11 @@ else
.Where(w => !string.IsNullOrWhiteSpace(w.NativeLanguageTerm) && !string.IsNullOrWhiteSpace(w.TargetLanguageTerm))
.GroupBy(w => w.Id).Select(g => g.First()).ToList();

// #190 — capture the full filtered+deduped vocabulary scope for distractor sampling.
// GenerateChoiceOptions draws from this so we always show 4 MC options regardless of
// how many words remain in the active round.
distractorScope = allWords;

// Get progress
var wordIds = allWords.Select(w => w.Id).ToList();
var progressDict = await ProgressService.GetProgressForWordsAsync(wordIds);
Expand Down Expand Up @@ -811,7 +821,8 @@ else
audioAnswerModeAvailable = false;
}

audioPromptAvailable = await CanPlayAudioAsync(GetTargetAudioText(currentItem.Word), GetTargetAudioLanguage(currentItem.Word));
// #193 — availability check must follow the prompt side (native or target), not always target.
audioPromptAvailable = await CanPlayAudioAsync(GetPromptAudioText(currentItem.Word), GetPromptAudioLanguage(currentItem.Word));
ApplyPromptPreferences();

responseTimer.Restart();
Expand Down Expand Up @@ -1089,9 +1100,16 @@ else
{
var correctOption = CreateChoiceOption(item);

var options = vocabItems
.Where(i => i != item)
.Select(CreateChoiceOption)
// #190 — sample distractors from the FULL vocabulary scope (resource-filtered or all user vocab),
// not just the active round. This guarantees a full set of 4 options even when remaining-to-learn
// shrinks to 1. Falls back to vocabItems if the scope hasn't been populated yet (defensive).
var pool = distractorScope.Count > 0
? (IEnumerable<VocabularyWord>)distractorScope
: vocabItems.Select(v => v.Word);

var options = pool
.Where(w => w.Id != item.Word.Id)
.Select(w => CreateChoiceOptionForWord(w))
.Where(option => !string.IsNullOrWhiteSpace(option.Text))
.Where(option => !string.Equals(option.Text, correctOption.Text, StringComparison.OrdinalIgnoreCase))
.GroupBy(option => option.Text, StringComparer.OrdinalIgnoreCase)
Expand All @@ -1100,15 +1118,22 @@ else
.Take(3)
.ToList();

// Edge case: if the full scope has fewer than 4 unique words, we degrade gracefully —
// we ship whatever distinct distractors we have plus the correct answer. This is
// deterministic by construction (no synthetic options injected) and avoids leaking
// "filler" answers that aren't real vocabulary.
options.Add(correctOption);
return options.OrderBy(_ => Guid.NewGuid()).ToArray();
}

private QuizChoiceOption CreateChoiceOption(VocabularyQuizItem item)
=> CreateChoiceOptionForWord(item.Word);

private QuizChoiceOption CreateChoiceOptionForWord(VocabularyWord word)
{
var answerText = GetAnswerText(item.Word);
var answerText = GetAnswerText(word);
var answerLanguage = promptUsesNativeLanguage
? item.Word.Language ?? targetLanguage
? word.Language ?? targetLanguage
: nativeLanguage;

return new QuizChoiceOption(answerText, answerText, answerLanguage);
Expand Down Expand Up @@ -1412,7 +1437,8 @@ else
{
if (currentItem?.Word == null) return;

await PlayAudioTextAsync(GetTargetAudioText(currentItem.Word), GetTargetAudioLanguage(currentItem.Word));
// #193 — play prompt-side audio so we never leak the answer when prompt direction is native→target.
await PlayAudioTextAsync(GetPromptAudioText(currentItem.Word), GetPromptAudioLanguage(currentItem.Word));
}

private Task PlayChoiceAudio(QuizChoiceOption option) => PlayAudioTextAsync(option.AudioText, option.AudioLanguage);
Expand Down Expand Up @@ -1524,6 +1550,16 @@ else

private string GetTargetAudioLanguage(VocabularyWord word) => word.Language ?? targetLanguage;

// #193 anti-cheat: prompt audio MUST follow the prompt direction. When prompt is native (e.g. "to turn"),
// playing target audio (털) leaks the answer. These helpers return the side actually shown as the prompt.
private string GetPromptAudioText(VocabularyWord word) => promptUsesNativeLanguage
? word.NativeLanguageTerm ?? ""
: word.TargetLanguageTerm ?? "";

private string GetPromptAudioLanguage(VocabularyWord word) => promptUsesNativeLanguage
? nativeLanguage
: word.Language ?? targetLanguage;

private async Task<bool> CanUseAudioAnswerModeAsync(IEnumerable<QuizChoiceOption> options)
{
if (!promptUsesNativeLanguage || !QuizPrefs.UseAudioPrompt)
Expand Down
Loading