Add Spanish Suffixes grammar guide with card-based content layout

Add comprehensive suffix reference (diminutives, augmentatives, verb
endings, noun/adjective-forming, adverbs, pejoratives) as grammar note
#21 under Word Building category.

Refactor grammar detail rendering to group paragraphs with their
examples into visual cards, replacing the flat wall-of-text layout.
Suffix entries get pill-styled labels with compact inline examples.
Bump courseDataVersion to 5.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-04-12 22:31:30 -05:00
parent d372a5c77f
commit 877e699c56
3 changed files with 482 additions and 27 deletions

View File

@@ -28,6 +28,7 @@ struct GrammarNote: Identifiable {
accentMarksStress,
seConstructions,
estarGerundProgressive,
spanishSuffixes,
]
// MARK: - 1. Ser vs Estar
@@ -887,4 +888,215 @@ struct GrammarNote: Identifiable {
**Other verbs with gerunds:** While *estar* is the most common, other verbs can also combine with gerunds: *seguir* (to keep on), *continuar* (to continue), *andar* (to go around), *llevar* (duration). *Sigo estudiando español.* — I keep on studying Spanish. *Llevo tres años viviendo aquí.* — I've been living here for three years.
"""
)
// MARK: - 21. Spanish Suffixes
private static let spanishSuffixes = GrammarNote(
id: "spanish-suffixes",
title: "Spanish Suffixes",
category: "Word Building",
body: """
Spanish uses suffixes — endings added to root words — to change meaning, size, tone, or part of speech. Learning these patterns lets you decode unfamiliar words and build new ones from roots you already know.
**Diminutives — smallness and affection**
Diminutive suffixes make a noun feel smaller, cuter, or more endearing. They are extremely common in everyday speech.
*-ito / -ita* — the most common diminutive. Expresses smallness or affection.
*perro → perrito* — little dog
*casa → casita* — little house
*momento → momentito* — just a moment
*abuela → abuelita* — grandma (affectionate)
*-cito / -cita* — used after words ending in a consonant or -e.
*café → cafecito* — a nice little coffee
*pobre → pobrecito* — poor little thing
*amor → amorcito* — sweetheart
*-illo / -illa* — a slightly less affectionate diminutive.
*chico → chiquillo* — little kid
*guerra → guerrilla* — small war / guerrilla
*pan → panecillo* — bread roll
*-ico / -ica* — regional diminutive popular in Colombia, Cuba, and Costa Rica.
*rato → ratico* — a little while
*gato → gatico* — little cat
**Augmentatives — bigness and intensity**
Augmentative suffixes make nouns feel bigger, more intense, or sometimes clumsier. They often carry a slightly negative or humorous tone.
*-ón / -ona* — big or intense, sometimes negative.
*silla → sillón* — armchair (big chair)
*soltar → solterón* — confirmed bachelor
*cabeza → cabezón* — big-headed / stubborn
*-ote / -ota* — big, often clumsy or exaggerated.
*grande → grandote* — really big
*amigo → amigote* — a big buddy (casual)
*palabra → palabrota* — a swear word (a "big word")
*-azo / -aza* — big; also means a blow or hit with something.
*perro → perrazo* — huge dog
*puño → puñetazo* — a punch
*gol → golazo* — an amazing goal
*codo → codazo* — an elbow jab
*-udo / -uda* — having a lot of something.
*pelo → peludo* — hairy
*barba → barbudo* — bearded
*panza → panzudo* — big-bellied
**Verb-related endings**
These endings are built into the verb conjugation system and tell you when and how the action occurs.
*-ando* — gerund for -ar verbs (happening now, equivalent to English "-ing").
*hablar → hablando* — speaking
*caminar → caminando* — walking
*estudiar → estudiando* — studying
*-iendo* — gerund for -er and -ir verbs.
*comer → comiendo* — eating
*vivir → viviendo* — living
*leer → leyendo* — reading (note the spelling change)
*-ado* — past participle for -ar verbs (equivalent to English "-ed").
*hablar → hablado* — spoken
*cerrar → cerrado* — closed
*cansar → cansado* — tired
*-ido* — past participle for -er and -ir verbs.
*comer → comido* — eaten
*vivir → vivido* — lived
*dormir → dormido* — slept
**Noun-forming suffixes**
These suffixes turn verbs or adjectives into nouns, creating words for actions, results, places, and people.
*-ción / -sión* — action or result, equivalent to English "-tion."
*actuar → acción* — action
*decidir → decisión* — decision
*comunicar → comunicación* — communication
*expresar → expresión* — expression
*-miento* — action or result, similar to -ción.
*conocer → conocimiento* — knowledge
*sentir → sentimiento* — feeling
*mover → movimiento* — movement
*-ería* — a shop, business, or place associated with something.
*pan → panadería* — bakery
*libro → librería* — bookstore
*zapato → zapatería* — shoe store
*carne → carnicería* — butcher shop
*-ero / -era* — a person who does or makes something.
*pan → panadero* — baker
*cocina → cocinero* — cook
*carta → cartero* — mail carrier
*enfermo → enfermera* — nurse
*-dor / -dora* — a person or thing that does something.
*jugar → jugador* — player
*trabajar → trabajador* — worker
*computar → computadora* — computer
*lavar → lavadora* — washing machine
*-ista* — a person devoted to something (gender-neutral form).
*arte → artista* — artist
*diente → dentista* — dentist
*fútbol → futbolista* — soccer player
*piano → pianista* — pianist
*-ura* — a quality or result.
*dulce → dulzura* — sweetness
*loco → locura* — madness
*abrir → abertura* — opening
*pintar → pintura* — painting / paint
*-eza* — an abstract quality.
*bello → belleza* — beauty
*triste → tristeza* — sadness
*puro → pureza* — purity
*natural → naturaleza* — nature
*-dad / -tad* — a quality, equivalent to English "-ty."
*libre → libertad* — liberty
*real → realidad* — reality
*feliz → felicidad* — happiness
*amigo → amistad* — friendship
*-anza* — action or result.
*esperar → esperanza* — hope
*enseñar → enseñanza* — teaching
*confiar → confianza* — trust / confidence
**Adjective-forming suffixes**
These turn nouns or verbs into adjectives that describe qualities, origins, or tendencies.
*-oso / -osa* — full of something, equivalent to English "-ous."
*peligro → peligroso* — dangerous
*hermosura → hermoso* — beautiful
*cariño → cariñoso* — affectionate
*éxito → exitoso* — successful
*-ano / -ana* — relating to a place or group.
*México → mexicano* — Mexican
*América → americano* — American
*cristiano* — Christian
*humano* — human
*-eño / -eña* — from a place.
*Panamá → panameño* — Panamanian
*Brasil → brasileño* — Brazilian
*isla → isleño* — islander
*-ense* — from a place (often countries or cities).
*Costa Rica → costarricense* — Costa Rican
*Estados Unidos → estadounidense* — American (USA)
*Canadá → canadiense* — Canadian
*-ble* — able to be, equivalent to English "-ble."
*posible* — possible
*increíble* — incredible
*responsable* — responsible
*agradable* — pleasant
*-ivo / -iva* — tending toward, equivalent to English "-ive."
*actuar → activo* — active
*crear → creativo* — creative
*producir → productivo* — productive
**Adverb and intensity suffixes**
*-mente* — turns adjectives into adverbs, equivalent to English "-ly." Attach it to the feminine form of the adjective.
*rápida → rápidamente* — quickly
*fácil → fácilmente* — easily
*tranquila → tranquilamente* — calmly
*completa → completamente* — completely
*-ísimo / -ísima* — absolute superlative, meaning "extremely" or "super."
*rápido → rapidísimo* — super fast
*bella → bellísima* — extremely beautiful
*mucho → muchísimo* — very very much
*grande → grandísimo* — enormous
**Pejorative suffixes — negative or ugly tone**
These suffixes add a negative, derogatory, or ugly connotation.
*-ucho / -ucha* — ugly, run-down.
*casa → casucha* — a shack, a dump
*delgado → delgaducho* — scrawny
*-aco / -aca* — ugly, derogatory.
*pájaro → pajarraco* — ugly bird
*libro → libraco* — a terrible book
**Key insight:** Once you recognize root + suffix patterns, you can decode unfamiliar words. For example, *panadería* = *pan* (bread) + *-ero* (person) + *-ía* (place) = "place where the bread-person works" = bakery. The suffix system makes Spanish vocabulary highly predictable and composable.
"""
)
}

View File

@@ -123,7 +123,7 @@ actor DataLoader {
/// Re-seed course data if the version has changed (e.g. examples were added).
/// Call this on every launch it checks a version key and only re-seeds when needed.
static func refreshCourseDataIfNeeded(container: ModelContainer) async {
let currentVersion = 4 // Bump this whenever course_data.json changes
let currentVersion = 5 // Bump this whenever course_data.json changes
let key = "courseDataVersion"
let shared = UserDefaults.standard

View File

@@ -101,40 +101,148 @@ struct GrammarNoteDetailView: View {
private struct FormattedGrammarBody: View {
let content: String
private var lines: [GrammarLine] {
GrammarLine.parse(content)
private var sections: [GrammarSection] {
GrammarSection.group(GrammarLine.parse(content))
}
var body: some View {
VStack(alignment: .leading, spacing: 10) {
ForEach(lines) { line in
switch line.kind {
case .paragraph(let text):
renderParagraph(text)
case .spanishExample(let spanish):
Text(spanish)
.font(.body.weight(.medium))
.italic()
.padding(.leading, 12)
case .examplePair(let spanish, let english):
VStack(alignment: .leading, spacing: 3) {
Text(spanish)
.font(.body.weight(.medium))
.italic()
Text(english)
.font(.callout)
.foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 16) {
ForEach(sections) { section in
if section.heading == nil {
// Intro content before the first heading no card
renderLines(section.lines)
} else {
// Headed section in a card
VStack(alignment: .leading, spacing: 10) {
Text(section.heading!)
.font(.headline)
renderLines(section.lines)
}
.padding(.leading, 12)
case .heading(let text):
Text(text)
.font(.headline)
.padding(.top, 6)
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
}
}
}
}
@ViewBuilder
private func renderLines(_ lines: [GrammarLine]) -> some View {
let blocks = ContentBlock.group(lines)
VStack(alignment: .leading, spacing: 12) {
ForEach(blocks) { block in
switch block.kind {
case .standalone(let line):
renderSingleLine(line)
case .exampleCard(let header, let examples):
renderExampleCard(header: header, examples: examples)
case .suffixCard(let suffix, let description, let examples):
renderSuffixCard(suffix: suffix, description: description, examples: examples)
}
}
}
}
@ViewBuilder
private func renderSingleLine(_ line: GrammarLine) -> some View {
switch line.kind {
case .paragraph(let text):
renderParagraph(text)
case .spanishExample(let spanish):
Text(spanish)
.font(.body.weight(.medium))
.italic()
.padding(.leading, 12)
case .examplePair(let spanish, let english):
VStack(alignment: .leading, spacing: 3) {
Text(spanish)
.font(.body.weight(.medium))
.italic()
Text(english)
.font(.callout)
.foregroundStyle(.secondary)
}
.padding(.leading, 12)
case .heading, .suffixDef:
EmptyView()
}
}
@ViewBuilder
private func renderExampleCard(header: GrammarLine?, examples: [GrammarLine]) -> some View {
VStack(alignment: .leading, spacing: 8) {
if let header, case .paragraph(let text) = header.kind {
renderParagraph(text)
}
VStack(alignment: .leading, spacing: 6) {
ForEach(examples) { ex in
if case .examplePair(let spanish, let english) = ex.kind {
VStack(alignment: .leading, spacing: 2) {
Text(spanish)
.font(.body.weight(.medium))
.italic()
Text(english)
.font(.callout)
.foregroundStyle(.secondary)
}
} else if case .spanishExample(let spanish) = ex.kind {
Text(spanish)
.font(.body.weight(.medium))
.italic()
}
}
}
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.fill.quaternary, in: RoundedRectangle(cornerRadius: 10))
}
@ViewBuilder
private func renderSuffixCard(suffix: String, description: String, examples: [GrammarLine]) -> some View {
VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(suffix)
.font(.subheadline.weight(.bold).monospaced())
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(.tint.opacity(0.12), in: Capsule())
Text(description)
.font(.callout)
.foregroundStyle(.secondary)
}
if !examples.isEmpty {
VStack(alignment: .leading, spacing: 4) {
ForEach(examples) { ex in
if case .examplePair(let spanish, let english) = ex.kind {
HStack(spacing: 0) {
Text(spanish)
.font(.body.weight(.medium))
.italic()
Text(" ")
Text(english)
.font(.callout)
.foregroundStyle(.secondary)
}
} else if case .spanishExample(let spanish) = ex.kind {
Text(spanish)
.font(.body.weight(.medium))
.italic()
}
}
}
.padding(.leading, 8)
}
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.fill.quaternary, in: RoundedRectangle(cornerRadius: 10))
}
@ViewBuilder
private func renderParagraph(_ text: String) -> some View {
Text(parseInlineFormatting(text))
@@ -200,11 +308,20 @@ private struct GrammarLine: Identifiable {
let id: Int
let kind: Kind
var isExample: Bool {
switch kind {
case .examplePair, .spanishExample: return true
default: return false
}
}
enum Kind {
case paragraph(String)
case heading(String)
case spanishExample(String)
case examplePair(spanish: String, english: String)
/// A suffix definition line like `*-ito / -ita* description`
case suffixDef(suffix: String, description: String)
}
static func parse(_ body: String) -> [GrammarLine] {
@@ -255,7 +372,13 @@ private struct GrammarLine: Identifiable {
let englishPart = String(line[dashRange.upperBound...])
.replacingOccurrences(of: "*", with: "")
.trimmingCharacters(in: .whitespaces)
result.append(GrammarLine(id: result.count, kind: .examplePair(spanish: spanishPart, english: englishPart)))
// Detect suffix definitions: spanish part starts with "-"
if spanishPart.hasPrefix("-") {
result.append(GrammarLine(id: result.count, kind: .suffixDef(suffix: spanishPart, description: englishPart)))
} else {
result.append(GrammarLine(id: result.count, kind: .examplePair(spanish: spanishPart, english: englishPart)))
}
} else {
// Just a Spanish example without translation
let spanish = line.replacingOccurrences(of: "*", with: "")
@@ -279,6 +402,126 @@ private struct GrammarLine: Identifiable {
}
}
// MARK: - Content Block Grouping
/// Groups paragraphs with their trailing examples into visual cards.
/// Also handles suffix definitions as a special card type.
private struct ContentBlock: Identifiable {
let id: Int
enum Kind {
/// A standalone line (paragraph with no examples, or orphaned example)
case standalone(GrammarLine)
/// A paragraph header followed by example pairs rendered as a card
case exampleCard(header: GrammarLine?, examples: [GrammarLine])
/// A suffix definition with its examples rendered as a pill card
case suffixCard(suffix: String, description: String, examples: [GrammarLine])
}
let kind: Kind
static func group(_ lines: [GrammarLine]) -> [ContentBlock] {
var result: [ContentBlock] = []
var i = 0
while i < lines.count {
let line = lines[i]
// Suffix definition: collect trailing examples
if case .suffixDef(let suffix, let desc) = line.kind {
var examples: [GrammarLine] = []
var j = i + 1
while j < lines.count, lines[j].isExample {
examples.append(lines[j])
j += 1
}
result.append(ContentBlock(id: result.count, kind: .suffixCard(suffix: suffix, description: desc, examples: examples)))
i = j
continue
}
// Paragraph followed by examples: group into a card
if case .paragraph = line.kind {
var j = i + 1
// Check if examples follow
if j < lines.count, lines[j].isExample {
var examples: [GrammarLine] = []
while j < lines.count, lines[j].isExample {
examples.append(lines[j])
j += 1
}
result.append(ContentBlock(id: result.count, kind: .exampleCard(header: line, examples: examples)))
i = j
} else {
// Standalone paragraph
result.append(ContentBlock(id: result.count, kind: .standalone(line)))
i += 1
}
continue
}
// Orphaned examples (no preceding paragraph) group into a card
if line.isExample {
var examples: [GrammarLine] = []
var j = i
while j < lines.count, lines[j].isExample {
examples.append(lines[j])
j += 1
}
result.append(ContentBlock(id: result.count, kind: .exampleCard(header: nil, examples: examples)))
i = j
continue
}
// Skip headings (handled at section level)
if case .heading = line.kind {
i += 1
continue
}
result.append(ContentBlock(id: result.count, kind: .standalone(line)))
i += 1
}
return result
}
}
// MARK: - Grammar Section Grouping
private struct GrammarSection: Identifiable {
let id: Int
let heading: String?
let lines: [GrammarLine]
static func group(_ lines: [GrammarLine]) -> [GrammarSection] {
var sections: [GrammarSection] = []
var currentHeading: String? = nil
var currentLines: [GrammarLine] = []
var sectionIndex = 0
for line in lines {
if case .heading(let text) = line.kind {
// Flush the previous section
if !currentLines.isEmpty || currentHeading != nil {
sections.append(GrammarSection(id: sectionIndex, heading: currentHeading, lines: currentLines))
sectionIndex += 1
currentLines = []
}
currentHeading = text
} else {
currentLines.append(line)
}
}
// Flush final section
if !currentLines.isEmpty || currentHeading != nil {
sections.append(GrammarSection(id: sectionIndex, heading: currentHeading, lines: currentLines))
}
return sections
}
}
// MARK: - Hashable/Equatable conformance for NavigationLink
extension GrammarNote: Hashable, Equatable {