Feedback: show the full declension table, not one row

Every answer card now renders the complete 4×4 declension grid instead
of a single case row. The correct cell is highlighted green; on a wrong
answer, the cell matching what you typed is highlighted red, so you see
both your pick and the right one in context.

- FillFeedback (articles + possessives): full table, correct + wrong
  highlighting within the noun's gender column.
- ChunkFeedback + ContractionFeedback: contraction questions now show
  the definite-article table too, not just the fused answer.
- PronounFeedback: correct cell green, your wrong pick red.
This commit is contained in:
2026-07-17 07:42:56 +00:00
parent aa781b72bb
commit f567555876
+76 -35
View File
@@ -894,18 +894,51 @@ function PaywallScreen({ message, onCheckout, onBack }) {
// FEEDBACK COMPONENTS // FEEDBACK COMPONENTS
// ═══════════════════════════════════════════ // ═══════════════════════════════════════════
function FillFeedback({ q, inp, ok }) { const FB_GENDERS = ['masculine','feminine','neuter','plural'];
const genders = ['masculine','feminine','neuter','plural']; const FB_CASES = ['nominative','accusative','dative','genitive'];
const stripCells = genders.map(g => { const normAns = s => (s || '').trim().toLowerCase();
if (q.type === 'article') {
const tbl = q.artType === 'indefinite' ? INDEF : DEF; // A full declension grid (all 4 cases × 4 genders) for answer feedback.
return tbl[g][q.cas]; // The correct cell is highlighted green; when the answer was wrong, the
// cell(s) matching the user's input are highlighted red — so you see both
// your pick and the right one in the whole table, not just one row.
// focusG (optional) keeps one gender column sharp and greys the rest.
function FeedbackTable({ valueFor, isCorrect, isWrong, focusG }) {
return (
<table style={S.stripTbl}>
<thead><tr>
<th style={{...S.stripH, textAlign:'left'}}></th>
{FB_GENDERS.map(g => (
<th key={g} style={{...S.stripH, color: GENDER_C[g], opacity: (!focusG || g === focusG) ? 1 : 0.5}}>{g.slice(0,4)}.</th>
))}
</tr></thead>
<tbody>
{FB_CASES.map(c => (
<tr key={c}>
<td style={{...S.stripD, textAlign:'left', fontSize:11, fontWeight:600, color: CASE_C[c]}}>{CL[c]}</td>
{FB_GENDERS.map(g => {
const val = valueFor(g, c);
const correct = isCorrect(g, c, val);
const wrong = !correct && !!isWrong && isWrong(g, c, val);
const bg = correct ? GREEN + '22' : wrong ? RED + '22' : 'transparent';
const color = correct ? GREEN : wrong ? RED : (focusG && g !== focusG ? '#c8c8c8' : '#555');
return (
<td key={g} style={{...S.stripD, background:bg, borderRadius:4,
fontWeight:(correct||wrong)?700:400, color, fontSize:(correct||wrong)?14:13}}>{val}</td>
);
})}
</tr>
))}
</tbody>
</table>
);
} }
return combinePoss(q.stem, POSS_ENDINGS[g][q.cas]);
}); function FillFeedback({ q, inp, ok }) {
const stripLabel = q.type === 'article' const valueFor = q.type === 'article'
? (q.artType === 'indefinite' ? 'ein…' : 'der/die') ? (g, c) => (q.artType === 'indefinite' ? INDEF : DEF)[g][c]
: `${q.stem}-`; : (g, c) => combinePoss(q.stem, POSS_ENDINGS[g][c]);
const wrong = normAns(inp);
return ( return (
<div style={{...S.feedback, animation:'fadeUp 0.2s ease-out'}}> <div style={{...S.feedback, animation:'fadeUp 0.2s ease-out'}}>
@@ -918,24 +951,12 @@ function FillFeedback({ q, inp, ok }) {
{q.noun.w} is <span style={{color:GENDER_C[q.noun.g]}}>{q.noun.g}</span>. <span style={{color:CASE_C[q.cas]}}>{CL[q.cas]}</span> + {q.noun.g} = <strong style={{color:'#1a1a1a'}}>{q.ans}</strong> {q.noun.w} is <span style={{color:GENDER_C[q.noun.g]}}>{q.noun.g}</span>. <span style={{color:CASE_C[q.cas]}}>{CL[q.cas]}</span> + {q.noun.g} = <strong style={{color:'#1a1a1a'}}>{q.ans}</strong>
</div> </div>
)} )}
<table style={S.stripTbl}> <FeedbackTable
<thead><tr> valueFor={valueFor}
<th style={{...S.stripH, textAlign:'left', fontWeight:600, color: CASE_C[q.cas]}}>{CL[q.cas]}</th> focusG={q.noun.g}
{genders.map(g => <th key={g} style={{...S.stripH, color:GENDER_C[g]}}>{g.slice(0,4)}.</th>)} isCorrect={(g, c) => g === q.noun.g && c === q.cas}
</tr></thead> isWrong={ok ? null : (g, c, val) => g === q.noun.g && normAns(val) === wrong}
<tbody><tr> />
<td style={{...S.stripD, textAlign:'left', color:'#aaa', fontSize:11}}>{stripLabel}</td>
{genders.map((g, i) => {
const hl = g === q.noun.g;
return <td key={g} style={{
...S.stripD, fontWeight: hl ? 700 : 400,
color: hl ? '#1a1a1a' : '#bbb',
background: hl ? '#f3f3f3' : 'transparent',
borderRadius: 4, fontSize: hl ? 15 : 13,
}}>{stripCells[i]}</td>;
})}
</tr></tbody>
</table>
</div> </div>
); );
} }
@@ -1002,6 +1023,15 @@ function ChunkFeedback({ q, inp, ok }) {
? 'Preferred — these fuse, so use the contraction in speech and writing.' ? 'Preferred — these fuse, so use the contraction in speech and writing.'
: <><strong>{article}</strong> doesnt fuse with „{q.prepKey}" keep them as two words.</>} : <><strong>{article}</strong> doesnt fuse with „{q.prepKey}" keep them as two words.</>}
</div> </div>
<div style={{marginTop:10, fontSize:11, color:'#aaa', marginBottom:2}}>
Definite article by case <span style={{color:GENDER_C[q.noun.g]}}>{DEF[q.noun.g].nominative} {q.noun.w}</span>
</div>
<FeedbackTable
valueFor={(g, c) => DEF[g][c]}
focusG={q.noun.g}
isCorrect={(g, c) => g === q.noun.g && c === q.cas}
isWrong={null}
/>
</div> </div>
); );
} }
@@ -1022,6 +1052,15 @@ function ContractionFeedback({ q, inp, ok }) {
? 'Preferred — use this contraction in both speech and writing.' ? 'Preferred — use this contraction in both speech and writing.'
: 'Informal — common when speaking, but the two-word form is more usual in writing.'} : 'Informal — common when speaking, but the two-word form is more usual in writing.'}
</div> </div>
<div style={{marginTop:10, fontSize:11, color:'#aaa', marginBottom:2}}>
Where {q.art}" sits in the definite-article table
</div>
<FeedbackTable
valueFor={(g, c) => DEF[g][c]}
focusG={null}
isCorrect={(g, c, val) => normAns(val) === normAns(q.art)}
isWrong={null}
/>
</div> </div>
); );
} }
@@ -1046,13 +1085,15 @@ function PronounFeedback({ q, inp, ok }) {
<tbody><tr> <tbody><tr>
<td style={{...S.stripD, textAlign:'left', color:'#aaa', fontSize:11}}>{q.pron.en}</td> <td style={{...S.stripD, textAlign:'left', color:'#aaa', fontSize:11}}>{q.pron.en}</td>
{cases.map(c => { {cases.map(c => {
const hl = c === q.cas; const val = q.pron[keys[c]];
const correct = c === q.cas;
const wrong = !ok && !correct && normAns(val) === normAns(inp);
return <td key={c} style={{ return <td key={c} style={{
...S.stripD, fontWeight: hl ? 700 : 400, ...S.stripD, fontWeight: (correct || wrong) ? 700 : 400,
color: hl ? '#1a1a1a' : '#bbb', color: correct ? GREEN : wrong ? RED : '#bbb',
background: hl ? '#f3f3f3' : 'transparent', background: correct ? GREEN + '22' : wrong ? RED + '22' : 'transparent',
borderRadius: 4, fontSize: hl ? 15 : 13, borderRadius: 4, fontSize: (correct || wrong) ? 15 : 13,
}}>{q.pron[keys[c]]}</td>; }}>{val}</td>;
})} })}
</tr></tbody> </tr></tbody>
</table> </table>