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
// ═══════════════════════════════════════════
const FB_GENDERS = ['masculine','feminine','neuter','plural'];
const FB_CASES = ['nominative','accusative','dative','genitive'];
const normAns = s => (s || '').trim().toLowerCase();
// A full declension grid (all 4 cases × 4 genders) for answer feedback.
// 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>
);
}
function FillFeedback({ q, inp, ok }) {
const genders = ['masculine','feminine','neuter','plural'];
const stripCells = genders.map(g => {
if (q.type === 'article') {
const tbl = q.artType === 'indefinite' ? INDEF : DEF;
return tbl[g][q.cas];
}
return combinePoss(q.stem, POSS_ENDINGS[g][q.cas]);
});
const stripLabel = q.type === 'article'
? (q.artType === 'indefinite' ? 'ein…' : 'der/die')
: `${q.stem}-`;
const valueFor = q.type === 'article'
? (g, c) => (q.artType === 'indefinite' ? INDEF : DEF)[g][c]
: (g, c) => combinePoss(q.stem, POSS_ENDINGS[g][c]);
const wrong = normAns(inp);
return (
<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>
</div>
)}
<table style={S.stripTbl}>
<thead><tr>
<th style={{...S.stripH, textAlign:'left', fontWeight:600, color: CASE_C[q.cas]}}>{CL[q.cas]}</th>
{genders.map(g => <th key={g} style={{...S.stripH, color:GENDER_C[g]}}>{g.slice(0,4)}.</th>)}
</tr></thead>
<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>
<FeedbackTable
valueFor={valueFor}
focusG={q.noun.g}
isCorrect={(g, c) => g === q.noun.g && c === q.cas}
isWrong={ok ? null : (g, c, val) => g === q.noun.g && normAns(val) === wrong}
/>
</div>
);
}
@@ -1002,6 +1023,15 @@ function ChunkFeedback({ q, inp, ok }) {
? 'Preferred — these fuse, so use the contraction in speech and writing.'
: <><strong>{article}</strong> doesnt fuse with „{q.prepKey}" keep them as two words.</>}
</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>
);
}
@@ -1022,6 +1052,15 @@ function ContractionFeedback({ q, inp, ok }) {
? 'Preferred — use this contraction in both speech and writing.'
: 'Informal — common when speaking, but the two-word form is more usual in writing.'}
</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>
);
}
@@ -1046,13 +1085,15 @@ function PronounFeedback({ q, inp, ok }) {
<tbody><tr>
<td style={{...S.stripD, textAlign:'left', color:'#aaa', fontSize:11}}>{q.pron.en}</td>
{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={{
...S.stripD, fontWeight: hl ? 700 : 400,
color: hl ? '#1a1a1a' : '#bbb',
background: hl ? '#f3f3f3' : 'transparent',
borderRadius: 4, fontSize: hl ? 15 : 13,
}}>{q.pron[keys[c]]}</td>;
...S.stripD, fontWeight: (correct || wrong) ? 700 : 400,
color: correct ? GREEN : wrong ? RED : '#bbb',
background: correct ? GREEN + '22' : wrong ? RED + '22' : 'transparent',
borderRadius: 4, fontSize: (correct || wrong) ? 15 : 13,
}}>{val}</td>;
})}
</tr></tbody>
</table>