Kontraktionen: ask it backwards, as a two-tap word cloud
"hinter + das = ?" with options am / im / uebers / hinters was no test at
all — only one option could start with "hinter", so you never had to know
which article was fused in. The whole level was answerable on the prefix.
Flipped it. The fused form is given and you take it apart:
- The form is shown large ("im"), and you pick the preposition from a word
cloud, then the article from a second one. Graded on both parts.
- The preposition step is a real question for the truncated forms — im vs
am, vom, zum/zur — which are exactly the ones that get mixed up.
- The article step is the point: -s means das, -m means dem, -r means der.
Distractors are always real articles (das/dem/der/die/den), never whole
contraction forms, so elimination on shape no longer works.
- Feedback names which half was wrong and states the ending rule, then
highlights the article in the definite-article table.
- The chosen preposition chip is tappable to change before committing;
backspace does the same on desktop. On reveal the line resolves to the
correct pair.
Tests: the old flaw is pinned shut (nothing on offer is ever a contraction
form), both choice sets are checked, and a data test asserts the -s/-m/-r
ending predicts the article for all 16 contractions.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Uza6mhTpAvSwMQuFc4Cz8
This commit is contained in:
+94
-17
@@ -184,6 +184,7 @@ export default function App() {
|
||||
const [res, setRes] = useState([]);
|
||||
const [chart, setChart] = useState(false);
|
||||
const [rule, setRule] = useState(false);
|
||||
const [pickPrep, setPickPrep] = 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);
|
||||
@@ -239,7 +240,7 @@ export default function App() {
|
||||
const lvObj = LEVELS.find(l => l.id === id);
|
||||
if (lvObj?.tab) setTab(lvObj.tab);
|
||||
sessionRef.current = createSession(id);
|
||||
setLv(id); setRes([]); setInp(''); setRev(false); setQ(sessionRef.current.next()); setScr('practice');
|
||||
setLv(id); setRes([]); setInp(''); setRev(false); setPickPrep(null); setQ(sessionRef.current.next()); setScr('practice');
|
||||
};
|
||||
|
||||
const openCheckout = plan => {
|
||||
@@ -257,7 +258,9 @@ export default function App() {
|
||||
const doSubmit = useCallback((overrideInput) => {
|
||||
const userAns = (overrideInput ?? inp).trim();
|
||||
if (!userAns) { setShake(true); setTimeout(()=>setShake(false),400); return; }
|
||||
const correct = userAns.toLowerCase() === q.ans.toLowerCase();
|
||||
// Two-part answers (contractions) grade against "prep art", not the form.
|
||||
const target = q.answerKey ?? q.ans;
|
||||
const correct = userAns.toLowerCase() === target.toLowerCase();
|
||||
if (overrideInput !== undefined) setInp(overrideInput);
|
||||
// Dismiss mobile keyboard so feedback is visible
|
||||
try { iref.current?.blur(); } catch(e){}
|
||||
@@ -274,7 +277,7 @@ export default function App() {
|
||||
|
||||
const doNext = useCallback(() => {
|
||||
if (res.length >= ROUND) { setScr('summary'); return; }
|
||||
setInp(''); setRev(false); setOk(false);
|
||||
setInp(''); setRev(false); setOk(false); setPickPrep(null);
|
||||
if (!sessionRef.current) sessionRef.current = createSession(lv);
|
||||
setQ(sessionRef.current.next());
|
||||
}, [res, lv]);
|
||||
@@ -288,6 +291,17 @@ export default function App() {
|
||||
if (chart) { if (e.key === 'Escape') setChart(false); return; }
|
||||
if (scr === 'summary' && e.key === 'Enter') { e.preventDefault(); summaryRef.current?.(); return; }
|
||||
if (scr !== 'practice') return;
|
||||
if (!rev && q?.type === 'contraction') {
|
||||
const cloud = pickPrep === null ? q.prepChoices : q.artChoices;
|
||||
const idx = ['1','2','3','4','5','6'].indexOf(e.key);
|
||||
if (idx >= 0 && cloud[idx]) {
|
||||
e.preventDefault();
|
||||
if (pickPrep === null) setPickPrep(cloud[idx]);
|
||||
else submitRef.current(`${pickPrep} ${cloud[idx]}`);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Backspace' && pickPrep !== null) { e.preventDefault(); setPickPrep(null); return; }
|
||||
}
|
||||
if (!rev && q?.choices) {
|
||||
const idx = ['1','2','3','4'].indexOf(e.key);
|
||||
if (idx >= 0 && q.choices[idx]) { e.preventDefault(); submitRef.current(q.choices[idx]); return; }
|
||||
@@ -691,18 +705,47 @@ export default function App() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* PRACTICE: contractions */}
|
||||
{/* PRACTICE: contractions — shown fused, taken apart in two taps */}
|
||||
{scr === 'practice' && q?.type === 'contraction' && (
|
||||
<div style={{textAlign:'center', paddingTop:30}}>
|
||||
<div style={{fontSize:13,color:'#999',marginBottom:12}}>Contract the preposition and article</div>
|
||||
<div style={{fontSize:34, fontWeight:700, color:'#1a1a1a', margin:'4px 0 6px'}}>
|
||||
{q.prep} <span style={{color:'#ccc'}}>+</span> {q.art} <span style={{color:'#ccc'}}>=</span> <span style={{color:'#ccc'}}>?</span>
|
||||
<div style={{textAlign:'center', paddingTop:20}}>
|
||||
<div style={{fontSize:13,color:'#999',marginBottom:10}}>What is this a contraction of?</div>
|
||||
<div style={{fontSize:40, fontWeight:700, color:'#1a1a1a', margin:'4px 0 10px', letterSpacing:'-0.5px'}}>
|
||||
{q.ans}
|
||||
</div>
|
||||
<div style={{fontSize:12, color:'#aaa', marginBottom:22}}>
|
||||
|
||||
<div style={{fontSize:22, fontWeight:600, marginBottom:14, minHeight:30}}>
|
||||
{rev
|
||||
? <span style={{color: ok ? GREEN : RED}}>
|
||||
{q.prep} <span style={{color:'#ccc', margin:'0 4px'}}>+</span> {q.art}
|
||||
</span>
|
||||
: pickPrep
|
||||
? <>
|
||||
<button style={S.chosenChip} onClick={()=>setPickPrep(null)} title="tap to change">{pickPrep}</button>
|
||||
<span style={{color:'#ccc', margin:'0 8px'}}>+</span>
|
||||
<span style={{color:'#ccc'}}>?</span>
|
||||
</>
|
||||
: <span style={{color:'#ccc'}}>? <span style={{margin:'0 8px'}}>+</span> ?</span>}
|
||||
</div>
|
||||
|
||||
<Hideable hidden={hintHidden('caseInfo')} onToggle={()=>toggleHint('caseInfo')} label="case" style={S.secondaryContext}>
|
||||
<span style={{color:CASE_C[q.cas], fontWeight:600}}>{CL[q.cas]}</span>
|
||||
</div>
|
||||
</Hideable>
|
||||
|
||||
{!rev ? (
|
||||
<AnswerGrid choices={q.choices} onPick={doSubmit}/>
|
||||
<div>
|
||||
<div style={S.cloudStep}>
|
||||
{pickPrep ? 'Now the article' : 'Which preposition?'}
|
||||
</div>
|
||||
<WordCloud
|
||||
words={pickPrep === null ? q.prepChoices : q.artChoices}
|
||||
onPick={w => { if (pickPrep === null) setPickPrep(w); else doSubmit(`${pickPrep} ${w}`); }}
|
||||
/>
|
||||
{pickPrep && !IS_TOUCH && (
|
||||
<div style={{...S.hintText, marginTop:12}}>
|
||||
<kbd style={S.kbd}>backspace</kbd> to change the preposition
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : quickTick ? (
|
||||
<QuickTick/>
|
||||
) : (
|
||||
@@ -967,6 +1010,22 @@ function AnswerGrid({ choices, onPick }) {
|
||||
);
|
||||
}
|
||||
|
||||
// A loose cloud of word chips. Sizes vary a little so it reads as a cloud
|
||||
// rather than a list, but every chip keeps a full-height tap target.
|
||||
function WordCloud({ words, onPick }) {
|
||||
return (
|
||||
<div style={S.cloud}>
|
||||
{words.map((w, i) => (
|
||||
<button key={w} style={{...S.cloudChip, fontSize: 16 + (w.length > 4 ? 2 : 4)}}
|
||||
onClick={()=>onPick(w)}>
|
||||
{w}
|
||||
{!IS_TOUCH && <sub style={S.cloudKbd}>{i+1}</sub>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// One-tap correct answer: no solution shown, just a tick before auto-skip.
|
||||
function QuickTick() {
|
||||
return (
|
||||
@@ -1239,16 +1298,29 @@ function ChunkFeedback({ q, inp, ok }) {
|
||||
}
|
||||
|
||||
function ContractionFeedback({ q, inp, ok }) {
|
||||
const [gotPrep, gotArt] = (inp || '').split(' ');
|
||||
const ending = q.ans.slice(-1);
|
||||
const endingRule = {s:'das', m:'dem', r:'der'}[ending];
|
||||
return (
|
||||
<div style={{...S.feedback, animation:'fadeUp 0.2s ease-out', maxWidth:420}}>
|
||||
<div style={{fontSize:15, fontWeight:600, marginBottom:8, color:ok?GREEN:'#1a1a1a'}}>
|
||||
{ok
|
||||
? <>✓ {q.prep} + {q.art} = <span style={{color:GREEN}}>{q.ans}</span></>
|
||||
: <>✗ <span style={{textDecoration:'line-through', color:'#999', fontWeight:400}}>{inp}</span> <span style={{color:'#999', fontWeight:400}}>→</span> <span style={{color:GREEN}}>{q.ans}</span></>}
|
||||
</div>
|
||||
<div style={{fontSize:13, color:'#666', marginBottom:6, lineHeight:1.5}}>
|
||||
{q.prep} + {q.art} = <strong style={{color:'#1a1a1a'}}>{q.ans}</strong>
|
||||
? <>✓ <span style={{color:GREEN}}>{q.ans}</span> = {q.prep} + {q.art}</>
|
||||
: <>✗ <span style={{textDecoration:'line-through', color:'#999', fontWeight:400}}>{gotPrep} + {gotArt}</span> <span style={{color:'#999', fontWeight:400}}>→</span> <span style={{color:GREEN}}>{q.prep} + {q.art}</span></>}
|
||||
</div>
|
||||
{!ok && (
|
||||
<div style={{fontSize:13, color:'#666', marginBottom:8, lineHeight:1.5}}>
|
||||
<strong>{q.ans}</strong> = {q.prep} + {q.art}
|
||||
{gotPrep !== q.prep && <> — the preposition is <strong>{q.prep}</strong>, not {gotPrep}.</>}
|
||||
</div>
|
||||
)}
|
||||
{endingRule && (
|
||||
<div style={{fontSize:12, color:'#888', marginBottom:8, lineHeight:1.5}}>
|
||||
Ending <strong style={{color:'#1a1a1a'}}>-{ending}</strong> always means{' '}
|
||||
<strong style={{color:'#1a1a1a'}}>{endingRule}</strong>. That is the whole trick:
|
||||
-s → das, -m → dem, -r → der.
|
||||
</div>
|
||||
)}
|
||||
<div style={{fontSize:12, color: q.pref ? GREEN : '#d97706', lineHeight:1.5}}>
|
||||
{q.pref
|
||||
? 'Preferred — use this contraction in both speech and writing.'
|
||||
@@ -1261,7 +1333,7 @@ function ContractionFeedback({ q, inp, ok }) {
|
||||
valueFor={(g, c) => DEF[g][c]}
|
||||
focusG={null}
|
||||
isCorrect={(g, c, val) => normAns(val) === normAns(q.art)}
|
||||
isWrong={null}
|
||||
isWrong={ok ? null : (g, c, val) => !!gotArt && normAns(val) === normAns(gotArt)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -1561,6 +1633,11 @@ 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'} : {}) },
|
||||
|
||||
cloud: { display:'flex', flexWrap:'wrap', gap:10, justifyContent:'center', alignItems:'center', maxWidth:360, margin:'0 auto' },
|
||||
cloudChip: { position:'relative', padding:'14px 20px', borderRadius:999, border:'1.5px solid #ddd', background:'#fff', color:'#1a1a1a', fontWeight:700, cursor:'pointer', fontFamily:'inherit', minHeight:52, transition:'all 0.12s' },
|
||||
cloudKbd: { position:'absolute', top:4, right:8, fontSize:9, color:'#ccc', fontWeight:600 },
|
||||
cloudStep: { fontSize:11, fontWeight:700, textTransform:'uppercase', letterSpacing:0.8, color:'#bbb', marginBottom:12 },
|
||||
chosenChip: { padding:'6px 16px', borderRadius:999, border:`1.5px solid ${GREEN}`, background:'#f3ffe8', color:GREEN, fontSize:20, fontWeight:700, cursor:'pointer', fontFamily:'inherit' },
|
||||
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 },
|
||||
|
||||
Reference in New Issue
Block a user