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:
+67
-48
@@ -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 (1–4, ↵) — 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
@@ -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
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user