The session queue was in-memory only: leaving the flashcard screen or
killing the app rebuilt it from scratch. The set of verbs you were
working through wasn't sticky.
VocabStudyGroup (new cloud-synced @Model) — the active standard study
set: the in-session queue (un-graduated verbs + each card's
learning-step state) as a JSON blob, plus the learned count. One active
group at a time, keyed "active-standard". CloudKit-synced, so the same
group follows you from iPad to iPhone.
VocabStudyGroupStore — fetch-or-create / persist / clear. activeGroup()
returns the newest if duplicates exist (two devices racing before sync
settles); clear() removes all duplicates.
VocabSessionQueue — CardState is now String-backed; added
init(entries:learnedCount:) to resume a saved queue and snapshot() to
export it.
VocabFlashcardPracticeView (standard kind):
- On load, resume the active group if one exists; otherwise build a
fresh set and persist it immediately.
- Every answer writes the updated queue to the group.
- Finishing the set clears the group; the completion button is now
"Next Set" and builds a fresh group.
Review Learned (reviewLearned kind) is unaffected — it's an ad-hoc cram
and intentionally isn't persisted; its button stays "Study Again".
Registered VocabStudyGroup in the cloud container schema.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After a few Vocab Flashcards sessions, the verbs you've learned have
future SM-2 due dates, so they're excluded from new sessions (which
only pull due + new). There was no way to deliberately re-review a set
you'd already memorised.
Review Learned (new row in Practice → Vocabulary) fixes that — a cram
pass over every verb that has a VerbReviewCard, most-recently-studied
first, uncapped.
VocabVerbPool.reviewLearnedVerbs — studied verbs sorted by
lastReviewDate desc. Ignores due dates and the Level filter; it's a
deliberate "review everything I've learned" pass.
VocabFlashcardPracticeView gains a `kind` parameter
(.standard / .reviewLearned). reviewLearned uses the new pool and —
crucially — does NOT call VerbReviewStore.rate on graduation. Ratings
drive the in-session learning queue for the session feel, but the
long-term schedule is left untouched (Anki's reschedule-off cram
behaviour), so a consolidation pass can't shove your real due dates
weeks out.
Header shows "Practice pass — your review schedule won't change" in
this mode. Quiz/Learn toggle and Study Again work the same.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both the in-app Feature Reference and app_features.md were last updated
2026-04-21, before Books, the verb-table vocab SRS, Extra Study,
read-along, the guide enrichment, and the Practice-tab restructure.
FeatureReferenceView (Settings → How Features Work):
- Regrouped to match the live Practice tab: Conjugation / Vocabulary
/ Reading sections.
- Added Vocab Flashcards (Quiz + Learn modes, in-session learning
queue), Vocab Multiple Choice, Books + read-aloud, Extra Study,
and Guide cross-links.
- Tense/grammar counts corrected (36 grammar notes, enriched guides).
- "Settings That Affect Practice" now lists Cards per session and
notes the Verbs-tab ↔ Level sync.
app_features.md (Conjuga section of the comparison doc):
- Practice modes split into Conjugation / Vocabulary / Reading.
- Documented the two-layer vocab SRS, Books + books pipeline,
Extra Study, guide enrichment + cross-links, configurable session
size, and the VerbReviewCard cloud model.
- Data table updated (36 grammar notes, bundled books row).
Docs only — no behavior change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
VocabVerbPool.sessionCardLimit was hardcoded at 20. Settings now has a
"Vocab Flashcards → Cards per session" picker (10 / 15 / 20 / 25 / 30 /
50 / All) backed by the vocabSessionCardLimit @AppStorage key.
VocabVerbPool.sessionCardLimit became a computed property reading that
key (0/unset → default 20; 999 → "All"). Applies to both Quiz and Learn
modes since they share the same session pool.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A Quiz/Learn toggle now sits in the flashcard toolbar (Menu, persisted
via @AppStorage):
Quiz — unchanged. The VocabSessionQueue SRS path: tap to reveal, rate
Again/Hard/Good/Easy, requeue, graduation feeds VerbReviewStore.
Learn — browsing, not testing. Both sides show at once (English +
Spanish + example sentence + speaker button), Next/Previous step
through the session pool, looping (wraps at both ends). No rating
buttons, no SRS writes, no requeue — just repeated exposure.
Both modes draw from the same fetched session pool (due-first + new,
capped 20) and the same lazily-generated example cache. Switching
modes is instant — the quiz queue keeps its state, the learn cursor
keeps its position; they're independent. The header shows the SRS
progress in Quiz and a plain "3 of 20" position in Learn.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Vocab Flashcards / Multiple Choice were a flat linear walk through a
shuffled list — rating a card "Hard" or "Again" did nothing to bring it
back. Replaced with a real two-layer SRS, matching how Anki separates
in-session learning steps from the long-term schedule.
VocabSessionQueue (new) — the in-session layer. Position-based learning
steps:
- Again → card reappears 5–8 cards later (state: learning)
- Hard → reappears 7–10 cards later (state: learning)
- Good → first time: → review state, reappears 16–24 cards later
already in review: graduates, leaves the session
- Easy → graduates immediately
A card you keep failing keeps cycling until you mark it Good twice or
Easy once. answer() returns a ReviewQuality only on graduation — that's
the single rating handed to the long-term VerbReviewStore, so
intermediate Again/Hard presses no longer thrash the cross-session
SM-2 schedule.
VocabVerbPool.sessionVerbs (rewritten) — due-first ordering + a 20-card
session cap. Overdue verbs (per VerbReviewCard.dueDate) come first,
most-overdue leading; then never-reviewed verbs by frequency rank.
Not-yet-due verbs are intentionally skipped — that's the SRS schedule
doing its job. A single sitting is now bounded instead of a 100+ card
slog.
Study Again — the completion screen gets a "Study Again" button that
rebuilds the queue from the same verb set (re-shuffled), so you can run
the whole set again after finishing.
Progress display switched from "1 of 110" to "N learned · M to go",
which reflects the live queue as cards requeue and graduate.
Both vocab views now share VocabSessionQueue + VocabVerbPool; the queue
struct is pure value-type logic, easy to reason about and test.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both vocab session reveal panes now show a speaker button next to the
Spanish infinitive. Tapping it speaks the word via the existing
SpeechService (Spanish voice), same TTS the Course flashcards use.
Flashcard mode: button beside the infinitive in the reveal pane.
Multiple choice: button beside the infinitive in the answer feedback.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Image Playground's .illustration style can't depict abstract verbs —
"dar" (to give) generated a Shiba Inu in a meadow, "saber" a cake.
Verbs are actions, not objects; a static illustration rarely helps and
the irrelevant output was pure noise.
Removed:
- VerbIllustration view (was in VocabFlashcardPracticeView).
- VocabImageService.swift entirely — VerbIllustration was its only
consumer.
- vocabImageService from the app environment in ConjugaApp.
- The illustration row from both vocab session reveal panes.
Vocab card reveal is now: Spanish infinitive + example sentence +
rating buttons. The example sentence already carries the learning
context.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The example sentences in VerbDetailView (and the new vocab practice
modes) frequently used the wrong verb — a "tener" set would show
"Él estaba leyendo un libro" (estar), "Nosotros vamos a viajar" (ir),
"Tú debes estudiar" (deber). The model drifted off the target verb
partway through generating the 6-example batch, and nothing checked
the output.
Two defenses:
Prompt grounding — VerbExampleGenerator.generate now takes a
formsByTense map (tenseId → conjugated forms, from the new
ReferenceStore.conjugatedForms). Each tense line in the prompt
lists the verb's exact conjugated forms and instructs the model to
use one of them. The model echoes a real form instead of recalling
(and mis-recalling) the conjugation.
Output validation — every generated sentence is checked against the
conjugation table via accent/case-folded whole-word matching. Any
sentence that doesn't contain a real conjugated form of the verb is
rejected. Failures trigger one regeneration pass; anything still
wrong is dropped rather than displayed. Better to show 4 correct
examples than 6 with 2 wrong.
Cache invalidation — VerbExampleCache now persists a versioned
wrapper (version 2). Pre-fix cached example sets — which may contain
wrong-verb sentences — fail the version check and are discarded, so
they regenerate cleanly under the new path.
Callers updated: VerbDetailView, VocabFlashcardPracticeView,
VocabMultipleChoicePracticeView all build formsByTense from
ReferenceStore and pass it through.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
VocabFlashcardPracticeView and VocabMultipleChoicePracticeView rendered
the English prompt as "to \(verb.english)". Every verb in conjuga_data
already stores english with a leading "to " (all 1750 of them), so the
prompt came out as "to to be", "to to run", etc.
Dropped the prefix — verb.english is shown verbatim. Same fix applied
to the Image Playground concept string so generated illustrations are
prompted with "to be" rather than "to to be".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Crash: Swift/NativeDictionary.swift:792 Fatal error: Duplicate values
for key: 'imp_tú', triggered the first time a user rated a vocab card
and the in-flight example generation tried to materialise.
Root cause: VerbExampleGenerator.generate() builds a [tenseId: example]
dictionary from the model's output via Dictionary(uniqueKeysWithValues:),
which traps on duplicates. The generator's @Generable schema declares
@Guide(.count(6)) on the examples array, so the LLM is forced to return
exactly 6. The new Vocab Flashcards / Multiple Choice views called
generate(... tenseIds: ["ind_presente"]) — only one tense — which left
the model to invent the other 5 tenseIds; it duplicated 'imp_tú' and
the dictionary init trapped.
Three fixes:
Services/VerbExampleGenerator.swift — use Dictionary(_:uniquingKeysWith:)
with first-wins so the generator can never crash regardless of caller
shape.
Views/Practice/Vocab/VocabFlashcardPracticeView.swift and
Views/Practice/Vocab/VocabMultipleChoicePracticeView.swift — pass
the canonical 6-tense set (VocabExampleTenseIds.canonical, same as
VerbDetailView uses), then pick the ind_presente example for the
card. Caches all six in VerbExampleCache as a side effect.
Views/Verbs/VerbListView.swift — replace empty-string systemImage
on the Level menu Labels with "circle" so the device console isn't
spammed with "No symbol named '' found in system symbol set" every
time the user opens the filter menu.
The crash analysis I gave earlier (CloudKit schema migration of
VerbReviewCard) was wrong — device console shows the real culprit. No
CloudKit-side changes needed; the new model stays in the cloud
container.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Vocab Practice used a deck picker over VocabCard rows. That meant it
ignored the Settings level toggles entirely and operated on a totally
separate vocabulary universe than the conjugation modes. Rewired
end-to-end:
Pool source
Replaced VocabCard with the Verb table. The pool is now
ReferenceStore.fetchVerbs(selectedLevels: UserProgress.selectedVerbLevels)
— the same call PracticeSessionService uses for conjugation. Changes
to level toggles in Settings (or the Verbs tab, which also writes to
this field) immediately affect Vocab Practice.
Entry flow
Deleted VocabPracticeEntryView. Practice → Vocabulary now has two
direct entries:
• Vocab Flashcards — verb.english → tap → verb.infinitive
• Vocab Multiple Choice — verb.english → pick from 4 infinitives
Both pull from the same level-filtered pool, shuffled.
Per-verb SRS
New VerbReviewCard @Model (cloud-synced, mirrors CourseReviewCard's
SM-2 fields but keyed by verbId). VerbReviewStore.rate(verbId:quality:)
applies the existing SRSEngine. Registered in cloud container schema
in ConjugaApp.swift.
Example sentences
Lazy-generated via VerbExampleGenerator on first reveal, cached
through VerbExampleCache (same path VerbDetailView uses). Empty until
the example arrives — block hides itself if Apple Intelligence isn't
available.
AI illustration
VerbIllustration replaces VocabIllustration; same Image Playground
pipeline. Cache key uses ("verb", infinitive, english) so verbs and
course-deck vocab never collide.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
VerbListView used a local @State selectedLevel that reset whenever
the user left the tab and was disconnected from Practice's verb
sourcing. PracticeSessionService reads UserProgress.selectedVerbLevels;
the Verbs tab now does the same.
Level menu becomes multi-select (toggle each level in/out, mirrors
SettingsView's pattern). "All Levels" enables every level; tapping
again on a level removes it from the active set. Picking "All"
while everything is already on is a no-op.
Filter logic loops over the active level set and admits a verb if
any active level matches via VerbLevelGroup.matches.
Active-filter chip shows "Basic", "Elementary", etc. when one level
is on, or "N levels" when multiple are on. Tapping the chip resets
to all-levels.
Empty set is treated as "no filter applied" on the Verbs list, but
setAllLevels(enabled: false) falls back to [.basic] rather than an
empty set — Practice treats empty as "no verbs", so we guard against
leaving the user with an empty pool.
Practice already reads UserProgress.selectedVerbLevels, so changes
made in the Verbs tab now flow into Practice automatically. No
Practice-side code changes needed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A device install was showing the Books row but no books inside. The
seed code is intact (bundle resource present, schema registered,
explicit-slug fallback in place). Bumping the version flips
versionCurrent to false on every existing install, forcing
refreshBooksDataIfNeeded to wipe + re-seed on next launch. The
diagnostic prints added earlier ([DataLoader] bundledBookJSONURLs
found N files / Book seeding complete) will tell us if it still
fails this time.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Practice tab restructured into three sections:
- Conjugation: the 6 conjugation modes + the Common Tenses / Weak
Verbs / Irregularity Drills focus buttons.
- Vocabulary: new "Vocab Practice" entry (flashcard or multiple
choice, deck-picker on entry) + the existing Vocab Review.
- Reading: Stories, Books, Lyrics, Conversation, Listening, Cloze
(moved here from the flat list).
VocabPracticeEntryView lets the user pick any course/textbook deck
(or "All decks") and a mode (Flashcard / Multiple Choice). Last-used
choice is remembered via @AppStorage.
VocabFlashcardPracticeView:
Front shows the English meaning. Tap to reveal the Spanish word,
example sentences from the card, an AI-generated illustration of
the concept, and Again/Hard/Good/Easy rating buttons. SRS updates
via the existing CourseReviewStore.rate() path.
VocabMultipleChoicePracticeView:
English prompt, 4 Spanish options. Distractors come from the same
deck and prefer matching part-of-speech (via
DictionaryService.lookup) — falls back to random when POS is
unknown or the deck has fewer than 4 cards. After answer: reveal
correct/incorrect, the Spanish word, examples, illustration, and
the same rating buttons.
VocabImageService wraps Apple Intelligence's ImageCreator
(iOS 18.2+) for on-device illustration generation. Caches PNG
results to disk under Caches/VocabImages keyed by
SHA256(deck+ES+EN). In-flight dedup keeps concurrent requests for
the same key sharing one task. Falls back to a placeholder UI when
Apple Intelligence isn't available (older devices / disabled in
Settings) — detected lazily on the first failed ImageCreator init.
EN-first direction is enforced regardless of the underlying deck's
isReversed flag, so the user sees the English-to-Spanish recall
direction they asked for even when practising a reversed course
deck.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After the full enrichment pass, both the Tenses and Grammar surfaces of
the Guide tab cover overlapping material — WEIRDO appears in both
"Subjuntivo Presente" and "Subjunctive Triggers", preterite↔imperfect
contrast in three places, etc. Instead of trimming either body and
losing content, add a small chip row at the top of each detail view
linking directly across.
GuideCrossLinks.swift (new) — curated tense→[noteId] map covering 18 of
the 20 tenses. The two without aligned notes (ind_pluscuamperfecto,
ind_preterito_anterior) don't show chips. The reverse map (noteId→
[tenseId]) is derived once at static init and sorted by canonical tense
order so chips appear in conjugation-table order.
GuideDetailView — "Related grammar" indigo chip row directly under the
header. Tap a chip → switch to the Grammar segment with that note
selected.
GrammarNoteDetailView — "Used in tenses" orange chip row directly under
the title. Tap a chip → switch to the Tenses segment with that tense
selected.
The GuideView segment-change handler now only clears the *other* tab's
selection so programmatic jumps keep their destination intact; manual
segment swipes still feel "fresh" like before.
No content is removed. Users get a deeper-dive path one tap away in
either direction, and the redundancy becomes a feature instead of a
maintenance hazard.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Brings every tense guide and grammar note in the app up to teacher-
handout depth via parallel research subagents drafting against a
shared "thorough" checklist (TL;DR, usages, conjugation table, common
irregulars, mnemonic, top pitfalls, contrast with neighbour topic,
real-world dialogue example).
Tense guides — Conjuga/conjuga_data.json (tenseGuides[].body)
All 19 remaining guides rewritten. The 20th (subj_presente) was
enriched in the prior commit. Each body now ~4-5.5K chars (vs the
500-1500 chars of the pre-pass reference cards), covering:
- All five indicative tenses, both conditionals, both imperatives.
- Full subjunctive set including the archaic futuro / futuro
perfecto, framed honestly with "recognise, don't produce" guidance.
- Per-tense conjugation patterns and the top 5-15 irregular verbs.
- Tense-vs-tense contrasts (preterite↔imperfect, future↔ir-a,
-ra↔-se past subjunctive, etc.).
- Pitfalls that English speakers actually make.
Grammar notes — Conjuga/Conjuga/Models/GrammarNote.swift
All 36 notes audited and rewritten where the existing body was
missing one of: explicit mnemonic, contrast pair, pitfalls section,
or coverage of a key sub-topic. None copied verbatim — every note
got at least one of those slotted in. Notable additions:
- DOCTOR/PLACE, WEIRDO, ESCAPA, RID, PRODDS, BANGS, RRPIA mnemonics
where missing.
- commands-imperative: nosotros + vosotros forms were entirely
absent; both added with the -d/-os and present-subjunctive rules.
- relative-pronouns: el que/el cual distinction, cuyo, lo que/lo
cual, donde/adonde.
- se-constructions: all 6 uses including the le→se substitution.
- irregular-yo-verbs: impact on subjunctive and negative tú command.
- Plus 5-item pitfalls sections on every note that lacked one.
Tooling — Conjuga/Scripts/guide-enrichment/
- PLAN.md (prior commit) — the audit, checklist, and priority order
that drove this pass.
- apply_drafts.py (new) — reads drafts/out/*.md, swaps tense guides
into the JSON and grammar notes into the Swift source via regex on
the GrammarNote(...) declarations. Handles multi-block `#` comment
headers some agents emitted. drafts/in/ and drafts/out/ are
gitignored — regeneratable from current state.
DataLoader.swift — courseDataVersion 8 → 9 so existing installs re-
seed all guides on next launch.
Verification:
- `swift -frontend -parse` on GrammarNote.swift succeeds (exit 0).
- JSON validates (python3 json.load round-trip).
- Triple-quote count is even (72 = 36 pairs, matching 36 notes).
- Full xcodebuild verify deferred — local SDK install was disrupted
by an Xcode update; will retest as part of the next ad-hoc deploy.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The present-subjunctive guide was surface-level: two numbered usages
and a handful of examples, no mnemonic and no structural trigger cue.
That's the recurring problem with the tense guides — they're reference
cards, not teaching materials.
This commit fixes the immediate gap and lays out a plan to fix the
rest:
Conjuga/conjuga_data.json — subj_presente body expanded from 794 to
3670 chars. Adds the WEIRDO mnemonic with per-letter triggers and
examples (Wishes, Emotions, Impersonal, Recommendations, Doubt,
Ojalá), the ESCAPA adverbial-conjunction set, the "que + change of
subject" structural rule, adjectival clauses with unknown
antecedents, and the future-time-clause rule (cuando / hasta que /
en cuanto).
Scripts/guide-enrichment/PLAN.md (new) — audit of all 20 tense
guides and 36 grammar notes, tier-1/2/3 prioritisation, "thorough"
checklist (TL;DR, usages, conjugation, irregulars, mnemonic,
pitfalls, contrast, dialogue example), research sources, per-topic
workflow, effort estimate.
DataLoader.swift — courseDataVersion 7 → 8 so existing installs
re-seed the new body on next launch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a TTS read-along to the book reader. Tap the play button in the
toolbar; AVSpeechSynthesizer reads the chapter paragraph-by-paragraph
with the current word highlighted in yellow, auto-scrolling the active
paragraph to centre. Tap any word during read-along to pause and open
the definition sheet; reading resumes when the sheet dismisses.
Behavior per spec:
- Tap-to-define interrupts the synth (pauseSpeaking at: .immediate) and
resumes on sheet dismiss.
- Voice picker sheet (waveform.circle toolbar button) lists installed
Spanish voices grouped by Premium / Enhanced / Default quality, with
a "Download more voices…" row that opens iOS Settings (no public
deep-link to Accessibility → Spoken Content exists; the footer spells
out the path).
- Speed picker (Slow / Normal / Fast) drives AVSpeechUtterance.rate.
- Stops at chapter end, no auto-advance to the next chapter.
- Vocabulary lines shaped `palabra = meaning` are skipped — the synth
would otherwise say "palabra equals meaning" and they're reference
material, not prose.
Audio session uses .playback + .spokenAudio mode and is properly
deactivated with .notifyOthersOnDeactivation on stop() so music apps
resume cleanly after reading ends.
Voice/rate persisted via @AppStorage; controller picks them up
onAppear and writes back through Bindings the picker mutates.
Word-index space in BookSpeechController.wordRanges(in:) matches
BookReaderView's split(separator: " ") rendering exactly — both split
on ASCII U+0020 only, so willSpeakRange callbacks resolve to the right
visible word.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
bundledBookJSONURLs() was relying solely on
Bundle.urls(forResourcesWithExtension:subdirectory:), the directory-
enumeration API. That API is observed to return empty in some on-device
configurations even when the resource is present, which would cause
seedBooks() to silently no-op and leave BookLibraryView showing "No
Books" despite the JSON being bundled correctly.
Switched to the same pattern textbook seeding uses: explicit per-slug
Bundle.url(forResource:withExtension:) with a bundleURL.appending-
PathComponent fallback. Directory enumeration is kept as a secondary so
future books bundled without code changes still get picked up.
Also added diagnostic prints to refreshBooksDataIfNeeded and the URL
discovery step so device logs reveal what happened if seeding still
falls through.
Bumped bookDataVersion to 3 so existing installs re-trigger
refreshBooksDataIfNeeded → seedBooks on next launch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
BookLibraryView is itself pushed from PracticeView's NavigationStack,
so the .navigationDestination(for: Book.self) it declared was a
non-root registration. Combined with NavigationLink(value: book), that
resolved the push to *both* the destination handler and the closure
that produced BookLibraryView originally — pushing the chapter list
underneath, then re-pushing the library on top. Hitting back popped
the library and revealed the chapter list, in the wrong order.
Switched both Library→ChapterList and ChapterList→Reader to closure-
based NavigationLinks. Destinations attach directly to the link, no
type-keyed registry involved.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
extract_epub.py was walking <p> only, but every "Vocabulario" section in
the Olly Richards EPUB lives inside <ul><li>...</li></ul>. That meant
the heading made it through but the entries didn't — 680 vocab lines
across 24 sections in this book were missing from the bundled JSON.
Audit (text-node owner by closest block ancestor) confirmed <li> is the
only silent drop: 5,260 nodes in <p>, 1,960 in <li>, 0 anywhere else.
No <h1>-<h6>, tables, or blockquotes in this EPUB at all.
Fix: walk find_all(["p", "li"]) in document order so bullet entries
slot in right after their "Vocabulario" / list heading. Re-extracted
(2,646 → 3,326 paragraphs), re-translated all 118 jobs in parallel
Claude Code subagents. translate_chapters.py prompt template now tells
subagents to keep bilingual `palabra = meaning` lines verbatim — both
sides already coexist on the line.
Bumped bookDataVersion to 2 so refreshBooksDataIfNeeded re-seeds.
Verified in simulator: all 13 chapter row sizes grew (e.g. ch6
18,295→20,951 chars).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New "Books" row in the Practice tab opens a library of bundled bilingual
books. Each chapter renders Spanish paragraph-by-paragraph; tap any
word for a definition sheet (DictionaryService with on-device AI
fallback), or toggle the toolbar button to swap to the pre-computed
English translation inline.
Local-only Book + BookChapter SwiftData models added to the local
container schema (reset version bumped to 5). DataLoader.seedBooks
walks the bundle for `book_*.json` resources, so future books drop in
without touching app code — just bundle a new JSON and bump
bookDataVersion.
First book: Olly Richards' "Spanish Short Stories For Beginners
Vol 2" — 13 chapters, 2,646 paragraphs, bilingual.
Scripts/books/ is the repeatable pipeline for future EPUBs:
extract_epub.py → translate_chapters.py (per-chapter resumable jobs) →
bundle_book.py. Translation is done by parallel Claude Code subagents
reading per-job input files and writing output files — no API key
required, matching the pattern used for the textbook vocab vision
pass. See Scripts/books/README.md for the full how-to.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per-week "Extra Study (N)" row appears at the end of each LanGo week
section when at least one card is marked. Cards are marked from inside
VocabFlashcardView via a star next to the speaker on reveal. Marks are
keyed by the same SHA256 hash CourseReviewCard uses, so a mark and its
SRS state describe the same logical card.
ExtraStudyMark is CloudKit-synced (private DB), with uniqueness enforced
by fetch-or-create on id since CloudKit forbids @Attribute(.unique).
Skipped for textbook courses: DeckStudyView nils out the mark context
when the deck's courseName matches a TextbookChapter, and CourseView
hides the row when the active course is a textbook — so there are no
orphan marks the user can't reach.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two small fixes after the LLM-vision pass:
1. merge_pdf_into_book.py — when the LLM classifies an image as 'hybrid'
but extracts zero pairs (e.g., a conjugation table whose only English
text is on the section header that was excluded by the prompt rules),
respect that decision instead of falling through to the bbox/heuristic
pipeline. Previously: 1 chapter-2 estar conjugation table generated
4 bad pairs from the heuristic fallback.
2. fix_vocab.py language_score — recognize Spanish present-perfect
('he tenido', 'He andado por este pueblo') as Spanish. The classifier
was treating the auxiliary 'he'/'has'/'ha' as English subject pronouns,
producing false-positive mis-orientation flags on 4 chapter-15/20/23
present-perfect example tables.
Result: mis-oriented vocab pairs across the book go from 5 → 0.
textbookDataVersion bumped to 14.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The bbox-OCR pipeline mis-paired ~114 vocab tables across the book — the
chapter 7 "Other Idioms" image (issue #32) being the most visible.
Three failure modes were collapsing the data:
1) classifier blind to subject pronouns ("yo", "I", etc.)
2) right-then-left OCR reads on 2-col tables
3) Y-cluster drift on multi-line cells in 4-col layouts
Replaced the entire vocab-extraction tier with a Claude vision pass over
all 931 vocab images. Output is keyed by image with three classifications:
- pair_table (extract all Spanish↔English pairs)
- reference_only (Spanish-only conjugation tables — no pairs, UI shows
the flat OCR lines as a reference list instead)
- hybrid (some header pairs + reference content beneath; only
the genuine pairs become cards)
merge_pdf_into_book.py now picks pair source by priority:
llm-vision → bounding-box OCR → block-alternation heuristic.
Numbers (across the whole book):
- mis-oriented tables: 114 → 5
- quarantined cards: 250 → 2
- extracted pairs: 2832 → 4569
textbookDataVersion bumped to 13. Per-batch agent outputs gitignored
under Conjuga/Scripts/textbook/paired_vocab_llm/ — only the merged
paired_vocab_llm.json (also gitignored) is needed to rebuild.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The eligibility filter required every form's regularity tag to equal
"regular", but the data uses four labels:
- regular (179 forms — curated paradigm verbs)
- ordinary (50,992 forms — pattern-following verbs like hablar, comer)
- irregular (8,653)
- orto (176 — orthographic spelling changes like busqué)
Result was a 27-combo eligible pool, ~26 of which were -ir verbs in
present tense — every Full Table prompt landed on the same handful of
verbs.
Pulled the rule into a SharedModels function (FullTableEligibility) so
it's testable in isolation. Accepts "regular" + "ordinary" (both mean
"follows the pattern"); rejects "irregular" and "orto". 9 unit tests
cover the matrix including edge cases (incomplete forms, mixed labels,
unknown values).
PracticeSessionService.makePromptIfFullyRegular now delegates to
FullTableEligibility, sorting forms by personIndex so the regularity
array lines up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Full Table (issue from chat): drop the level filter — Full Table tests
regular conjugation patterns, not vocabulary recognition, so restricting
to Basic-level verbs collapsed the eligible pool to two combos
(vivir present, ir future). Pool now draws from all 1,750 verbs. Random
sampling first; if 40 attempts fail we fall through to a deterministic
shuffled scan that guarantees finding any eligible (verb, tense) combo
when one exists. Returning nil now happens only when the user's filters
genuinely produce zero eligible prompts. The view replaces its silent
blank screen with a ContentUnavailableView pointing at the settings
that need adjusting. FeatureReferenceView documents the level exception.
Streak (issue #31 follow-up): activity recording was scoped to flashcard
and Full Table reviews only, so spending an hour on textbook work,
guides, videos, or AI chat could break a "streak" that the dashboard
kept displaying as if it were intact. Three fixes:
1. Extract ReviewStore.recordActivity(context:) — a streak-only entry
point that any user-initiated learning action can call.
2. Add UserProgress.validateStreakIfStale(today:context:) — resets a
broken currentStreak to 0 immediately, called from app launch and
dashboard appear so the displayed number is never a lie.
3. DailyLog formatter pins POSIX locale + current timezone so the
yyyy-MM-dd strings can't drift across locales.
Wired recordActivity into every previously-silent learning action: chat
send, story-quiz completion, textbook exercise submit, grammar exercise
completion, course-deck study finish, week test / checkpoint save,
listening + pronunciation check, cloze quiz completion, lyrics word
lookup, video stream / play / download success, sentence-builder check,
and course-vocab SRS rate (which was bypassing ReviewStore entirely).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- VideoActionsView: stack icon over title via a custom label style so
the Stream/Download/Play row fits iPhone width without wrapping
mid-word ("Stre am", "Down load").
- project.yml: add textbook_data.json and textbook_vocab.json to the
Conjuga target's Copy Bundle Resources. They were missing, so
DataLoader.seedTextbookData early-returned every launch and the
textbook never appeared in the Book tab. Regenerated Conjuga.xcodeproj.
Add YouTube video integration, reflexive-verb list + practice-pool
filters, verb-detail example sentences, lyrics word lookup. Bump iOS /
Xcode requirements to 26. Correct grammar-note count and document the
YouTubeKit + AVFoundation muxing architecture plus the video-curation
script.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Swap 24 tense-guide / grammar-note videos to The Language Tutor's
numbered lesson series where a matching lesson exists, filling the two
remaining gaps (ind_preterito_anterior → Lesson 65, estar-gerund-
progressive → Lesson 113). All 32 TLT picks preserved on this pass.
For the non-TLT slots, prefer BaseLang's beginner lesson series where a
topic-specific video exists: ser-vs-estar, preterite-vs-imperfect,
subjunctive-triggers, object-pronouns, conditional-if-clauses,
tener-expressions, future-vs-ir-a, possessive-adjectives,
irregular-yo-verbs, and stem-changing-verbs.
Retire both Tell Me In Spanish videos (personal-a → castellano4U,
types-of-irregular-verbs → Master IRREGULAR VERBS Complete Lesson).
Generator header note clarifies that "not available on this app" rows
are a transient yt-dlp extraction limit — videos still play when tapped
in the app via the Stream button, which opens youtube.com externally.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
youtube_videos.md lists every entry in youtube_videos.json with its
tense-guide / grammar-note id, title, channel, upload date, duration,
views, and likes (where public). Also flags the two topics with no
curated video so the gap is auditable in one place.
generate_videos_markdown.py queries yt-dlp in parallel for each unique
videoId and writes the markdown. Rerun when curation changes. One
current entry (saber-vs-conocer → j87i7MVCvIE) is now marked Private
Video — needs re-curation as a follow-up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
YouTubeKit returns stream URLs only; its own README punts on the
progressive-vs-adaptive problem. Most modern YouTube videos above 360p
serve video and audio as separate DASH tracks, so the previous "any MP4"
fallback silently grabbed a video-only track and the downloaded file had
no audio.
VideoDownloadService now resolves a DownloadPlan up front. Progressive
MP4 (when one exists) still streams straight to the final destination.
Otherwise the service downloads the best MP4 video track + best M4A
audio track to temp files, then combines them into a single MP4 via
AVMutableComposition + AVAssetExportSession. Passthrough preset first
(lossless container rewrite) with a fallback to highestQuality if the
codec combination requires re-encoding. Temp files are cleaned up on
both success and failure.
DownloadStatus replaces the bare Double progress so the UI can show
per-phase labels (Video %, Audio %, Finalizing…). Muxing progress is
rendered as an indeterminate spinner since AVAssetExportSession's
progress property doesn't cross actor boundaries cleanly.
Also retires the deprecated Stream.subtype comparison in favor of
Stream.fileExtension, matching YouTubeKit's current API.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The middle button in the Stream/Download/Play row now cycles through the
full download lifecycle instead of ending at a disabled "Downloaded"
checkmark. Once a video is on disk the button becomes a red, destructive
"Delete" with a trash icon; tapping presents a confirmation dialog, and
confirming removes the file + SwiftData row, flipping the button back to
"Download" and disabling Play.
Settings → Downloaded Videos retains the swipe-delete and "Delete all"
affordances for bulk management.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Each of the 56 tense guides and grammar notes gets a curated YouTube video
attached (54 with picks, 2 silent nulls on rare / hard-to-find topics).
Users can stream in YouTube/Safari, download via YouTubeKit for offline
viewing, or play the local MP4 full-screen via AVPlayer.
YouTubeVideoStore loads the bundled youtube_videos.json at launch and serves
lookups by tense id or grammar note id. VideoDownloadService resolves the
best progressive MP4 stream off the main actor (YouTubeKit isn't Sendable),
writes to documents/videos/<videoId>.mp4, and records a DownloadedVideo row
in the local SwiftData container so the app knows what's on disk across
launches.
VideoActionsButtonRow is the unified UI for both detail views: three large
buttons — Stream (red, always enabled), Download (blue, disabled while in
flight and after completion, shows progress), Play (green, enabled only
when downloaded). Full-screen cover on tap. Settings gains a Downloaded
Videos list with swipe-delete, total-size summary, and a 500 MB warning.
Local store reset version bumped to 4 for the new DownloadedVideo schema.
Known fragility: YouTubeKit scrapes YouTube's private stream API and will
break when YouTube changes their internal format. Streaming keeps working
regardless.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bundles the 100 most common reflexive verbs from spanishwithdaniel.com as a
canonical list and wires it through the UI. Compound list entries (recibirse
/ graduarse, equivocarse / confundirse) are split. Trailing prepositions and
set-phrase completions are captured as usageHint (e.g. acordarse "de",
ponerse "de acuerdo").
ReflexiveVerbStore loads the JSON at launch and exposes lookups by base
infinitive, both via @Environment for SwiftUI and a static shared instance
for services. Verbs whose bare infinitive isn't in the list skip the UI
treatment silently.
VerbDetailView shows a new Reflexive section with the reflexive infinitive,
usage hint, and English meaning when there is a match. VerbListView gains a
"Reflexive verbs only" filter alongside the existing Level and Irregularity
filters. Settings adds the same flag so it also constrains the practice
pool; PracticeSessionService applies the reflexive filter in all six pick
paths.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tapping a verb now shows six example sentences beneath the conjugation table,
one per core tense (Present, Preterite, Imperfect, Future, Present Subjunctive,
Imperative). Each example renders the tense label, Spanish sentence, and
English translation in the DeckStudyView style.
Generation uses Foundation Models with a @Generable schema that pins each
response to the requested tenseId and forces tú/nosotros subjects for
imperatives. Results are cached as JSON in the Caches directory keyed by
verb id (DictionaryService pattern); cache misses regenerate on demand.
Devices without Apple Intelligence see an inline notice instead of the
loading state. No network dependency.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
UserProgress gains selectedLevelsBlob and enabledIrregularCategoriesBlob
(mirrors the existing tense-blob pattern). The multi-level setter keeps the
legacy selectedLevel String in sync with the highest-ranked selection, so
widget sync, AI scenarios, and achievement checks keep working unchanged.
Legacy single-level users are migrated on first read.
Settings replaces the level Picker with per-level toggles and adds an
Irregular Types section with three toggles. Practice pool is the literal
intersection: empty levels means zero results, empty irregular categories
means no irregularity constraint.
Pure filter logic lives in SharedModels (PracticeFilter, VerbLevel.highest)
and is covered by 20 Swift Testing cases. ReferenceStore delegates so the
intersection behavior is unit-tested without a ModelContainer.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tokenize Spanish lyric lines into a flow layout of underlined, long-pressable
words. Long-press (0.35s) opens a sheet with base form, English, part of
speech, and a Tense · person row for verbs. Unknown words silently no-op.
English gloss lines remain untouched.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
README: expanded from the original terse list into categorized sections
covering the six conjugation practice modes, textbook reader, AI chat
and stories, listening/pronunciation, cloze, lyrics, vocab SRS, offline
dictionary, grammar notes/exercises, and CloudKit sync. Architecture
section now documents the dual local/cloud SwiftData stores with the
App Group ID, the widget-schema-must-match requirement, and the
Scripts/textbook extraction pipeline.
app_features.md: added a full Conjuga section (practice modes, verb
reference, grammar, dictionary, sync, widgets, data counts) alongside
the existing ConjuGato and Conjuu ES analyses; added Conjuga as a
first column in the comparison table with rows for the new capability
axes (AI, textbook, speech, offline dictionary, lyrics, CloudKit,
widgets); added a "Conjuga excels at" strengths section.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Codifies the rule that Claude must not run git commit or git push
without an explicit request from the user in the current turn.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Root cause of the repeatedly-disappearing textbook: both widget timeline
providers were opening the shared local SwiftData store with a schema
that omitted TextbookChapter. On each widget refresh SwiftData
destructively migrated the store to match the widget's narrower schema,
dropping the ZTEXTBOOKCHAPTER rows (and sometimes the table itself).
The app then re-created an empty table on next open, but
refreshTextbookDataIfNeeded skipped re-seeding because the UserDefaults
version flag was already current — leaving the store empty indefinitely.
Three changes:
1. Widgets (CombinedWidget, WordOfDayWidget): added TextbookChapter to
both schema arrays so they match the main app. Widget refreshes will
no longer drop the entity.
2. DataLoader.refreshTextbookDataIfNeeded: trigger now considers BOTH
the version flag and the actual on-disk row count. If rows are
missing for any reason (past wipes, future subset-schema openers,
corruption), the next launch re-seeds. Eliminates the class of bug
where a version flag lies about what's really in the store.
3. StoreInspector: reports ZTEXTBOOKCHAPTER row count alongside the
other entities so we can confirm state from logs.
Bumped textbookDataVersion to 12 so devices that were stuck in the
silent-failure state re-seed on next launch regardless of prior flag
value.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The toolbar Filter menu now has two sections:
- Level (existing)
- Irregularity: Any Irregular / Spelling Change / Stem Change /
Unique Irregular
Filters combine, so "Basic" + "Unique Irregular" narrows to the
foundational ser/ir/haber-class verbs. Categories are derived at load
time from existing IrregularSpan rows using the same spanType ranges
already used by PracticeSessionService (1xx spelling, 2xx stem, 3xx
unique), so no schema or data changes are required.
UI additions:
- Per-row icons (star / arrows / I-beam) show each verb's
irregularity categories at a glance, tinted by type.
- When any filter is active, a chip bar appears under the search
field showing the active filters (tap to clear) and the resulting
verb count.
- Filter toolbar icon fills when any filter is applied.
Data coverage: 614 / 1750 verbs are flagged irregular — 411 spelling,
275 stem-change, 67 unique — consistent with canonical lists from
SpanishDict, Lawless Spanish, and Wikipedia.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
randomFullTablePrompt now rejects any (verb, tense) combo where any
form has regularity != "regular" and keeps retrying (40 attempts) until
a fully-regular combo is picked. Full Table practice is meant for
drilling regular conjugation patterns — irregular tables belong in the
Irregularity Drills mode.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two bugs were causing chapters to disappear on every relaunch:
1. seedTextbookData used `try? context.save()` (swallowing errors) and
returned `inserted > 0`, so a failed save still reported success.
Callers then bumped UserDefaults textbookDataVersion and subsequent
launches skipped the re-seed entirely — with no rows on disk.
2. refreshTextbookDataIfNeeded wiped chapters via the batch-delete API
`context.delete(model: TextbookChapter.self)`, which hits the store
directly without clearing the context's .unique-id index. Re-inserting
chapters with the same ids could then throw a unique-constraint error
on save — also silently eaten by `try?`.
Fixes:
- seedTextbookData now uses do/catch around save(), returns false on
error, and verifies persistence via fetchCount before returning true.
- refreshTextbookDataIfNeeded fetches and deletes chapters individually
so the context tracks the deletion cleanly; wipe save is also now
checked and bails early on failure.
- Bumped textbookDataVersion to 11 so devices poisoned by the previous
silent-failure path retry on next launch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Earlier launches (before cd491bd bundled textbook_data.json) ran
seedTextbookData, got "not bundled — skipping", and still bumped
UserDefaults textbookDataVersion to 9. Subsequent launches then
short-circuited in refreshTextbookDataIfNeeded and never re-seeded,
so ZTEXTBOOKCHAPTER stayed empty and the Course tab hid the section.
- seedTextbookData now returns Bool (true only when chapters inserted).
- Both call sites only write the version key on success, so a missing
or unparseable bundle no longer locks us out of future retries.
- Bumped textbookDataVersion to 10 to force existing installs to
re-seed on next launch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously the chapter reader showed vocab tables as a flat list of OCR
lines — because Vision reads columns top-to-bottom, the Spanish column
appeared as one block followed by the English column, making pairings
illegible.
Now every vocab table renders as a 2-column grid with Spanish on the
left and English on the right. Supporting changes:
- New ocr_all_vocab.swift: bounding-box OCR over all 931 vocab images,
cluster lines into rows by Y-coordinate, split rows by largest X-gap,
detect 2- / 3- / 4-column layouts automatically. ~2800 pairs extracted
this pass vs ~1100 from the old block-alternation heuristic.
- merge_pdf_into_book.py now prefers bounding-box pairs when present,
falls back to the heuristic, embeds the resulting pairs as
vocab_table.cards in book.json.
- DataLoader passes cards through to TextbookBlock on seed.
- TextbookChapterView renders cards via SwiftUI Grid (2 cols).
- fix_vocab.py quarantine rule relaxed — only mis-pairs where both
sides are clearly the same language are removed. "unknown" sides
stay (bbox pipeline already oriented them correctly).
Textbook card count jumps from 1044 → 3118 active pairs.
textbookDataVersion bumped to 9.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The pbxproj references textbook_data.json and textbook_vocab.json as Copy
Bundle Resources, so xcodebuild fails if they're missing. Committing the
generated output keeps the repo self-sufficient — regenerate via
Conjuga/Scripts/textbook/run_pipeline.sh when content changes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Assistant messages now render each word as a button. Tap shows a sheet
with base form, English translation, and part of speech. Dictionary
lookup first; falls back to Foundation Models (@Generable ChatWordInfo)
for words not in the local dictionary. Results cached per-session.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>