Wechsel im Kontext: blank the article, make the verb decide, add the rule

The level showed the article already in the sentence ("unter dem Stuhl"),
so it was answerable by recognising "dem" — it never tested wohin/wo at
all. And the frame pool was tiny: one cat, one lamp, eight nouns.

- The article is now blanked. The only evidence is the verb and whether it
  has a direct object, which is the actual skill. The solved sentence is
  revealed on answer, contracted where German really contracts (ins Regal,
  am Bahnhof), with a note naming the fusion.
- Frames rebuilt around what decides the case: 27 new nouns, 18 new verbs,
  and every frame one-way so no verb+noun pairing licenses both readings.
- Motion verbs now appear on the DATIVE side on purpose — schwimmen im See,
  tanzen in der Kueche, spielen auf der Strasse — because "is it moving?" is
  the wrong test and that is the main thing learners get wrong.
- The put/be pairs are drilled properly: legen/liegen, stellen/stehen,
  setzen/sitzen, plus haengen and stecken, which keep the same present tense
  either way so only the direct object tells you.
- ankommen added as the lexical exception: Dativ despite obvious arrival.
- New "Wohin oder wo?" info panel: the rule, the before-and-after test, the
  movement trap, the verb-pair table and the exception. Reachable from the
  practice screen, from the answer feedback, and from Charts.
