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
+34 -9
View File
@@ -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