@@ -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', () => {