From b9a4e16892e3e4de01cdfb16c70478cf50fa8f11 Mon Sep 17 00:00:00 2001 From: George Turkington Date: Tue, 22 Sep 2026 06:06:59 +0000 Subject: [PATCH] Add the Adjectives section, and explain why the noun itself changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gaps, both visible from one screenshot of the Genitiv level. 1. Adjective endings were half-present and entirely untested. STRONG and WEAK existed in engine.js with correct data, but only as the S/W columns of a reference chart — nothing generated them, nothing tested them, and the MIXED table (the one "in seinem eigenen Kontext" actually needs) did not exist at all. There was not a single adjective in the codebase. 2. Nothing explained why "Regen" becomes "Regens" after trotz. New Adjectives tab, six lessons plus Daily Practice: Schwach after der/diese/jede — only -e and -en Gemischt after ein/kein/mein — weak plus three exceptions Stark no determiner — the adjective carries the case Welche Tabelle? der-word, ein-word or nothing? (the meta-skill) Nach Präposition the full chain: preposition -> case -> table Alles gemischt all three tables, including Genitiv The three tables are built on one idea: the case has to be signalled once in the noun phrase, and whoever can show it does. MIXED is therefore not hand-typed — it is DERIVED from POSS_ENDINGS as "weak, except in the three slots where the ein-word has no ending of its own, where it borrows the strong one". A test asserts that relationship as well as all 48 cells, so the rule cannot silently rot. Two info panels: - "Which ending table?" — the one-signal idea, the three tables, plus the two traps: strong genitive masc/neut is -en not -es (the noun already carries the -(e)s), and predicate adjectives take no ending at all. - "Why Regens?" — appears wherever the case changes the noun itself: the -es/-s split, that feminines and plurals never change, the dative plural -n, and a note on the N-declension nouns Beug does not have yet. Notable decisions: - Nominative is now tested, for adjectives only. der gute Mann vs ein guter Mann is the clearest illustration of the whole weak/mixed split and it exists only in the nominative, so the app-wide "no nominative" rule is explicitly scoped to non-adjective levels rather than quietly dropped. - The tab bar measured 369px of content in a 350px bar with four tabs, so a fifth could not fit. The row now scrolls instead of squeezing labels. - Adjectives are gated like Possessives and Pronouns. The corpus caught its keep again. Adjective sentences are enumerated into corpus.txt keyed on the semantic pairing (frame x adjective x noun), which keeps it at ~2000 lines instead of exploding over every determiner and case. Reading it surfaced, and this commit fixes: "I live in the old lake" (places split into buildings/settlements/landscape), "I play with the blue sofa", "I like the bad kitchen" (frames that assert liking now exclude negative adjectives), "I buy the good time" and "I cook with the good money" (pools narrowed to what you can buy and cook with), grosser/kleiner of a person rendering as "big/small" rather than "tall/short", and "in dem alten Hotel" failing to fuse into "im alten Hotel". 130 tests pass; 12000 generated questions across all 30 levels. Known gap, stated in the rule panel: Beug has no plural nouns, so the plural column of all three tables is shown for reference but not drilled. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017Uza6mhTpAvSwMQuFc4Cz8 --- src/App.jsx | 290 ++++++- src/corpus.txt | 2067 ++++++++++++++++++++++++++++++++++++++++++++ src/engine.js | 306 +++++++ src/engine.test.js | 176 +++- 4 files changed, 2831 insertions(+), 8 deletions(-) diff --git a/src/App.jsx b/src/App.jsx index 87ccfd3..37c6b30 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -3,6 +3,7 @@ import { DEF, INDEF, STRONG, WEAK, PREPS, PREPS_BY_CASE, CL, CASE_C, GENDER_C, PRONOUNS, POSSESSIVES, POSS_ENDINGS, combinePoss, LEVELS, wechselExample, createSession, bumpStreak, currentStreak, weekDots, + nounForm, ADJ_ENDINGS, ADJ_CLASS_LABEL, ADJ_CLASS_HINT, DER_ENDINGS, } from './engine.js'; // ═══════════════════════════════════════════ @@ -58,12 +59,15 @@ const TABS = [ {id:'article', label:'Articles'}, {id:'poss', label:'Possessives'}, {id:'pronoun', label:'Pronouns'}, + {id:'adj', label:'Adjectives'}, ]; // Daily Practice pseudo-levels are always free: unlocking one requires // passing every lesson in its tab, so gated tabs stay gated anyway. const DAILY_IDS = LEVELS.filter(l => l.daily).map(l => l.id); const FREE_LEVEL_IDS = new Set([2, 3, 4, 8, 9, 10, 18, ...DAILY_IDS]); +const ADJ_GENDERS = ['masculine','feminine','neuter','plural']; +const ADJ_CASES = ['nominative','accusative','dative','genitive']; const CORE_ARTICLE_CASES = ['nominative', 'accusative', 'dative']; const PAYMENTS_ENABLED = import.meta.env.VITE_PAYMENTS_ENABLED === 'true'; const STRIPE_PLANS = PAYMENTS_ENABLED ? [ @@ -185,6 +189,7 @@ export default function App() { const [chart, setChart] = useState(false); const [rule, setRule] = useState(false); const [pickPrep, setPickPrep] = useState(null); + const [nounInfo, setNounInfo] = useState(null); const [learnStep, setLearnStep] = useState(0); const [st, setSt] = useState({sc:{},tc:0,ta:0,streak:0,ld:null}); const [access, setAccess] = useState(null); @@ -287,6 +292,7 @@ export default function App() { useEffect(() => { const h = e => { + if (nounInfo) { if (e.key === 'Escape') setNounInfo(null); return; } if (rule) { if (e.key === 'Escape') setRule(false); return; } if (chart) { if (e.key === 'Escape') setChart(false); return; } if (scr === 'summary' && e.key === 'Enter') { e.preventDefault(); summaryRef.current?.(); return; } @@ -339,7 +345,7 @@ export default function App() { const rc = res.filter(Boolean).length; const cur = LEVELS.find(l=>l.id===lv); - const chartSection = scr === 'menu' ? tab : (q?.type === 'wechselContext' ? 'prep' : q?.type === 'chunk' ? 'article' : (q?.type || tab)); + const chartSection = scr === 'menu' ? tab : (q?.type === 'wechselContext' ? 'prep' : q?.type === 'chunk' ? 'article' : (q?.type === 'adjective' || q?.type === 'adjClass') ? 'adj' : (q?.type || tab)); const nextInTab = nextLevelInTab(lv); const quickActive = !!cur?.daily || !!st.auto; const quickTick = quickActive && rev && ok; @@ -598,6 +604,14 @@ export default function App() { {q.type === 'poss' && <> · {DEF[q.noun.g].nominative} {q.noun.w}} + {nounForm(q.noun, q.cas) !== q.noun.w && ( +
+ +
+ )} + {!rev ? ( q.choices ? ( @@ -754,6 +768,72 @@ export default function App() { )} + {/* PRACTICE: adjective endings */} + {scr === 'practice' && (q?.type === 'adjective' || q?.type === 'adjClass') && ( +
+
+ {q.sentence.split('_____').map((part,i,a)=>( + + {part} + {i < a.length-1 && ( + + {rev ? ADJ_ENDINGS[q.cls][q.noun.g][q.cas] : ' '} + + )} + + ))} +
+ + + + + + + +
+ {q.type === 'adjClass' ? 'Which ending table?' : 'Which ending?'} + + {nounForm(q.noun, q.cas) !== q.noun.w && ( + + )} +
+ + {!rev ? ( + q.type === 'adjClass' ? ( +
+ {q.choices.map((c,i) => ( + + ))} +
+ ) : ( + '-' + c)} onPick={c => doSubmit(c.slice(1))}/> + ) + ) : quickTick ? ( + + ) : ( + setRule('adj')}/> + )} +
+ )} + {scr === 'practice' && (
@@ -823,8 +903,9 @@ export default function App() { {scr !== 'practice' && }
- {chart && setChart(false)} onRule={()=>{ setChart(false); setRule(true); }}/>} - {rule && setRule(false)}/>} + {chart && setChart(false)} onRule={kind => { setChart(false); setRule(kind || true); }}/>} + {rule && (rule === 'adj' ? setRule(false)}/> : setRule(false)}/>)} + {nounInfo && setNounInfo(null)}/>}
); @@ -1165,6 +1247,183 @@ function WechselContextFeedback({ q, inp, ok, onRule }) { ); } +function AdjectiveFeedback({ q, inp, ok, onRule }) { + const ending = ADJ_ENDINGS[q.cls][q.noun.g][q.cas]; + const isClassQ = q.type === 'adjClass'; + const gotLabel = isClassQ ? (ADJ_CLASS_LABEL[inp] || inp) : '-' + inp; + const wantLabel = isClassQ ? ADJ_CLASS_LABEL[q.cls] : '-' + ending; + return ( +
+
+ {ok + ? <>✓ {wantLabel} + : <>✗ {gotLabel} → {wantLabel}} +
+
+ {q.answerSentence} +
+
+ {q.det + ? <>„{q.det}" is {q.cls === 'weak' ? 'a der-word' : 'an ein-word'} → {ADJ_CLASS_LABEL[q.cls]} + : <>No determiner → the adjective has to carry the case → Stark} + {' · '}{CL[q.cas]} + {' '}{q.noun.g} + {' → '}-{ending} +
+
+ {ADJ_CLASS_LABEL[q.cls]} — {ADJ_CLASS_HINT[q.cls]} +
+ + +
+ ); +} + +function AdjTable({ cls, g, cas, wrong }) { + return ( + + + + {ADJ_GENDERS.map(x => )} + + + {ADJ_CASES.map(c => ( + + + {ADJ_GENDERS.map(x => { + const val = ADJ_ENDINGS[cls][x][c]; + const hit = x === g && c === cas; + const bad = !hit && wrong && x === g && val === wrong; + return ; + })} + + ))} + +
{x.slice(0,4)}.
{CL[c]}-{val}
+ ); +} + +// Why the noun itself changed — the last surviving case ending on German nouns. +function NounEndingModal({ q, onClose }) { + const changed = nounForm(q.noun, q.cas); + return ( +
+
e.stopPropagation()}> +
+
Why „{changed}"?
+ +
+
+ German normally leaves the noun alone and lets the article do the work. The + genitive is the one surviving exception: masculine and neuter nouns + add -s or -es in the genitive singular. So {q.noun.w} becomes + {changed}. +
+
+ {DEF[q.noun.g].genitive} {changed} — {q.noun.en} +
+ +
-es or just -s?
+
+ -es after one-syllable nouns, and after -s, -ß, -x, -z:
+ des Hauses, des Mannes, des Kindes, des Platzes +
+
+ -s after longer nouns — especially those ending in -el, -en, -er — + and after foreign words:
+ des Regens, des Vaters, des Lehrers, des Autos, des Hotels +
+ +
Feminine nouns never change
+
+ der Frau, der Stadt, der Nacht — the article alone marks the case. + Same for plurals: der Kinder. +
+ +
The only other one left
+
+ The dative plural adds -n if the plural doesn't already end in one: + mit den Kindern, zwischen den Bäumen. +
+
+ There is also a small group of masculine nouns — der Junge, der Student, + der Herr, der Mensch — that take -n in every case except the nominative. + None of them appear in Beug yet. +
+ + +
+
+ ); +} + +// The three adjective tables and the single idea underneath them. +function AdjRuleModal({ onClose }) { + const rows = [ + ['weak', 'After a der-word', 'der · dieser · jeder · welcher · alle', + 'The determiner already shows the case, so the adjective goes limp — only -e or -en.', + 'der gut\u00ADe Mann · den guten Mann'], + ['mixed', 'After an ein-word', 'ein · kein · mein · dein · sein · ihr · unser · euer', + 'Same as weak, EXCEPT the three slots where the ein-word has no ending of its own. There the adjective steps in and takes the strong ending.', + 'ein guter Mann · ein gutes Kind'], + ['strong', 'No determiner', '— nothing in front —', + 'Nothing else can signal the case, so the adjective carries the full load.', + 'guter Wein · kaltes Wasser'], + ]; + return ( +
+
e.stopPropagation()}> +
+
Which ending table?
+ +
+ +
+ One idea holds the whole system together: the case and gender have to be + signalled exactly once in the noun phrase. Whoever is able to show it, + shows it — and whoever is left over relaxes. +
+ + {rows.map(([cls, title, words, body, ex]) => ( +
+
+ {ADJ_CLASS_LABEL[cls]} — {title} +
+
{words}
+
{body}
+
{ex}
+ +
+ ))} + +
Two things worth pinning down
+
+ Strong genitive masc/neut is -en, not -es — trotz schlechten + Wetters. The noun already carries the -(e)s, so the adjective doesn't repeat it. +
+
+ After the verb, an adjective takes no ending at all — + Das Haus ist groß, never großes. An adjective only declines + when it stands in front of its noun. +
+
+ Beug has no plural nouns yet, so the plural column is shown for reference but is + not drilled. +
+ + +
+
+ ); +} + // The wohin/wo rule, the trap, the verb pairs, and the one real exception. function WechselRuleModal({ onClose }) { const PAIRS = [ @@ -1453,7 +1712,7 @@ function ChartModal({ section, q, onClose, onRule }) { Wechsel: Akkusativ when the phrase is where something ends up (wohin?), Dativ when it is where the action happens (wo?) — movement alone does not decide it. - {onRule && } + {onRule && } )} @@ -1469,6 +1728,23 @@ function ChartModal({ section, q, onClose, onRule }) { )} + {section === 'adj' && ( +
+
Adjective Endings
+
+ The case is signalled once. If the determiner shows it, the adjective goes limp. +
+ {['weak','mixed','strong'].map(cls => ( +
+
{ADJ_CLASS_LABEL[cls]}
+
{ADJ_CLASS_HINT[cls]}
+ +
+ ))} + {onRule && } +
+ )} + {section === 'pronoun' && (
Personal Pronouns
@@ -1584,8 +1860,10 @@ const S = { pillBtnOn: { border:`1.5px solid ${GREEN}`, color:GREEN, background:'#f3ffe8' }, chartsPill: { position:'absolute', right:20, bottom:2, borderRadius:999, padding:'5px 13px', fontSize:12, fontWeight:600, cursor:'pointer', fontFamily:'inherit', lineHeight:1.2, border:'1.5px solid #d5d5d5', color:'#777', background:'#fff' }, - tabs: { display:'flex', gap:0, borderBottom:'1px solid #eee', marginTop:6, marginBottom:12 }, - tab: { flex:1, background:'none', border:'none', padding:'12px 8px', cursor:'pointer', color:'#999', fontSize:13, fontWeight:600, fontFamily:'inherit', borderBottomWidth:2, borderBottomStyle:'solid', borderBottomColor:'transparent', marginBottom:-1, transition:'all 0.15s' }, + // Five tabs no longer fit across a phone, so the row scrolls instead of + // squeezing the labels. The scrollbar itself is hidden (see the