From f39db3cb862ac3c49a5565887a661117d99c6bd8 Mon Sep 17 00:00:00 2001 From: George Turkington Date: Tue, 21 Jul 2026 07:27:32 +0000 Subject: [PATCH] One-tap grids everywhere, hideable translation/hints, cleaner top and bottom bars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- src/App.jsx | 135 ++++++++++++++++++++++++++------------------- src/engine.js | 43 ++++++++++++--- src/engine.test.js | 17 ++++++ 3 files changed, 128 insertions(+), 67 deletions(-) diff --git a/src/App.jsx b/src/App.jsx index 6c92730..a2526c7 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -169,6 +169,10 @@ const ROUND = 10; const GREEN = '#58a700'; 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() { const [scr, setScr] = useState('home'); const [tab, setTab] = useState('prep'); @@ -282,7 +286,7 @@ 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 === 'article' || q?.type === 'chunk') && q.choices) { + 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; } } @@ -296,7 +300,7 @@ export default function App() { if (e.key === '1') { e.preventDefault(); submitRef.current('accusative'); 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(); } }; window.addEventListener('keydown', h); @@ -353,16 +357,15 @@ export default function App() { {scr !== 'home' && scr !== 'menu' ? :
Beug
} -
+
{scr === 'practice' && ( )} - {scr === 'practice' && } {PAYMENTS_ENABLED && hasPaidAccess && Unlocked}
@@ -549,7 +552,9 @@ export default function App() { ))}
-
{q.translation}
+ {(q.type === 'article' || q.type === 'chunk') ? ( + ) : q.type === 'poss' ? ( + ) : ( -
- {q.type === 'poss' && <> - {q.stem}- - ({q.possMeaning}) - } - {q.type === 'pronoun' && <> - {q.pron.nom} - ({q.pron.en}) - · {q.pron.tag} - } -
+ )} {!rev ? ( - (q.type === 'article' || q.type === 'chunk') ? ( -
- {q.choices.map((choice, i) => ( - - ))} -
+ q.choices ? ( + ) : (
@@ -599,7 +596,7 @@ export default function App() { inputMode="text"/>
-
or press Enter
+ {!IS_TOUCH &&
or press Enter
}
) ) : quickTick ? ( @@ -629,7 +626,7 @@ export default function App() { ))} @@ -645,11 +642,11 @@ export default function App() { {scr === 'practice' && q?.type === 'wechselContext' && (
{q.sentence}
- {(!hintHidden('meaning') || rev) ? ( + {(!hintHidden('translation') || rev) ? ( <>
{q.translation} - {!rev && } + {!rev && }
{q.prepKey} @@ -659,7 +656,7 @@ export default function App() { ) : ( <>
- +
{q.prepKey} @@ -674,7 +671,7 @@ export default function App() { ))}
@@ -697,16 +694,7 @@ export default function App() { {CL[q.cas]}
{!rev ? ( -
-
- setInp(e.target.value)} - style={S.input} placeholder="contraction…" - autoComplete="off" autoCapitalize="off" spellCheck={false} - enterKeyHint="go" inputMode="text"/> - -
-
or press Enter
-
+ ) : quickTick ? ( ) : ( @@ -716,20 +704,25 @@ export default function App() { )} {scr === 'practice' && ( -
- {Array.from({length:ROUND},(_,i)=>( -
- ))} - {Math.min(res.length+(rev?0:1), ROUND)}/{ROUND} · {cur?.name} +
+
+
+ {Array.from({length:ROUND},(_,i)=>( +
+ ))} +
+
{Math.min(res.length+(rev?0:1), ROUND)}/{ROUND} · {cur?.name}
+ +
)} {scr === 'practice' && rev && !quickTick && (
)} @@ -750,7 +743,7 @@ export default function App() { {cur?.daily && nextDaily ? ( <>
@@ -950,6 +943,21 @@ function PaywallScreen({ message, onCheckout, onBack }) { // 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 ( +
+ {choices.map((choice, i) => ( + + ))} +
+ ); +} + // One-tap correct answer: no solution shown, just a tick before auto-skip. function QuickTick() { return ( @@ -1267,7 +1275,7 @@ function ChartModal({ section, q, onClose }) { )}
@@ -1364,11 +1372,16 @@ const S = { wrap: { background:'#f7f7f7', minHeight:'100vh', color:'#1a1a1a', fontFamily:'"DM Sans",system-ui,sans-serif' }, 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' }, linkBtn: { background:'none', border:'none', color:'#999', fontSize:13, cursor:'pointer', padding:0, fontWeight:500, fontFamily:'inherit' }, 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 }, 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 }, @@ -1410,9 +1423,13 @@ const S = { 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' }, - 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' }, @@ -1420,8 +1437,10 @@ const S = { stripH: { padding:'4px 6px', fontSize:10, fontWeight:600, textTransform:'uppercase', letterSpacing:0.3, color:'#bbb', textAlign:'center' }, stripD: { padding:'6px', textAlign:'center', fontSize:13 }, - dotsRow: { display:'flex', gap:5, justifyContent:'center', alignItems:'center', position:'fixed', bottom:20, left:0, right:0 }, - dotsLabel: { fontSize:11, color:'#aaa', marginLeft:10 }, + bottomBar: { position:'fixed', bottom:14, left:0, right:0, zIndex:5 }, + 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' }, nextBtn: { padding:'11px 22px', borderRadius:8, border:'none', background:GREEN, color:'#fff', fontSize:14, fontWeight:700, cursor:'pointer', fontFamily:'inherit' }, diff --git a/src/engine.js b/src/engine.js index 4783d7a..5c9dbf2 100644 --- a/src/engine.js +++ b/src/engine.js @@ -550,16 +550,40 @@ const enNoun = (r, det) => `${det}${det ? ' ' : ''}${r.noun.en}`; // QUESTION GENERATORS // ═══════════════════════════════════════════ -function articleChoices(ans, artType) { - const pool = artType === 'indefinite' - ? ['ein','eine','einen','einem','eines','einer','keine','keinen','keiner'] - : ['der','die','das','den','dem','des']; - const rem = pool.filter(a => a !== ans); +// The answer plus 3 distractors drawn from a pool, shuffled. +function pickChoices(ans, pool) { + const rem = [...new Set(pool)].filter(a => a !== ans); const picked = []; 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); } +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()) { const lv = LEVELS.find(l=>l.id===level); 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 translation = `${r.subjEn} ${r.verbEn}${r.midEn} ${r.enPrep} ${possMeaning} ${r.noun.en}${r.postEn}.`; 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; const ans = combinePoss(stem, POSS_ENDINGS[noun.g][cas]); 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()) { @@ -703,14 +727,15 @@ export function genPronounQ(level, state = newState()) { const enPost = tpl.enPost || ''; const sentence = `${cap(subj.nom)} ${sDe} _____${dePost}.`; 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()) { const form = drawBag(state, 'contr', CONTRACTIONS.map(c=>c.form), state.last.contr); state.last.contr = 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 diff --git a/src/engine.test.js b/src/engine.test.js index b49d76d..80b4272 100644 --- a/src/engine.test.js +++ b/src/engine.test.js @@ -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 ────────────────────────────── describe('no nominative anywhere', () => {