One-tap grids everywhere, hideable translation/hints, cleaner top and bottom bars

- Possessives, pronouns, and Kontraktionen switch from typed input to the
  same 4-choice tap grid as articles. Possessive choices all share the
  question's stem; pronoun choices lead with the pronoun's own case forms.
- The translation line is hideable everywhere (shows by default, hidden
  state persists like the other hints, revealed again on a wrong answer).
  The possessive and pronoun hint lines get hide toggles too.
- Header: Charts moves out; One-tap becomes a pill — plain grey when off,
  green outlined when on. More margin below the header across the board.
- Bottom bar: progress dots centered with 'n/10 · level name' underneath,
  outlined Charts button (capital C) pinned bottom-right.
- Touch devices hide the 1-4/enter/esc keyboard chips; answer buttons keep
  their full size via min-heights so tap targets stay big.
This commit is contained in:
2026-07-21 07:27:32 +00:00
parent 1444814062
commit f39db3cb86
3 changed files with 128 additions and 67 deletions
+67 -48
View File
@@ -169,6 +169,10 @@ const ROUND = 10;
const GREEN = '#58a700'; const GREEN = '#58a700';
const RED = '#dc2626'; const RED = '#dc2626';
// Touch devices don't get keyboard hints (14, ↵) — but the answer buttons
// keep the same footprint so tap targets stay big.
const IS_TOUCH = typeof window !== 'undefined' && !!window.matchMedia?.('(pointer: coarse)').matches;
export default function App() { export default function App() {
const [scr, setScr] = useState('home'); const [scr, setScr] = useState('home');
const [tab, setTab] = useState('prep'); const [tab, setTab] = useState('prep');
@@ -282,7 +286,7 @@ export default function App() {
if (chart) { if (e.key === 'Escape') setChart(false); return; } if (chart) { if (e.key === 'Escape') setChart(false); return; }
if (scr === 'summary' && e.key === 'Enter') { e.preventDefault(); summaryRef.current?.(); return; } if (scr === 'summary' && e.key === 'Enter') { e.preventDefault(); summaryRef.current?.(); return; }
if (scr !== 'practice') return; if (scr !== 'practice') return;
if (!rev && (q?.type === 'article' || q?.type === 'chunk') && q.choices) { if (!rev && q?.choices) {
const idx = ['1','2','3','4'].indexOf(e.key); const idx = ['1','2','3','4'].indexOf(e.key);
if (idx >= 0 && q.choices[idx]) { e.preventDefault(); submitRef.current(q.choices[idx]); return; } if (idx >= 0 && q.choices[idx]) { e.preventDefault(); submitRef.current(q.choices[idx]); return; }
} }
@@ -296,7 +300,7 @@ export default function App() {
if (e.key === '1') { e.preventDefault(); submitRef.current('accusative'); return; } if (e.key === '1') { e.preventDefault(); submitRef.current('accusative'); return; }
if (e.key === '2') { e.preventDefault(); submitRef.current('dative'); return; } if (e.key === '2') { e.preventDefault(); submitRef.current('dative'); return; }
} }
if (e.key === 'Enter') { e.preventDefault(); if (rev) nextRef.current(); else if (q?.type !== 'article' && q?.type !== 'chunk') submitRef.current(); } if (e.key === 'Enter') { e.preventDefault(); if (rev) nextRef.current(); else if (!q?.choices && q?.type !== 'prep' && q?.type !== 'wechselContext') submitRef.current(); }
if (rev && (e.key === 'ArrowRight' || e.key === ' ')) { e.preventDefault(); nextRef.current(); } if (rev && (e.key === 'ArrowRight' || e.key === ' ')) { e.preventDefault(); nextRef.current(); }
}; };
window.addEventListener('keydown', h); window.addEventListener('keydown', h);
@@ -353,16 +357,15 @@ export default function App() {
{scr !== 'home' && scr !== 'menu' {scr !== 'home' && scr !== 'menu'
? <button style={S.linkBtn} onClick={()=>setScr('menu')}> back</button> ? <button style={S.linkBtn} onClick={()=>setScr('menu')}> back</button>
: <div style={S.logo}>Beug</div>} : <div style={S.logo}>Beug</div>}
<div style={{display:'flex',gap:14,alignItems:'center'}}> <div style={{display:'flex',gap:12,alignItems:'center'}}>
{scr === 'practice' && ( {scr === 'practice' && (
<button <button
style={{...S.linkBtn, color: quickActive ? GREEN : '#999', fontWeight: quickActive ? 700 : 500}} style={{...S.pillBtn, ...(quickActive ? S.pillBtnOn : S.pillBtnOff)}}
title={cur?.daily ? 'One-tap is always on in Daily Practice' : 'Answer with one tap, then auto-advance'} title={cur?.daily ? 'One-tap is always on in Daily Practice' : 'Answer with one tap, then auto-advance'}
onClick={()=>{ if (cur?.daily) return; save({...st, auto: !st.auto}); }}> onClick={()=>{ if (cur?.daily) return; save({...st, auto: !st.auto}); }}>
{quickActive ? 'One-tap' : 'One-tap off'} One-tap
</button> </button>
)} )}
{scr === 'practice' && <button style={S.linkBtn} onClick={()=>setChart(true)}>charts</button>}
{PAYMENTS_ENABLED && hasPaidAccess && <span style={S.stat}>Unlocked</span>} {PAYMENTS_ENABLED && hasPaidAccess && <span style={S.stat}>Unlocked</span>}
<StreakBadge st={st}/> <StreakBadge st={st}/>
</div> </div>
@@ -549,7 +552,9 @@ export default function App() {
))} ))}
</div> </div>
<div style={S.translation}>{q.translation}</div> <Hideable hidden={hintHidden('translation') && !rev} onToggle={()=>toggleHint('translation')} label="translation" style={S.translation}>
{q.translation}
</Hideable>
{(q.type === 'article' || q.type === 'chunk') ? ( {(q.type === 'article' || q.type === 'chunk') ? (
<Hideable hidden={hintHidden('gender')} onToggle={()=>toggleHint('gender')} label="gender" style={S.keyInfo}> <Hideable hidden={hintHidden('gender')} onToggle={()=>toggleHint('gender')} label="gender" style={S.keyInfo}>
@@ -557,18 +562,17 @@ export default function App() {
{' '}{q.noun.w} {' '}{q.noun.w}
<span style={S.keyInfoMute}> ({q.noun.en})</span> <span style={S.keyInfoMute}> ({q.noun.en})</span>
</Hideable> </Hideable>
) : ( ) : q.type === 'poss' ? (
<div style={S.keyInfo}> <Hideable hidden={hintHidden('possHint')} onToggle={()=>toggleHint('possHint')} label="possessive" style={S.keyInfo}>
{q.type === 'poss' && <>
<span style={{fontWeight:700}}>{q.stem}-</span> <span style={{fontWeight:700}}>{q.stem}-</span>
<span style={S.keyInfoMute}> ({q.possMeaning})</span> <span style={S.keyInfoMute}> ({q.possMeaning})</span>
</>} </Hideable>
{q.type === 'pronoun' && <> ) : (
<Hideable hidden={hintHidden('pronounHint')} onToggle={()=>toggleHint('pronounHint')} label="pronoun" style={S.keyInfo}>
<span style={{fontWeight:700}}>{q.pron.nom}</span> <span style={{fontWeight:700}}>{q.pron.nom}</span>
<span style={S.keyInfoMute}> ({q.pron.en})</span> <span style={S.keyInfoMute}> ({q.pron.en})</span>
<span style={S.keyInfoMute}> · {q.pron.tag}</span> <span style={S.keyInfoMute}> · {q.pron.tag}</span>
</>} </Hideable>
</div>
)} )}
<Hideable hidden={hintHidden('caseInfo')} onToggle={()=>toggleHint('caseInfo')} label="case" style={S.secondaryContext}> <Hideable hidden={hintHidden('caseInfo')} onToggle={()=>toggleHint('caseInfo')} label="case" style={S.secondaryContext}>
@@ -579,15 +583,8 @@ export default function App() {
</Hideable> </Hideable>
{!rev ? ( {!rev ? (
(q.type === 'article' || q.type === 'chunk') ? ( q.choices ? (
<div style={S.articleGrid}> <AnswerGrid choices={q.choices} onPick={doSubmit}/>
{q.choices.map((choice, i) => (
<button key={choice} style={S.articleBtn} onClick={()=>doSubmit(choice)}>
<div style={{fontSize: choice.includes(' ') ? 17 : 22, fontWeight:700}}>{choice}</div>
<div style={{fontSize:11,color:'#999',marginTop:4}}><kbd style={S.kbdSmall}>{i+1}</kbd></div>
</button>
))}
</div>
) : ( ) : (
<div style={S.inputWrap}> <div style={S.inputWrap}>
<div style={S.inputRow}> <div style={S.inputRow}>
@@ -599,7 +596,7 @@ export default function App() {
inputMode="text"/> inputMode="text"/>
<button style={S.checkBtn} onClick={()=>doSubmit()}>Check</button> <button style={S.checkBtn} onClick={()=>doSubmit()}>Check</button>
</div> </div>
<div style={S.hintText}>or press <kbd style={S.kbd}>Enter</kbd></div> {!IS_TOUCH && <div style={S.hintText}>or press <kbd style={S.kbd}>Enter</kbd></div>}
</div> </div>
) )
) : quickTick ? ( ) : quickTick ? (
@@ -629,7 +626,7 @@ export default function App() {
<button key={val} style={{...S.choiceBtn, borderColor: CASE_C[val]+'40'}} <button key={val} style={{...S.choiceBtn, borderColor: CASE_C[val]+'40'}}
onClick={()=>doSubmit(val)}> onClick={()=>doSubmit(val)}>
<div style={{fontWeight:700, color: CASE_C[val]}}>{lab}</div> <div style={{fontWeight:700, color: CASE_C[val]}}>{lab}</div>
<div style={{fontSize:11,color:'#999',marginTop:2}}><kbd style={S.kbdSmall}>{key}</kbd></div> {!IS_TOUCH && <div style={{fontSize:11,color:'#999',marginTop:2}}><kbd style={S.kbdSmall}>{key}</kbd></div>}
</button> </button>
))} ))}
</div> </div>
@@ -645,11 +642,11 @@ export default function App() {
{scr === 'practice' && q?.type === 'wechselContext' && ( {scr === 'practice' && q?.type === 'wechselContext' && (
<div style={{textAlign:'center', paddingTop:18}}> <div style={{textAlign:'center', paddingTop:18}}>
<div style={{...S.sentence, animation:shake?'shake 0.3s':'none'}}>{q.sentence}</div> <div style={{...S.sentence, animation:shake?'shake 0.3s':'none'}}>{q.sentence}</div>
{(!hintHidden('meaning') || rev) ? ( {(!hintHidden('translation') || rev) ? (
<> <>
<div style={S.translation}> <div style={S.translation}>
{q.translation} {q.translation}
{!rev && <button style={{...S.hintToggle, marginLeft:8}} onClick={()=>toggleHint('meaning')}>hide</button>} {!rev && <button style={{...S.hintToggle, marginLeft:8}} onClick={()=>toggleHint('translation')}>hide</button>}
</div> </div>
<div style={S.keyInfo}> <div style={S.keyInfo}>
<span style={{fontWeight:700}}>{q.prepKey}</span> <span style={{fontWeight:700}}>{q.prepKey}</span>
@@ -659,7 +656,7 @@ export default function App() {
) : ( ) : (
<> <>
<div style={S.translation}> <div style={S.translation}>
<button style={S.hintToggle} onClick={()=>toggleHint('meaning')}>show translation</button> <button style={S.hintToggle} onClick={()=>toggleHint('translation')}>show translation</button>
</div> </div>
<div style={S.keyInfo}> <div style={S.keyInfo}>
<span style={{fontWeight:700}}>{q.prepKey}</span> <span style={{fontWeight:700}}>{q.prepKey}</span>
@@ -674,7 +671,7 @@ export default function App() {
<button key={val} style={{...S.choiceBtn, borderColor: CASE_C[val]+'40', minWidth:130}} <button key={val} style={{...S.choiceBtn, borderColor: CASE_C[val]+'40', minWidth:130}}
onClick={()=>doSubmit(val)}> onClick={()=>doSubmit(val)}>
<div style={{fontWeight:700, color: CASE_C[val]}}>{lab}</div> <div style={{fontWeight:700, color: CASE_C[val]}}>{lab}</div>
<div style={{fontSize:11,color:'#999',marginTop:2}}><kbd style={S.kbdSmall}>{key}</kbd></div> {!IS_TOUCH && <div style={{fontSize:11,color:'#999',marginTop:2}}><kbd style={S.kbdSmall}>{key}</kbd></div>}
</button> </button>
))} ))}
</div> </div>
@@ -697,16 +694,7 @@ export default function App() {
<span style={{color:CASE_C[q.cas], fontWeight:600}}>{CL[q.cas]}</span> <span style={{color:CASE_C[q.cas], fontWeight:600}}>{CL[q.cas]}</span>
</div> </div>
{!rev ? ( {!rev ? (
<div style={S.inputWrap}> <AnswerGrid choices={q.choices} onPick={doSubmit}/>
<div style={S.inputRow}>
<input ref={iref} value={inp} onChange={e=>setInp(e.target.value)}
style={S.input} placeholder="contraction…"
autoComplete="off" autoCapitalize="off" spellCheck={false}
enterKeyHint="go" inputMode="text"/>
<button style={S.checkBtn} onClick={()=>doSubmit()}>Check</button>
</div>
<div style={S.hintText}>or press <kbd style={S.kbd}>Enter</kbd></div>
</div>
) : quickTick ? ( ) : quickTick ? (
<QuickTick/> <QuickTick/>
) : ( ) : (
@@ -716,20 +704,25 @@ export default function App() {
)} )}
{scr === 'practice' && ( {scr === 'practice' && (
<div style={S.bottomBar}>
<div style={S.bottomBarInner}>
<div style={S.dotsRow}> <div style={S.dotsRow}>
{Array.from({length:ROUND},(_,i)=>( {Array.from({length:ROUND},(_,i)=>(
<div key={i} style={{...S.dot, <div key={i} style={{...S.dot,
background: i<res.length?(res[i]?GREEN:RED):i===res.length?'#333':'#ddd', background: i<res.length?(res[i]?GREEN:RED):i===res.length?'#333':'#ddd',
}}/> }}/>
))} ))}
<span style={S.dotsLabel}>{Math.min(res.length+(rev?0:1), ROUND)}/{ROUND} · {cur?.name}</span> </div>
<div style={S.dotsLabel}>{Math.min(res.length+(rev?0:1), ROUND)}/{ROUND} · {cur?.name}</div>
<button style={S.chartsPill} onClick={()=>setChart(true)}>Charts</button>
</div>
</div> </div>
)} )}
{scr === 'practice' && rev && !quickTick && ( {scr === 'practice' && rev && !quickTick && (
<div style={{textAlign:'center', marginTop:14}}> <div style={{textAlign:'center', marginTop:14}}>
<button style={S.nextBtn} onClick={doNext}> <button style={S.nextBtn} onClick={doNext}>
{res.length >= ROUND ? 'See results' : 'Next'} <span style={{opacity:0.5,fontSize:12,marginLeft:4}}></span> {res.length >= ROUND ? 'See results' : 'Next'}{!IS_TOUCH && <span style={{opacity:0.5,fontSize:12,marginLeft:4}}></span>}
</button> </button>
</div> </div>
)} )}
@@ -750,7 +743,7 @@ export default function App() {
{cur?.daily && nextDaily ? ( {cur?.daily && nextDaily ? (
<> <>
<button style={S.nextBtn} onClick={()=>startLv(nextDaily.level.id)}> <button style={S.nextBtn} onClick={()=>startLv(nextDaily.level.id)}>
Continue: {nextDaily.tab.label} Daily Practice <span style={{opacity:0.5,fontSize:12,marginLeft:4}}></span> Continue: {nextDaily.tab.label} Daily Practice {!IS_TOUCH && <span style={{opacity:0.5,fontSize:12,marginLeft:4}}></span>}
</button> </button>
<div style={{marginTop:14}}> <div style={{marginTop:14}}>
<button style={S.linkBtn} onClick={()=>startLv(lv)}>play again</button> <button style={S.linkBtn} onClick={()=>startLv(lv)}>play again</button>
@@ -950,6 +943,21 @@ function PaywallScreen({ message, onCheckout, onBack }) {
// FEEDBACK COMPONENTS // FEEDBACK COMPONENTS
// ═══════════════════════════════════════════ // ═══════════════════════════════════════════
// Shared 2×2 answer grid. On touch devices the number-key chips disappear
// but the buttons keep their footprint so tap targets stay big.
function AnswerGrid({ choices, onPick }) {
return (
<div style={S.articleGrid}>
{choices.map((choice, i) => (
<button key={choice} style={S.articleBtn} onClick={()=>onPick(choice)}>
<div style={{fontSize: choice.includes(' ') ? 17 : 22, fontWeight:700}}>{choice}</div>
{!IS_TOUCH && <div style={{fontSize:11,color:'#999',marginTop:4}}><kbd style={S.kbdSmall}>{i+1}</kbd></div>}
</button>
))}
</div>
);
}
// One-tap correct answer: no solution shown, just a tick before auto-skip. // One-tap correct answer: no solution shown, just a tick before auto-skip.
function QuickTick() { function QuickTick() {
return ( return (
@@ -1267,7 +1275,7 @@ function ChartModal({ section, q, onClose }) {
)} )}
<button style={{...S.nextBtn, width:'100%', marginTop:10}} onClick={onClose}> <button style={{...S.nextBtn, width:'100%', marginTop:10}} onClick={onClose}>
Close <span style={{opacity:0.5,fontSize:12,marginLeft:4}}>esc</span> Close{!IS_TOUCH && <span style={{opacity:0.5,fontSize:12,marginLeft:4}}>esc</span>}
</button> </button>
</div> </div>
</div> </div>
@@ -1364,11 +1372,16 @@ const S = {
wrap: { background:'#f7f7f7', minHeight:'100vh', color:'#1a1a1a', fontFamily:'"DM Sans",system-ui,sans-serif' }, wrap: { background:'#f7f7f7', minHeight:'100vh', color:'#1a1a1a', fontFamily:'"DM Sans",system-ui,sans-serif' },
inner: { maxWidth:520, margin:'0 auto', padding:'16px 20px 100px' }, inner: { maxWidth:520, margin:'0 auto', padding:'16px 20px 100px' },
hdr: { display:'flex', justifyContent:'space-between', alignItems:'center', padding:'10px 0 8px', position:'sticky', top:0, background:'#f7f7f7', zIndex:10 }, hdr: { display:'flex', justifyContent:'space-between', alignItems:'center', padding:'10px 0 8px', position:'sticky', top:0, background:'#f7f7f7', zIndex:10, marginBottom:16 },
logo: { fontSize:17, fontWeight:700, color:'#1a1a1a' }, logo: { fontSize:17, fontWeight:700, color:'#1a1a1a' },
linkBtn: { background:'none', border:'none', color:'#999', fontSize:13, cursor:'pointer', padding:0, fontWeight:500, fontFamily:'inherit' }, linkBtn: { background:'none', border:'none', color:'#999', fontSize:13, cursor:'pointer', padding:0, fontWeight:500, fontFamily:'inherit' },
stat: { fontSize:13, color:'#999', fontWeight:500 }, stat: { fontSize:13, color:'#999', fontWeight:500 },
pillBtn: { borderRadius:999, padding:'5px 12px', fontSize:12, fontWeight:600, cursor:'pointer', fontFamily:'inherit', lineHeight:1.2, background:'none' },
pillBtnOff: { border:'none', color:'#999' },
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 }, 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' }, 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' },
tabActive: { color:'#1a1a1a', borderBottomColor:GREEN }, tabActive: { color:'#1a1a1a', borderBottomColor:GREEN },
@@ -1410,9 +1423,13 @@ const S = {
kbdSmall: { background:'#f0f0f0', padding:'1px 4px', borderRadius:3, fontSize:9, color:'#888', border:'1px solid #e0e0e0' }, kbdSmall: { background:'#f0f0f0', padding:'1px 4px', borderRadius:3, fontSize:9, color:'#888', border:'1px solid #e0e0e0' },
articleGrid: { display:'grid', gridTemplateColumns:'1fr 1fr', gap:10, maxWidth:320, margin:'12px auto 0' }, articleGrid: { display:'grid', gridTemplateColumns:'1fr 1fr', gap:10, maxWidth:320, margin:'12px auto 0' },
articleBtn: { padding:'18px 16px', borderRadius:10, border:'1.5px solid #ddd', background:'#fff', color:'#1a1a1a', cursor:'pointer', fontFamily:'inherit', textAlign:'center', transition:'all 0.12s' }, // On touch the keyboard chips are hidden; the min-heights keep the tap
// targets exactly as big as the desktop buttons with chips.
articleBtn: { padding:'18px 16px', borderRadius:10, border:'1.5px solid #ddd', background:'#fff', color:'#1a1a1a', cursor:'pointer', fontFamily:'inherit', textAlign:'center', transition:'all 0.12s',
...(IS_TOUCH ? {minHeight:80, display:'flex', flexDirection:'column', alignItems:'center', justifyContent:'center'} : {}) },
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' }, 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'} : {}) },
feedback: { textAlign:'left', background:'#fff', borderRadius:10, padding:'14px 18px', margin:'0 auto', maxWidth:400, border:'1px solid #eee' }, feedback: { textAlign:'left', background:'#fff', borderRadius:10, padding:'14px 18px', margin:'0 auto', maxWidth:400, border:'1px solid #eee' },
@@ -1420,8 +1437,10 @@ const S = {
stripH: { padding:'4px 6px', fontSize:10, fontWeight:600, textTransform:'uppercase', letterSpacing:0.3, color:'#bbb', textAlign:'center' }, stripH: { padding:'4px 6px', fontSize:10, fontWeight:600, textTransform:'uppercase', letterSpacing:0.3, color:'#bbb', textAlign:'center' },
stripD: { padding:'6px', textAlign:'center', fontSize:13 }, stripD: { padding:'6px', textAlign:'center', fontSize:13 },
dotsRow: { display:'flex', gap:5, justifyContent:'center', alignItems:'center', position:'fixed', bottom:20, left:0, right:0 }, bottomBar: { position:'fixed', bottom:14, left:0, right:0, zIndex:5 },
dotsLabel: { fontSize:11, color:'#aaa', marginLeft:10 }, bottomBarInner: { position:'relative', maxWidth:520, margin:'0 auto', padding:'0 20px' },
dotsRow: { display:'flex', gap:5, justifyContent:'center', alignItems:'center' },
dotsLabel: { fontSize:11, color:'#aaa', textAlign:'center', marginTop:6 },
dot: { width:7, height:7, borderRadius:4, transition:'background 0.2s' }, dot: { width:7, height:7, borderRadius:4, transition:'background 0.2s' },
nextBtn: { padding:'11px 22px', borderRadius:8, border:'none', background:GREEN, color:'#fff', fontSize:14, fontWeight:700, cursor:'pointer', fontFamily:'inherit' }, nextBtn: { padding:'11px 22px', borderRadius:8, border:'none', background:GREEN, color:'#fff', fontSize:14, fontWeight:700, cursor:'pointer', fontFamily:'inherit' },
+34 -9
View File
@@ -550,16 +550,40 @@ const enNoun = (r, det) => `${det}${det ? ' ' : ''}${r.noun.en}`;
// QUESTION GENERATORS // QUESTION GENERATORS
// ═══════════════════════════════════════════ // ═══════════════════════════════════════════
function articleChoices(ans, artType) { // The answer plus 3 distractors drawn from a pool, shuffled.
const pool = artType === 'indefinite' function pickChoices(ans, pool) {
? ['ein','eine','einen','einem','eines','einer','keine','keinen','keiner'] const rem = [...new Set(pool)].filter(a => a !== ans);
: ['der','die','das','den','dem','des'];
const rem = pool.filter(a => a !== ans);
const picked = []; const picked = [];
while (picked.length < 3 && rem.length) picked.push(rem.splice(Math.floor(Math.random()*rem.length), 1)[0]); while (picked.length < 3 && rem.length) picked.push(rem.splice(Math.floor(Math.random()*rem.length), 1)[0]);
return [ans, ...picked].sort(() => Math.random() - 0.5); return [ans, ...picked].sort(() => Math.random() - 0.5);
} }
function articleChoices(ans, artType) {
return pickChoices(ans, artType === 'indefinite'
? ['ein','eine','einen','einem','eines','einer','keine','keinen','keiner']
: ['der','die','das','den','dem','des']);
}
// All declined forms of one possessive stem (mein, meine, meinen, meinem…) —
// every distractor shares the stem, so the choices never leak which ending is right.
function possChoices(stem, ans) {
const pool = [];
for (const g of Object.keys(POSS_ENDINGS)) {
for (const c of Object.keys(POSS_ENDINGS[g])) pool.push(combinePoss(stem, POSS_ENDINGS[g][c]));
}
return pickChoices(ans, pool);
}
// The target pronoun's own case forms first (mich vs mir vs ich is the real
// test), topped up with other pronouns' object forms.
function pronounChoices(pron, ans) {
const own = [...new Set([pron.nom, pron.acc, pron.dat])].filter(f => f !== ans);
const others = shuffle([...new Set(PRONOUNS.filter(p => p.key !== pron.key).flatMap(p => [p.acc, p.dat]))])
.filter(f => f !== ans && !own.includes(f));
const picked = [...shuffle(own), ...others].slice(0, 3);
return [ans, ...picked].sort(() => Math.random() - 0.5);
}
export function genArticleQ(level, state = newState()) { export function genArticleQ(level, state = newState()) {
const lv = LEVELS.find(l=>l.id===level); const lv = LEVELS.find(l=>l.id===level);
const indef = lv.artType === 'indefinite'; const indef = lv.artType === 'indefinite';
@@ -657,7 +681,7 @@ export function genPossQ(level, state = newState()) {
const sentence = `${r.subjDe} ${r.verbDe}${r.midDe} ${prepKey} _____ ${nounForm(r.noun, cas)}${r.postDe}.`; const sentence = `${r.subjDe} ${r.verbDe}${r.midDe} ${prepKey} _____ ${nounForm(r.noun, cas)}${r.postDe}.`;
const translation = `${r.subjEn} ${r.verbEn}${r.midEn} ${r.enPrep} ${possMeaning} ${r.noun.en}${r.postEn}.`; const translation = `${r.subjEn} ${r.verbEn}${r.midEn} ${r.enPrep} ${possMeaning} ${r.noun.en}${r.postEn}.`;
const rule = `${stem}- + ${CL[cas]} + ${r.noun.g}${ans}`; const rule = `${stem}- + ${CL[cas]} + ${r.noun.g}${ans}`;
return {type:'poss', noun:r.noun, stem, possMeaning, cas, ans, sentence, translation, rule, prepKey, tw}; return {type:'poss', noun:r.noun, stem, possMeaning, cas, ans, sentence, translation, rule, prepKey, tw, choices:possChoices(stem, ans)};
} }
} }
@@ -677,7 +701,7 @@ export function genPossQ(level, state = newState()) {
state.last.noun = noun.w; state.last.noun = noun.w;
const ans = combinePoss(stem, POSS_ENDINGS[noun.g][cas]); const ans = combinePoss(stem, POSS_ENDINGS[noun.g][cas]);
const rule = `${stem}- + ${CL[cas]} + ${noun.g}${ans}`; const rule = `${stem}- + ${CL[cas]} + ${noun.g}${ans}`;
return {type:'poss', noun, stem, possMeaning, cas, ans, sentence, translation, rule, prepKey:null, tw:null}; return {type:'poss', noun, stem, possMeaning, cas, ans, sentence, translation, rule, prepKey:null, tw:null, choices:possChoices(stem, ans)};
} }
export function genPronounQ(level, state = newState()) { export function genPronounQ(level, state = newState()) {
@@ -703,14 +727,15 @@ export function genPronounQ(level, state = newState()) {
const enPost = tpl.enPost || ''; const enPost = tpl.enPost || '';
const sentence = `${cap(subj.nom)} ${sDe} _____${dePost}.`; const sentence = `${cap(subj.nom)} ${sDe} _____${dePost}.`;
const translation = `${cap(subj.en)} ${sEn} ${pron.enObj}${enPost}.`; const translation = `${cap(subj.en)} ${sEn} ${pron.enObj}${enPost}.`;
return {type:'pronoun', pronKey, pron, cas, ans, sentence, translation}; return {type:'pronoun', pronKey, pron, cas, ans, sentence, translation, choices:pronounChoices(pron, ans)};
} }
export function genContractionQ(state = newState()) { export function genContractionQ(state = newState()) {
const form = drawBag(state, 'contr', CONTRACTIONS.map(c=>c.form), state.last.contr); const form = drawBag(state, 'contr', CONTRACTIONS.map(c=>c.form), state.last.contr);
state.last.contr = form; state.last.contr = form;
const c = CONTRACTIONS.find(x => x.form === form); const c = CONTRACTIONS.find(x => x.form === form);
return {type:'contraction', prep:c.prep, art:c.art, ans:c.form, pref:c.pref, cas:c.c}; return {type:'contraction', prep:c.prep, art:c.art, ans:c.form, pref:c.pref, cas:c.c,
choices:pickChoices(c.form, CONTRACTIONS.map(x=>x.form))};
} }
// Build the 4 choices for a fused prep+article question: always a BLEND of // Build the 4 choices for a fused prep+article question: always a BLEND of
+17
View File
@@ -269,6 +269,23 @@ describe('pronoun questions', () => {
}); });
}); });
describe('one-tap choices on every question type', () => {
it.each([11, 12, 13, 14, 15, 16, 17, 19])('level %s: 4 unique choices containing the answer', (id) => {
for (const q of runSession(id, 200)) {
expect(q.choices.length).toBe(4);
expect(new Set(q.choices).size).toBe(4);
expect(q.choices).toContain(q.ans);
}
});
it('possessive choices all share the question stem', () => {
for (const q of runSession(13, 200)) {
const root = q.stem === 'euer' ? 'eu' : q.stem;
for (const c of q.choices) expect(c.startsWith(root), `${c} should be a form of ${q.stem}`).toBe(true);
}
});
});
// ── Nominative is never tested ────────────────────────────── // ── Nominative is never tested ──────────────────────────────
describe('no nominative anywhere', () => { describe('no nominative anywhere', () => {