- Feedback now explains the specific sentence rather than restating theory.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Uza6mhTpAvSwMQuFc4Cz8
This commit is contained in:
2026-09-17 06:55:26 +00:00
co-authored by Claude Opus 5
parent f39db3cb86
commit 047454f23f
3 changed files with 418 additions and 67 deletions
+170 -34
View File
@@ -183,6 +183,7 @@ export default function App() {
const [ok, setOk] = useState(false);
const [res, setRes] = useState([]);
const [chart, setChart] = useState(false);
const [rule, setRule] = useState(false);
const [learnStep, setLearnStep] = useState(0);
const [st, setSt] = useState({sc:{},tc:0,ta:0,streak:0,ld:null});
const [access, setAccess] = useState(null);
@@ -283,6 +284,7 @@ export default function App() {
useEffect(() => {
const h = e => {
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; }
if (scr !== 'practice') return;
@@ -640,30 +642,36 @@ export default function App() {
{/* PRACTICE: wechsel in context */}
{scr === 'practice' && q?.type === 'wechselContext' && (
<div style={{textAlign:'center', paddingTop:18}}>
<div style={{...S.sentence, animation:shake?'shake 0.3s':'none'}}>{q.sentence}</div>
{(!hintHidden('translation') || rev) ? (
<>
<div style={S.translation}>
{q.translation}
{!rev && <button style={{...S.hintToggle, marginLeft:8}} onClick={()=>toggleHint('translation')}>hide</button>}
</div>
<div style={S.keyInfo}>
<span style={{fontWeight:700}}>{q.prepKey}</span>
<span style={S.keyInfoMute}> ({PREPS[q.prepKey].en})</span>
</div>
</>
) : (
<>
<div style={S.translation}>
<button style={S.hintToggle} onClick={()=>toggleHint('translation')}>show translation</button>
</div>
<div style={S.keyInfo}>
<span style={{fontWeight:700}}>{q.prepKey}</span>
</div>
</>
)}
<div style={S.secondaryContext}>Wechselpräposition · motion or location?</div>
<div style={{textAlign:'center', paddingTop:10}}>
<div style={{...S.sentence, animation:shake?'shake 0.3s':'none'}}>
{q.sentence.split('_____').map((part,i,a)=>(
<span key={i}>
{part}
{i < a.length-1 && (
<span style={{...S.blank,
borderBottomColor: rev?(ok?GREEN:RED):'#bbb',
color: rev?(ok?GREEN:RED):'#1a1a1a',
}}>
{rev ? q.article : ' '}
</span>
)}
</span>
))}
</div>
<Hideable hidden={hintHidden('translation') && !rev} onToggle={()=>toggleHint('translation')} label="translation" style={S.translation}>
{q.translation}
</Hideable>
<Hideable hidden={hintHidden('prepHint')} onToggle={()=>toggleHint('prepHint')} label="preposition" style={S.keyInfo}>
<span style={{fontWeight:700}}>{q.prepKey}</span>
<span style={S.keyInfoMute}> ({PREPS[q.prepKey].en})</span>
</Hideable>
<div style={S.secondaryContext}>
Wechselpräposition · wohin or wo?
<button style={S.ruleBtn} onClick={()=>setRule(true)}>ⓘ Rule</button>
</div>
{!rev ? (
<div style={{display:'flex',gap:10,justifyContent:'center',maxWidth:340,margin:'8px auto 0'}}>
@@ -678,7 +686,7 @@ export default function App() {
) : quickTick ? (
<QuickTick/>
) : (
<WechselContextFeedback q={q} inp={inp} ok={ok}/>
<WechselContextFeedback q={q} inp={inp} ok={ok} onRule={()=>setRule(true)}/>
)}
</div>
)}
@@ -772,7 +780,8 @@ export default function App() {
{scr !== 'practice' && <FeatureRequestFooter />}
</div>
{chart && <ChartModal section={chartSection} q={q} onClose={()=>setChart(false)}/>}
{chart && <ChartModal section={chartSection} q={q} onClose={()=>setChart(false)} onRule={()=>{ setChart(false); setRule(true); }}/>}
{rule && <WechselRuleModal onClose={()=>setRule(false)}/>}
<style>{`
@keyframes shake{0%,100%{transform:translateX(0)}25%{transform:translateX(-6px)}75%{transform:translateX(6px)}}
@@ -1063,17 +1072,135 @@ function PrepFeedback({ q, inp, ok }) {
);
}
function WechselContextFeedback({ q, inp, ok }) {
function WechselContextFeedback({ q, inp, ok, onRule }) {
return (
<div style={{...S.feedback, animation:'fadeUp 0.2s ease-out', maxWidth:420}}>
<div style={{fontSize:15, fontWeight:600, marginBottom:6, color:ok?GREEN:'#1a1a1a'}}>
<div style={{fontSize:15, fontWeight:600, marginBottom:8, color:ok?GREEN:'#1a1a1a'}}>
{ok ? <>✓ <span style={{color:CASE_C[q.ans]}}>{CL[q.ans]}</span></> : <>✗ <span style={{textDecoration:'line-through',color:'#999',fontWeight:400}}>{CL[inp]||inp}</span> <span style={{color:'#999',fontWeight:400}}>→</span> <span style={{color:CASE_C[q.ans]}}>{CL[q.ans]}</span></>}
</div>
<div style={{fontSize:13, color:'#666', marginBottom:6, lineHeight:1.5}}>
„{q.verb}" {q.isMotion ? 'implies motion (wohin?)' : 'implies location (wo?)'} → <span style={{color:CASE_C[q.ans], fontWeight:600}}>{CL[q.ans]}</span>
<div style={{fontSize:15, fontWeight:600, color:'#1a1a1a', marginBottom:8, lineHeight:1.45}}>
{q.answerSentence}
</div>
<div style={{fontSize:12, color:'#999', marginTop:4, lineHeight:1.6}}>
Wechselpräpositionen take <span style={{color:CASE_C.accusative}}>Akkusativ</span> when something moves toward a destination — wohin? (gehen, fahren, legen, stellen) — and <span style={{color:CASE_C.dative}}>Dativ</span> when it stays put — wo? (sein, stehen, sitzen, liegen).
{q.contraction && (
<div style={{fontSize:12, color:'#999', marginBottom:8}}>
{q.prepKey} + {q.article} fuses to <strong style={{color:'#666'}}>{q.contraction}</strong> in real German.
</div>
)}
<div style={{fontSize:13, color:'#666', marginBottom:6, lineHeight:1.5}}>
„{q.verb === 'ankommen' ? 'ankommen' : q.verb}" {q.isMotion
? 'puts something somewhere new (wohin?)'
: q.verb === 'ankommen'
? 'keeps the Dativ even though you arrive — the an- prefix already carries the arrival'
: 'just says where it all happens (wo?)'} → <span style={{color:CASE_C[q.ans], fontWeight:600}}>{CL[q.ans]}</span>
</div>
<div style={{fontSize:12, color:'#999', lineHeight:1.6}}>
Before this happened, was it already {PREPS[q.prepKey].en.split('/')[0]} the {q.nounEn}?
{' '}{q.isMotion
? <>No — it got there because of the action. That is <span style={{color:CASE_C.accusative}}>Akkusativ</span>.</>
: <>Yes — it was already there and stays. That is <span style={{color:CASE_C.dative}}>Dativ</span>.</>}
</div>
{onRule && (
<button style={{...S.ruleBtn, marginLeft:0, marginTop:10}} onClick={onRule}>ⓘ The whole rule</button>
)}
</div>
);
}
// The wohin/wo rule, the trap, the verb pairs, and the one real exception.
function WechselRuleModal({ onClose }) {
const PAIRS = [
['legen', 'lay it down', 'liegen', 'be lying'],
['stellen', 'stand it up', 'stehen', 'be standing'],
['setzen', 'sit it down', 'sitzen', 'be sitting'],
['hängen', 'hang it up (hängte)', 'hängen', 'be hanging (hing)'],
['stecken', 'stick it in', 'stecken', 'be stuck in'],
];
return (
<div style={S.overlay} onClick={onClose}>
<div style={S.modal} onClick={e=>e.stopPropagation()}>
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:14}}>
<div style={{fontSize:17,fontWeight:700}}>Wohin oder wo?</div>
<button onClick={onClose} style={{background:'none',border:'none',fontSize:18,color:'#999',cursor:'pointer'}}>✕</button>
</div>
<div style={S.ruleLead}>
Yes — there is a rule, and it is not “is something moving?”. Ask instead:
does the phrase say where the thing <strong>ends up</strong>, or where the
whole action <strong>happens</strong>?
</div>
<div style={{...S.ruleCard, borderLeft:`3px solid ${CASE_C.accusative}`}}>
<div style={{fontWeight:700, color:CASE_C.accusative, fontSize:13, marginBottom:4}}>Akkusativ — wohin?</div>
<div style={S.ruleBody}>The action puts something into a new place. It wasn’t there before; it is now.</div>
<div style={S.ruleEx}>Er legt das Buch auf <strong>den</strong> Tisch. — the book arrives on the table.</div>
</div>
<div style={{...S.ruleCard, borderLeft:`3px solid ${CASE_C.dative}`}}>
<div style={{fontWeight:700, color:CASE_C.dative, fontSize:13, marginBottom:4}}>Dativ — wo?</div>
<div style={S.ruleBody}>The phrase just locates the scene. It was already there and stays there.</div>
<div style={S.ruleEx}>Das Buch liegt auf <strong>dem</strong> Tisch. — it was already on the table.</div>
</div>
<div style={S.ruleH}>The test that actually works</div>
<div style={S.ruleBody}>
<strong>“Before this happened, was it already there?”</strong><br/>
Already there → <span style={{color:CASE_C.dative,fontWeight:600}}>Dativ</span>.
Got there because of the action → <span style={{color:CASE_C.accusative,fontWeight:600}}>Akkusativ</span>.
</div>
<div style={S.ruleH}>The trap: movement is not the test</div>
<div style={S.ruleBody}>
Plenty of motion verbs take the Dativ, because the phrase is the arena the
movement happens in, not a destination:
</div>
<div style={S.ruleEx}>
Er schwimmt in <strong>dem</strong> See (im See) — swimming around <em>in</em> the lake.<br/>
Er springt in <strong>den</strong> See — he was out, now he’s in.
</div>
<div style={S.ruleBody}>Same story with tanzen, spielen, laufen, rennen.</div>
<div style={S.ruleH}>The verb pairs — learn these cold</div>
<div style={{fontSize:11, color:'#999', marginBottom:8, lineHeight:1.5}}>
The Akkusativ ones are transitive: they always have a direct object being moved.
If something is being put somewhere, the preposition takes Akkusativ too.
</div>
<table style={S.fullChart}>
<thead><tr>
<th style={{...S.fcTh, textAlign:'left', color:CASE_C.accusative}}>puts it there · Akk</th>
<th style={{...S.fcTh, textAlign:'left', color:CASE_C.dative}}>is already there · Dat</th>
</tr></thead>
<tbody>
{PAIRS.map(([a, aEn, d, dEn]) => (
<tr key={a + d}>
<td style={{...S.fcTd, textAlign:'left'}}>
<strong>{a}</strong> <span style={{color:'#aaa', fontSize:11}}>{aEn}</span>
</td>
<td style={{...S.fcTd, textAlign:'left'}}>
<strong>{d}</strong> <span style={{color:'#aaa', fontSize:11}}>{dEn}</span>
</td>
</tr>
))}
</tbody>
</table>
<div style={{fontSize:11, color:'#999', marginTop:6, lineHeight:1.5}}>
hängen and stecken are the mean ones — same present tense either way, so the
only clue is whether there is a direct object.
</div>
<div style={S.ruleH}>The exception worth knowing</div>
<div style={S.ruleBody}>
<strong>ankommen</strong> takes the <span style={{color:CASE_C.dative,fontWeight:600}}>Dativ</span>,
even though arriving is the most obvious movement there is:
</div>
<div style={S.ruleEx}>Wir kommen an <strong>dem</strong> Bahnhof an (am Bahnhof).</div>
<div style={S.ruleBody}>
The <em>an-</em> prefix already carries the arrival, so the phrase only says where
you ended up being. <em>befestigen an</em> + Dativ works the same way.
</div>
<button style={{...S.nextBtn, width:'100%', marginTop:16}} onClick={onClose}>
Got it{!IS_TOUCH && <span style={{opacity:0.5,fontSize:12,marginLeft:4}}>esc</span>}
</button>
</div>
</div>
);
@@ -1180,7 +1307,7 @@ function PronounFeedback({ q, inp, ok }) {
// CHART MODAL
// ═══════════════════════════════════════════
function ChartModal({ section, q, onClose }) {
function ChartModal({ section, q, onClose, onRule }) {
const genders = ['masculine','feminine','neuter','plural'];
const cases = ['nominative','accusative','dative','genitive'];
@@ -1251,7 +1378,10 @@ function ChartModal({ section, q, onClose }) {
<div><strong style={{color:CASE_C.wechsel}}>Wechsel:</strong> {PREPS_BY_CASE.wechsel.join(', ')}</div>
</div>
<div style={{fontSize:12, color:'#999', marginTop:14, lineHeight:1.6}}>
<strong>Wechsel</strong>: accusative for motion (wohin?), dative for location (wo?).
<strong>Wechsel</strong>: 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 && <button style={{...S.ruleBtn, marginLeft:0, marginTop:8, display:'block'}} onClick={onRule}>ⓘ Wohin oder wo?</button>}
</div>
</div>
)}
@@ -1431,6 +1561,12 @@ const S = {
choiceBtn: { flex:'1 1 auto', minWidth:90, padding:'14px 16px', borderRadius:10, border:'1.5px solid', background:'#fff', color:'#1a1a1a', cursor:'pointer', fontSize:14, fontFamily:'inherit', textAlign:'center', transition:'all 0.12s',
...(IS_TOUCH ? {minHeight:66, display:'flex', flexDirection:'column', alignItems:'center', justifyContent:'center'} : {}) },
ruleBtn: { marginLeft:8, background:'none', border:'1px solid #ddd', borderRadius:999, padding:'3px 10px', color:'#888', fontSize:11, fontWeight:600, cursor:'pointer', fontFamily:'inherit' },
ruleLead: { fontSize:13, color:'#444', lineHeight:1.6, marginBottom:14 },
ruleCard: { background:'#fafafa', borderRadius:8, padding:'12px 14px', marginBottom:10 },
ruleH: { fontSize:12, fontWeight:800, textTransform:'uppercase', letterSpacing:0.6, color:'#bbb', margin:'18px 0 6px' },
ruleBody: { fontSize:13, color:'#555', lineHeight:1.6 },
ruleEx: { fontSize:13, color:'#1a1a1a', lineHeight:1.7, background:'#f5f5f5', borderRadius:6, padding:'8px 10px', margin:'8px 0' },
feedback: { textAlign:'left', background:'#fff', borderRadius:10, padding:'14px 18px', margin:'0 auto', maxWidth:400, border:'1px solid #eee' },
stripTbl: { width:'100%', borderCollapse:'collapse', marginTop:10 },