Files
Beug/src/App.jsx
T
georgeandClaude Opus 5 047454f23f Wechsel im Kontext: blank the article, make the verb decide, add the rule
The level showed the article already in the sentence ("unter dem Stuhl"),
so it was answerable by recognising "dem" — it never tested wohin/wo at
all. And the frame pool was tiny: one cat, one lamp, eight nouns.

- The article is now blanked. The only evidence is the verb and whether it
  has a direct object, which is the actual skill. The solved sentence is
  revealed on answer, contracted where German really contracts (ins Regal,
  am Bahnhof), with a note naming the fusion.
- Frames rebuilt around what decides the case: 27 new nouns, 18 new verbs,
  and every frame one-way so no verb+noun pairing licenses both readings.
- Motion verbs now appear on the DATIVE side on purpose — schwimmen im See,
  tanzen in der Kueche, spielen auf der Strasse — because "is it moving?" is
  the wrong test and that is the main thing learners get wrong.
- The put/be pairs are drilled properly: legen/liegen, stellen/stehen,
  setzen/sitzen, plus haengen and stecken, which keep the same present tense
  either way so only the direct object tells you.
- ankommen added as the lexical exception: Dativ despite obvious arrival.
- New "Wohin oder wo?" info panel: the rule, the before-and-after test, the
  movement trap, the verb-pair table and the exception. Reachable from the
  practice screen, from the answer feedback, and from Charts.
- Feedback now explains the specific sentence rather than restating theory.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Uza6mhTpAvSwMQuFc4Cz8
2026-09-17 06:55:26 +00:00

1610 lines
78 KiB
React
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useCallback, useRef } from "react";
import {
DEF, INDEF, STRONG, WEAK, PREPS, PREPS_BY_CASE, CL, CASE_C, GENDER_C,
PRONOUNS, POSSESSIVES, POSS_ENDINGS, combinePoss, LEVELS,
wechselExample, createSession, bumpStreak, currentStreak, weekDots,
} from './engine.js';
// ═══════════════════════════════════════════
// STORAGE (window.storage in Claude, localStorage when deployed)
// ═══════════════════════════════════════════
const SKEY = 'gd-v10';
const ACCESS_KEY = 'beug-access-v1';
const Storage = {
async get() {
try {
if (typeof window !== 'undefined' && window.storage?.get) {
const r = await window.storage.get(SKEY);
if (r?.value) return JSON.parse(r.value);
}
} catch (e) {}
try {
const v = localStorage.getItem(SKEY);
if (v) return JSON.parse(v);
} catch (e) {}
return null;
},
async set(state) {
const json = JSON.stringify(state);
try {
if (typeof window !== 'undefined' && window.storage?.set) {
await window.storage.set(SKEY, json);
return;
}
} catch (e) {}
try { localStorage.setItem(SKEY, json); } catch (e) {}
}
};
const Access = {
get() {
try {
const v = localStorage.getItem(ACCESS_KEY);
return v ? JSON.parse(v) : null;
} catch (e) {
return null;
}
},
set(plan = 'paid') {
const value = { paid: true, plan, unlockedAt: new Date().toISOString() };
try { localStorage.setItem(ACCESS_KEY, JSON.stringify(value)); } catch (e) {}
return value;
}
};
const TABS = [
{id:'prep', label:'Prepositions'},
{id:'article', label:'Articles'},
{id:'poss', label:'Possessives'},
{id:'pronoun', label:'Pronouns'},
];
// Daily Practice pseudo-levels are always free: unlocking one requires
// passing every lesson in its tab, so gated tabs stay gated anyway.
const DAILY_IDS = LEVELS.filter(l => l.daily).map(l => l.id);
const FREE_LEVEL_IDS = new Set([2, 3, 4, 8, 9, 10, 18, ...DAILY_IDS]);
const CORE_ARTICLE_CASES = ['nominative', 'accusative', 'dative'];
const PAYMENTS_ENABLED = import.meta.env.VITE_PAYMENTS_ENABLED === 'true';
const STRIPE_PLANS = PAYMENTS_ENABLED ? [
{
id:'weekly',
name:'Weekly',
price:'$2.99',
cadence:'per week',
note:'Start here',
url: import.meta.env.VITE_STRIPE_WEEKLY_URL || '',
env:'VITE_STRIPE_WEEKLY_URL',
},
{
id:'yearly',
name:'Yearly',
price:'$9.99',
cadence:'per year',
note:'Best value',
url: import.meta.env.VITE_STRIPE_YEARLY_URL || '',
env:'VITE_STRIPE_YEARLY_URL',
},
{
id:'lifetime',
name:'Lifetime',
price:'$19.99',
cadence:'one time',
note:'Pay once',
url: import.meta.env.VITE_STRIPE_LIFETIME_URL || '',
env:'VITE_STRIPE_LIFETIME_URL',
},
] : [];
// Audio served from /public/audio
const AUDIO = {
dativSung: '/audio/dativ-blue-danube-sung.m4a',
blueDanube: '/audio/blue-danube.mp3',
odeToJoy: '/audio/ode-to-joy.mp3',
};
// Chunked study cards for the Prepositions "Lernen" section.
// Mnemonics reproduced from the U-Michigan German Resources page.
const LEARN_CARDS = [
{
case:'accusative', label:'Akkusativ', desc:'These prepositions always take the Accusative.',
words:[
{de:'durch', en:'through'}, {de:'für', en:'for'}, {de:'gegen', en:'against'},
{de:'ohne', en:'without'}, {de:'um', en:'around / at'}, {de:'bis', en:'until / up to'},
],
mnemonics:[
{label:'Acronym', text:'“O Fudge” — ohne, für, um, durch, gegen.'},
{label:'Rhyme', text:'“Durch-für-gegen-ohne-um, Deutsch zu lernen ist nicht dumm.”'},
],
audio:[],
},
{
case:'dative', label:'Dativ', desc:'These prepositions always take the Dative.',
words:[
{de:'aus', en:'out of'}, {de:'außer', en:'except for'}, {de:'bei', en:'at / near'},
{de:'mit', en:'with'}, {de:'nach', en:'to / after'}, {de:'seit', en:'since'},
{de:'von', en:'from'}, {de:'zu', en:'to'}, {de:'gegenüber', en:'opposite'},
],
mnemonics:[
{label:'Sing it', text:'Sing “Aus-außer-bei-mit, nach-seit, von-zu” to the tune of the “Blue Danube” waltz.'},
{label:'Rhyme', text:'“Roses are red, violets are blue, aus-außer-bei-mit, nach-seit, von-zu.”'},
],
audio:[
{label:'🎤 Sung to the Blue Danube', src:AUDIO.dativSung},
{label:'🎻 The melody — The Blue Danube', src:AUDIO.blueDanube},
],
},
{
case:'wechsel', label:'Wechselpräpositionen', desc:'Two-way: motion → Akkusativ (wohin?), staying put → Dativ (wo?).',
words:[
{de:'an', en:'at / on'}, {de:'auf', en:'on / onto'}, {de:'hinter', en:'behind'},
{de:'neben', en:'next to'}, {de:'unter', en:'under'}, {de:'über', en:'over / above'},
{de:'in', en:'in / into'}, {de:'vor', en:'in front of'}, {de:'zwischen', en:'between'},
],
mnemonics:[
{label:'Sing it', text:'Sing “An, auf, hin-ter, ne-ben, un-ter/ü-ber, in, vor, zwi-i-schen” to the tune of “An die Freude” (“Ode to Joy”) from Beethoven’s 9th.'},
],
audio:[
{label:'🎼 The melody — Ode to Joy', src:AUDIO.odeToJoy},
],
},
{
case:'genitive', label:'Genitiv', desc:'These take the Genitive — only a handful, so just learn the list.',
words:[
{de:'trotz', en:'despite'}, {de:'während', en:'during'},
{de:'wegen', en:'because of'}, {de:'(an)statt', en:'instead of'},
],
mnemonics:[],
note:'No song for these — there are only a few. Formal writing uses the genitive; casual speech increasingly uses the dative (e.g. „wegen dem Wetter"). Michigan also lists außerhalb / innerhalb / diesseits / jenseits, which must stay genitive.',
audio:[],
},
];
// ═══════════════════════════════════════════
// MAIN APP
// ═══════════════════════════════════════════
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');
const [lv, setLv] = useState(1);
const [q, setQ] = useState(null);
const [inp, setInp] = useState('');
const [rev, setRev] = useState(false);
const [ok, setOk] = useState(false);
const [res, setRes] = useState([]);
const [chart, setChart] = useState(false);
const [rule, setRule] = useState(false);
const [learnStep, setLearnStep] = useState(0);
const [st, setSt] = useState({sc:{},tc:0,ta:0,streak:0,ld:null});
const [access, setAccess] = useState(null);
const [payMsg, setPayMsg] = useState('');
const [loaded, setLoaded] = useState(false);
const [shake, setShake] = useState(false);
const iref = useRef(null);
const sessionRef = useRef(null);
useEffect(()=>{(async()=>{
const data = await Storage.get();
// Seed the practice-day history for users from before it was tracked.
if (data?.ld && !data.days) data.days = { [data.ld]: true };
if (data) setSt(data);
let nextAccess = Access.get();
const params = new URLSearchParams(window.location.search);
if (params.get('checkout') === 'success' || params.get('beug_unlock') === '1') {
nextAccess = Access.set(params.get('plan') || 'paid');
window.history.replaceState({}, '', window.location.pathname);
setScr('menu');
}
if (nextAccess?.paid) setAccess(nextAccess);
setLoaded(true);
})();},[]);
const save = useCallback(async s => {
setSt(s);
await Storage.set(s);
},[]);
const hintHidden = k => !!st.hide?.[k];
const toggleHint = k => save({...st, hide:{...(st.hide||{}), [k]: !st.hide?.[k]}});
const hasPaidAccess = !!access?.paid;
const hasAccess = !PAYMENTS_ENABLED || hasPaidAccess;
const gated = id => PAYMENTS_ENABLED && !hasPaidAccess && !FREE_LEVEL_IDS.has(id);
const unlocked = (id) => {
return !gated(id);
};
const nextLevelInTab = (id) => {
const lvObj = LEVELS.find(l => l.id === id);
if (!lvObj) return null;
const tabLevels = LEVELS.filter(l => l.tab === lvObj.tab && !l.daily).sort((a,b) => a.id - b.id);
const idx = tabLevels.findIndex(l => l.id === id);
if (idx === -1 || idx === tabLevels.length - 1) return null;
return tabLevels[idx + 1];
};
const startLv = id => {
if (!unlocked(id)) { setPayMsg(''); setScr('paywall'); return; }
const lvObj = LEVELS.find(l => l.id === id);
if (lvObj?.tab) setTab(lvObj.tab);
sessionRef.current = createSession(id);
setLv(id); setRes([]); setInp(''); setRev(false); setQ(sessionRef.current.next()); setScr('practice');
};
const openCheckout = plan => {
if (!plan.url) {
setPayMsg(`Stripe link missing: add ${plan.env} in the deployment environment.`);
return;
}
window.location.href = plan.url;
};
const submitRef = useRef(null);
const nextRef = useRef(null);
const summaryRef = useRef(null);
const doSubmit = useCallback((overrideInput) => {
const userAns = (overrideInput ?? inp).trim();
if (!userAns) { setShake(true); setTimeout(()=>setShake(false),400); return; }
const correct = userAns.toLowerCase() === q.ans.toLowerCase();
if (overrideInput !== undefined) setInp(overrideInput);
// Dismiss mobile keyboard so feedback is visible
try { iref.current?.blur(); } catch(e){}
setOk(correct); setRev(true);
const nr = [...res, correct]; setRes(nr);
let ns = {...st};
ns.ta = (ns.ta||0) + 1;
if (correct) ns.tc = (ns.tc||0) + 1;
if (!ns.sc[lv]) ns.sc[lv] = {c:0,t:0};
ns.sc[lv] = {c: ns.sc[lv].c + (correct?1:0), t: ns.sc[lv].t + 1};
ns = bumpStreak(ns);
save(ns);
}, [inp, q, res, st, lv, save]);
const doNext = useCallback(() => {
if (res.length >= ROUND) { setScr('summary'); return; }
setInp(''); setRev(false); setOk(false);
if (!sessionRef.current) sessionRef.current = createSession(lv);
setQ(sessionRef.current.next());
}, [res, lv]);
submitRef.current = doSubmit;
nextRef.current = doNext;
useEffect(() => {
const h = e => {
if (rule) { if (e.key === 'Escape') setRule(false); return; }
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?.choices) {
const idx = ['1','2','3','4'].indexOf(e.key);
if (idx >= 0 && q.choices[idx]) { e.preventDefault(); submitRef.current(q.choices[idx]); return; }
}
if (!rev && q?.type === 'prep') {
if (e.key === '1') { e.preventDefault(); submitRef.current('accusative'); return; }
if (e.key === '2') { e.preventDefault(); submitRef.current('dative'); return; }
if (e.key === '3') { e.preventDefault(); submitRef.current('genitive'); return; }
if (e.key === '4') { e.preventDefault(); submitRef.current('wechsel'); return; }
}
if (!rev && q?.type === 'wechselContext') {
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?.choices && q?.type !== 'prep' && q?.type !== 'wechselContext') submitRef.current(); }
if (rev && (e.key === 'ArrowRight' || e.key === ' ')) { e.preventDefault(); nextRef.current(); }
};
window.addEventListener('keydown', h);
return () => window.removeEventListener('keydown', h);
});
// One-tap mode: always on in Daily Practice, opt-in (st.auto) elsewhere.
// A correct answer never shows the solution — just a green tick, then it
// skips straight to the next question. A wrong answer shows the correction
// and waits for an explicit Next, so it can actually register.
useEffect(() => {
if (scr !== 'practice' || !rev || !ok) return;
const quick = !!LEVELS.find(l => l.id === lv)?.daily || !!st.auto;
if (!quick) return;
const t = setTimeout(() => { nextRef.current?.(); }, 550);
return () => clearTimeout(t);
}, [scr, rev, ok, lv, st.auto]);
if (!loaded) return <div style={S.wrap}><p style={{color:'#999',textAlign:'center',paddingTop:120}}>Loading...</p></div>;
const rc = res.filter(Boolean).length;
const cur = LEVELS.find(l=>l.id===lv);
const chartSection = scr === 'menu' ? tab : (q?.type === 'wechselContext' ? 'prep' : q?.type === 'chunk' ? 'article' : (q?.type || tab));
const nextInTab = nextLevelInTab(lv);
const quickActive = !!cur?.daily || !!st.auto;
const quickTick = quickActive && rev && ok;
// Each tab's Daily Practice unlocks once every lesson in the tab is passed:
// at least one full round each, at the app's 70% pass rate.
const passedLv = id => { const s = st.sc?.[id]; return !!(s && s.t >= ROUND && s.c / s.t >= 0.7); };
const tabLessons = t => LEVELS.filter(l => l.tab === t && !l.daily);
const dailyFor = t => LEVELS.find(l => l.tab === t && l.daily);
const dailyUnlockedFor = t => tabLessons(t).every(l => passedLv(l.id));
// The next tab (cycling through the top tabs) with an unlocked Daily Practice.
const nextDailyAfter = t => {
const i = TABS.findIndex(x => x.id === t);
for (let k = 1; k < TABS.length; k++) {
const cand = TABS[(i + k) % TABS.length];
if (dailyUnlockedFor(cand.id)) return { tab: cand, level: dailyFor(cand.id) };
}
return null;
};
const nextDaily = cur?.daily ? nextDailyAfter(cur.tab) : null;
summaryRef.current = () => {
if (cur?.daily && nextDaily) startLv(nextDaily.level.id);
else startLv(lv);
};
return (
<div style={S.wrap}>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&display=swap" rel="stylesheet"/>
<div style={S.inner}>
<div style={S.hdr}>
{scr !== 'home' && scr !== 'menu'
? <button style={S.linkBtn} onClick={()=>setScr('menu')}>← back</button>
: <div style={S.logo}>Beug</div>}
<div style={{display:'flex',gap:12,alignItems:'center'}}>
{scr === 'practice' && (
<button
style={{...S.pillBtn, ...(quickActive ? S.pillBtnOn : S.pillBtnOff)}}
title={cur?.daily ? 'One-tap is always on in Daily Practice' : 'Answer with one tap, then auto-advance'}
onClick={()=>{ if (cur?.daily) return; save({...st, auto: !st.auto}); }}>
⚡ One-tap
</button>
)}
{PAYMENTS_ENABLED && hasPaidAccess && <span style={S.stat}>Unlocked</span>}
<StreakBadge st={st}/>
</div>
</div>
{/* HOME */}
{scr === 'home' && (
<HomeScreen
hasAccess={hasAccess}
onStart={()=>setScr('menu')}
/>
)}
{/* MENU */}
{scr === 'menu' && (
<div>
<div style={S.tabs}>
{TABS.map(t => (
<button key={t.id}
style={{...S.tab, ...(tab===t.id ? S.tabActive : {})}}
onClick={()=>setTab(t.id)}>
{t.label}
</button>
))}
</div>
<div style={{marginTop:10}}>
{(() => {
const daily = dailyFor(tab);
if (!daily) return null;
const lessons = tabLessons(tab);
const done = lessons.filter(l => passedLv(l.id)).length;
if (!dailyUnlockedFor(tab)) return (
<div style={{...S.lvRow, opacity:0.7}}>
<div style={{flex:1, minWidth:0}}>
<div style={{...S.lvName, color:'#999'}}>🔒 Daily Practice</div>
<div style={S.lvDesc}>Pass every lesson in this section to unlock · {done}/{lessons.length} done</div>
</div>
</div>
);
const sc = st.sc?.[daily.id];
const pct = sc ? Math.round(sc.c / sc.t * 100) : null;
return (
<div style={{...S.lvRow, cursor:'pointer', border:`1.5px solid ${GREEN}`, background:'#f3ffe8'}}
onClick={()=>startLv(daily.id)}>
<div style={{flex:1, minWidth:0}}>
<div style={S.lvName}>⚡ Daily Practice</div>
<div style={S.lvDesc}>{daily.desc} · keep it sharp</div>
{sc && <div style={{fontSize:11,color:'#bbb',marginTop:3}}>{sc.c}/{sc.t}</div>}
</div>
{pct !== null && <div style={{...S.lvPct, color:pct>=70?GREEN:RED}}>{pct}%</div>}
</div>
);
})()}
{tab === 'prep' && (
<div style={{...S.lvRow, cursor:'pointer'}}
onClick={()=>{ setLearnStep(0); setScr('learn'); }}>
<div style={{flex:1, minWidth:0}}>
<div style={S.lvName}>📚 Lernen</div>
<div style={S.lvDesc}>Chunk the lists first — Akkusativ, Dativ, Wechsel, Genitiv</div>
</div>
</div>
)}
{LEVELS.filter(l => l.tab === tab && !l.daily).sort((a,b)=>a.id-b.id).map(l => {
const u = unlocked(l.id);
const s = st.sc[l.id];
const p = s ? Math.round(s.c/s.t*100) : null;
return (
<div key={l.id}
style={{...S.lvRow, ...(u ? {} : S.lvRowBlurred), cursor:'pointer'}}
onClick={()=>startLv(l.id)}>
<div style={{flex:1, minWidth:0}}>
<div style={S.lvName}>{l.name}</div>
<div style={S.lvDesc}>{l.desc}</div>
{s && <div style={{fontSize:11,color:'#bbb',marginTop:3}}>{s.c}/{s.t}</div>}
</div>
{p !== null && <div style={{...S.lvPct, color:p>=70?GREEN:RED}}>{p}%</div>}
</div>
);
})}
</div>
<button style={S.chartLinkBtn} onClick={()=>setChart(true)}>
View {TABS.find(t=>t.id===tab).label.toLowerCase()} charts
</button>
</div>
)}
{/* PAYWALL */}
{PAYMENTS_ENABLED && scr === 'paywall' && (
<PaywallScreen
message={payMsg}
onCheckout={openCheckout}
onBack={()=>setScr('menu')}
/>
)}
{/* LEARN: chunked study cards for prepositions */}
{scr === 'learn' && (() => {
const card = LEARN_CARDS[learnStep];
const isLast = learnStep === LEARN_CARDS.length - 1;
return (
<div style={{paddingTop:8}}>
<div style={{display:'flex', gap:6, justifyContent:'center', marginBottom:18}}>
{LEARN_CARDS.map((c,i)=>(
<div key={c.case} style={{height:4, flex:1, maxWidth:64, borderRadius:2,
background: i<=learnStep ? CASE_C[c.case] : '#e5e5e5'}}/>
))}
</div>
<div style={{background:'#fff', border:'1px solid #eee', borderRadius:12, padding:'20px 18px'}}>
<div style={{fontSize:12, fontWeight:700, textTransform:'uppercase', letterSpacing:1, color:CASE_C[card.case]}}>{card.label}</div>
<div style={{fontSize:13, color:'#999', marginTop:4, marginBottom:16, lineHeight:1.5}}>{card.desc}</div>
<div style={{display:'flex', flexWrap:'wrap', gap:8, marginBottom:16}}>
{card.words.map(w=>(
<div key={w.de} style={{background:'#f7f7f7', borderRadius:8, padding:'8px 12px'}}>
<span style={{fontSize:16, fontWeight:700, color:'#1a1a1a'}}>{w.de}</span>
<span style={{fontSize:12, color:'#aaa', marginLeft:6}}>{w.en}</span>
</div>
))}
</div>
{card.mnemonics.length > 0 && (
<div style={{borderTop:'1px solid #f0f0f0', paddingTop:14}}>
<div style={{fontSize:11, fontWeight:700, textTransform:'uppercase', letterSpacing:0.5, color:'#bbb', marginBottom:8}}>Merkhilfe</div>
{card.mnemonics.map((m,i)=>(
<div key={i} style={{fontSize:13, color:'#444', lineHeight:1.55, marginBottom:8}}>
<span style={{fontSize:10, fontWeight:700, color:CASE_C[card.case], textTransform:'uppercase', letterSpacing:0.5, marginRight:6}}>{m.label}</span>
{m.text}
</div>
))}
</div>
)}
{card.note && (
<div style={{borderTop:'1px solid #f0f0f0', paddingTop:14, fontSize:12, color:'#888', lineHeight:1.55}}>
{card.note}
</div>
)}
{card.audio.length > 0 && (
<div style={{marginTop:8, display:'flex', flexDirection:'column', gap:10}}>
{card.audio.map(a=>(
<div key={a.src}>
<div style={{fontSize:11, color:'#999', marginBottom:4}}>{a.label}</div>
<audio controls preload="none" src={a.src} style={{width:'100%', height:36}}/>
</div>
))}
</div>
)}
</div>
<div style={{display:'flex', gap:10, justifyContent:'center', marginTop:18}}>
{learnStep > 0 && (
<button style={{...S.nextBtn, background:'#fff', color:'#666', border:'1px solid #ddd'}}
onClick={()=>setLearnStep(s=>s-1)}>← Zurück</button>
)}
{!isLast
? <button style={S.nextBtn} onClick={()=>setLearnStep(s=>s+1)}>Weiter →</button>
: <button style={S.nextBtn} onClick={()=>setScr('menu')}>Fertig ✓</button>}
</div>
</div>
);
})()}
{/* PRACTICE: sentence-based (article, poss, pronoun) */}
{scr === 'practice' && (q?.type === 'article' || q?.type === 'chunk' || q?.type === 'poss' || q?.type === 'pronoun') && (
<div style={{textAlign:'center', paddingTop:10}}>
<div style={{...S.sentence, animation:shake?'shake 0.3s':'none'}}>
{q.sentence.split('_____').map((part,i,a)=>(
<span key={i}>
{part}
{i < a.length-1 && (
<span style={{...S.blank,
borderBottomColor: rev?(ok?GREEN:RED):'#bbb',
color: rev?(ok?GREEN:RED):'#1a1a1a',
minWidth: q.type==='poss' ? 110 : 80,
}}>
{rev ? q.ans : (inp || '     ')}
</span>
)}
</span>
))}
</div>
<Hideable hidden={hintHidden('translation') && !rev} onToggle={()=>toggleHint('translation')} label="translation" style={S.translation}>
{q.translation}
</Hideable>
{(q.type === 'article' || q.type === 'chunk') ? (
<Hideable hidden={hintHidden('gender')} onToggle={()=>toggleHint('gender')} label="gender" style={S.keyInfo}>
<span style={{color: GENDER_C[q.noun.g], fontWeight:700}}>{DEF[q.noun.g].nominative}</span>
{' '}{q.noun.w}
<span style={S.keyInfoMute}> ({q.noun.en})</span>
</Hideable>
) : q.type === 'poss' ? (
<Hideable hidden={hintHidden('possHint')} onToggle={()=>toggleHint('possHint')} label="possessive" style={S.keyInfo}>
<span style={{fontWeight:700}}>{q.stem}-</span>
<span style={S.keyInfoMute}> ({q.possMeaning})</span>
</Hideable>
) : (
<Hideable hidden={hintHidden('pronounHint')} onToggle={()=>toggleHint('pronounHint')} label="pronoun" style={S.keyInfo}>
<span style={{fontWeight:700}}>{q.pron.nom}</span>
<span style={S.keyInfoMute}> ({q.pron.en})</span>
<span style={S.keyInfoMute}> · {q.pron.tag}</span>
</Hideable>
)}
<Hideable hidden={hintHidden('caseInfo')} onToggle={()=>toggleHint('caseInfo')} label="case" style={S.secondaryContext}>
<span style={{color: CASE_C[q.cas], fontWeight:600}}>{CL[q.cas]}</span>
{q.prepKey && <> · {q.prepKey} ({PREPS[q.prepKey].en})</>}
{q.tw !== null && q.tw !== undefined && <> · {q.tw ? '→ motion' : '● location'}</>}
{q.type === 'poss' && <> · <span style={{color:GENDER_C[q.noun.g]}}>{DEF[q.noun.g].nominative}</span> {q.noun.w}</>}
</Hideable>
{!rev ? (
q.choices ? (
<AnswerGrid choices={q.choices} onPick={doSubmit}/>
) : (
<div style={S.inputWrap}>
<div style={S.inputRow}>
<input ref={iref} value={inp} onChange={e=>setInp(e.target.value)}
style={S.input}
placeholder={q.type==='poss' ? 'form…' : q.type==='pronoun' ? 'pronoun…' : 'article…'}
autoComplete="off" autoCapitalize="off" spellCheck={false}
enterKeyHint="go"
inputMode="text"/>
<button style={S.checkBtn} onClick={()=>doSubmit()}>Check</button>
</div>
{!IS_TOUCH && <div style={S.hintText}>or press <kbd style={S.kbd}>Enter</kbd></div>}
</div>
)
) : quickTick ? (
<QuickTick/>
) : (
q.type === 'pronoun'
? <PronounFeedback q={q} inp={inp} ok={ok}/>
: q.type === 'chunk'
? <ChunkFeedback q={q} inp={inp} ok={ok}/>
: <FillFeedback q={q} inp={inp} ok={ok}/>
)}
</div>
)}
{/* PRACTICE: preposition (which case?) */}
{scr === 'practice' && q?.type === 'prep' && (
<div style={{textAlign:'center', paddingTop:36}}>
<div style={{fontSize:13,color:'#999',marginBottom:8}}>Which case does this preposition take?</div>
<div style={{fontSize:48, fontWeight:700, color:'#1a1a1a', margin:'8px 0 4px'}}>{q.prepKey}</div>
<div style={{fontSize:15, color:'#999', marginBottom:28}}>{q.prepEn}</div>
{!rev ? (
<div style={{display:'grid',gridTemplateColumns:'1fr 1fr',gap:10,maxWidth:340,margin:'0 auto'}}>
{[
['accusative','Akkusativ','1'],['dative','Dativ','2'],
['genitive','Genitiv','3'],['wechsel','Wechsel','4'],
].map(([val,lab,key]) => (
<button key={val} style={{...S.choiceBtn, borderColor: CASE_C[val]+'40'}}
onClick={()=>doSubmit(val)}>
<div style={{fontWeight:700, color: CASE_C[val]}}>{lab}</div>
{!IS_TOUCH && <div style={{fontSize:11,color:'#999',marginTop:2}}><kbd style={S.kbdSmall}>{key}</kbd></div>}
</button>
))}
</div>
) : quickTick ? (
<QuickTick/>
) : (
<PrepFeedback q={q} inp={inp} ok={ok}/>
)}
</div>
)}
{/* PRACTICE: wechsel in context */}
{scr === 'practice' && q?.type === 'wechselContext' && (
<div style={{textAlign:'center', paddingTop:10}}>
<div style={{...S.sentence, animation:shake?'shake 0.3s':'none'}}>
{q.sentence.split('_____').map((part,i,a)=>(
<span key={i}>
{part}
{i < a.length-1 && (
<span style={{...S.blank,
borderBottomColor: rev?(ok?GREEN:RED):'#bbb',
color: rev?(ok?GREEN:RED):'#1a1a1a',
}}>
{rev ? q.article : ' '}
</span>
)}
</span>
))}
</div>
<Hideable hidden={hintHidden('translation') && !rev} onToggle={()=>toggleHint('translation')} label="translation" style={S.translation}>
{q.translation}
</Hideable>
<Hideable hidden={hintHidden('prepHint')} onToggle={()=>toggleHint('prepHint')} label="preposition" style={S.keyInfo}>
<span style={{fontWeight:700}}>{q.prepKey}</span>
<span style={S.keyInfoMute}> ({PREPS[q.prepKey].en})</span>
</Hideable>
<div style={S.secondaryContext}>
Wechselpräposition · wohin or wo?
<button style={S.ruleBtn} onClick={()=>setRule(true)}>ⓘ Rule</button>
</div>
{!rev ? (
<div style={{display:'flex',gap:10,justifyContent:'center',maxWidth:340,margin:'8px auto 0'}}>
{[['accusative','Akkusativ','1'],['dative','Dativ','2']].map(([val,lab,key]) => (
<button key={val} style={{...S.choiceBtn, borderColor: CASE_C[val]+'40', minWidth:130}}
onClick={()=>doSubmit(val)}>
<div style={{fontWeight:700, color: CASE_C[val]}}>{lab}</div>
{!IS_TOUCH && <div style={{fontSize:11,color:'#999',marginTop:2}}><kbd style={S.kbdSmall}>{key}</kbd></div>}
</button>
))}
</div>
) : quickTick ? (
<QuickTick/>
) : (
<WechselContextFeedback q={q} inp={inp} ok={ok} onRule={()=>setRule(true)}/>
)}
</div>
)}
{/* PRACTICE: contractions */}
{scr === 'practice' && q?.type === 'contraction' && (
<div style={{textAlign:'center', paddingTop:30}}>
<div style={{fontSize:13,color:'#999',marginBottom:12}}>Contract the preposition and article</div>
<div style={{fontSize:34, fontWeight:700, color:'#1a1a1a', margin:'4px 0 6px'}}>
{q.prep} <span style={{color:'#ccc'}}>+</span> {q.art} <span style={{color:'#ccc'}}>=</span> <span style={{color:'#ccc'}}>?</span>
</div>
<div style={{fontSize:12, color:'#aaa', marginBottom:22}}>
<span style={{color:CASE_C[q.cas], fontWeight:600}}>{CL[q.cas]}</span>
</div>
{!rev ? (
<AnswerGrid choices={q.choices} onPick={doSubmit}/>
) : quickTick ? (
<QuickTick/>
) : (
<ContractionFeedback q={q} inp={inp} ok={ok}/>
)}
</div>
)}
{scr === 'practice' && (
<div style={S.bottomBar}>
<div style={S.bottomBarInner}>
<div style={S.dotsRow}>
{Array.from({length:ROUND},(_,i)=>(
<div key={i} style={{...S.dot,
background: i<res.length?(res[i]?GREEN:RED):i===res.length?'#333':'#ddd',
}}/>
))}
</div>
<div style={S.dotsLabel}>{Math.min(res.length+(rev?0:1), ROUND)}/{ROUND} · {cur?.name}</div>
<button style={S.chartsPill} onClick={()=>setChart(true)}>Charts</button>
</div>
</div>
)}
{scr === 'practice' && rev && !quickTick && (
<div style={{textAlign:'center', marginTop:14}}>
<button style={S.nextBtn} onClick={doNext}>
{res.length >= ROUND ? 'See results' : 'Next'}{!IS_TOUCH && <span style={{opacity:0.5,fontSize:12,marginLeft:4}}>↵</span>}
</button>
</div>
)}
{/* SUMMARY */}
{scr === 'summary' && (
<div style={{textAlign:'center', paddingTop:60}}>
<div style={{fontSize:12,color:'#999',textTransform:'uppercase',letterSpacing:2,fontWeight:600}}>Complete</div>
<div style={{fontSize:56, fontWeight:700, color:'#1a1a1a', margin:'10px 0'}}>
{rc}<span style={{fontSize:24, color:'#bbb'}}>/{ROUND}</span>
</div>
<div style={{...S.dotsRow, position:'static', marginTop:14}}>
{res.map((r,i)=><div key={i} style={{...S.dot,width:11,height:11,borderRadius:6,background:r?GREEN:RED}}/>)}
</div>
<div style={{color:rc>=7?GREEN:'#d97706', fontSize:15, fontWeight:600, margin:'24px 0'}}>
{rc>=9?'Ausgezeichnet!':rc>=7?'Gut gemacht!':rc>=5?'Keep practicing.':'Review the charts.'}
</div>
{cur?.daily && nextDaily ? (
<>
<button style={S.nextBtn} onClick={()=>startLv(nextDaily.level.id)}>
Continue: {nextDaily.tab.label} Daily Practice →{!IS_TOUCH && <span style={{opacity:0.5,fontSize:12,marginLeft:4}}>↵</span>}
</button>
<div style={{marginTop:14}}>
<button style={S.linkBtn} onClick={()=>startLv(lv)}>play again</button>
</div>
</>
) : (
<>
{rc>=7 && nextInTab && unlocked(nextInTab.id) && (
<div style={{color:GREEN, fontSize:13, marginBottom:16}}>{nextInTab.name} unlocked.</div>
)}
<button style={S.nextBtn} onClick={()=>startLv(lv)}>Play again</button>
{rc>=7 && nextInTab && unlocked(nextInTab.id) && (
<button style={{...S.nextBtn, background:'#fff', color:GREEN, border:`1.5px solid ${GREEN}`, marginTop:10, marginLeft:8}}
onClick={()=>startLv(nextInTab.id)}>
Next: {nextInTab.name} →
</button>
)}
</>
)}
<div style={{marginTop:16}}>
<button style={S.linkBtn} onClick={()=>setScr('menu')}>back to menu</button>
</div>
</div>
)}
{scr !== 'practice' && <FeatureRequestFooter />}
</div>
{chart && <ChartModal section={chartSection} q={q} onClose={()=>setChart(false)} onRule={()=>{ setChart(false); setRule(true); }}/>}
{rule && <WechselRuleModal onClose={()=>setRule(false)}/>}
<style>{`
@keyframes shake{0%,100%{transform:translateX(0)}25%{transform:translateX(-6px)}75%{transform:translateX(6px)}}
@keyframes fadeUp{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:translateY(0)}}
input::placeholder{color:#bbb}
*{box-sizing:border-box}
html,body{margin:0;background:#f7f7f7}
#root{min-height:100%}
input{scroll-margin-top:60px;scroll-margin-bottom:80px}
`}</style>
</div>
);
}
function HomeScreen({ onStart }) {
return (
<div style={{paddingTop:18}}>
<section style={S.hero}>
<div style={S.heroMark}>der · den · dem</div>
<h1 style={S.heroTitle}>Learn German cases by feel.</h1>
<p style={S.heroCopy}>
Learn this stuff in days not months. Built by fast German learners for other fast German learners. Drill the prepositions, articles, possessives, and pronouns that slow you down, then get back to actual German.
</p>
<div style={S.heroActions}>
<button style={S.nextBtn} onClick={onStart}>Get started</button>
</div>
</section>
<CoreReference />
</div>
);
}
function FeatureRequestFooter() {
return (
<footer style={S.footerContact}>
<div>
<div>Have a feature request?</div>
<div>Always looking to improve the tool.</div>
</div>
<a
style={S.footerMail}
href={`mailto:genesis_iodine_03@icloud.com?subject=${encodeURIComponent('Beug feature request')}&body=${encodeURIComponent('Hey, I have a feature request for Beug:\n\n')}`}
>
Get in touch
</a>
</footer>
);
}
// A hint line with a persistent hide/show toggle. Hidden stays hidden
// (across sessions) until the user shows it again — for authentic testing.
function Hideable({ hidden, onToggle, label, style, children }) {
if (hidden) {
return (
<div style={style}>
<button style={S.hintToggle} onClick={onToggle}>show {label}</button>
</div>
);
}
return (
<div style={style}>
{children}
<button style={{...S.hintToggle, marginLeft:8}} onClick={onToggle}>hide</button>
</div>
);
}
function StreakBadge({ st }) {
const dots = weekDots(st);
const n = currentStreak(st);
const practiced = dots.filter(d=>d.practiced).length;
return (
<span style={{display:'flex', alignItems:'center', gap:7}}
title={`Practiced ${practiced} of the last 7 days · ${n}-day streak`}>
<span style={{display:'flex', gap:3, alignItems:'center'}}>
{dots.map(d=>(
<span key={d.date} style={{
width:6, height:6, borderRadius:3,
background: d.practiced ? GREEN : '#ddd',
boxShadow: d.isToday ? '0 0 0 1.5px #fff, 0 0 0 2.5px #ccc' : 'none',
}}/>
))}
</span>
<span style={S.stat}>🔥 {n}</span>
</span>
);
}
function CoreReference() {
const prepositionGroups = [
['accusative', 'Akkusativ', PREPS_BY_CASE.accusative],
['dative', 'Dativ', PREPS_BY_CASE.dative],
['wechsel', 'Two-way', PREPS_BY_CASE.wechsel],
['genitive', 'Genitiv', PREPS_BY_CASE.genitive],
];
const genders = ['masculine','feminine','neuter','plural'];
return (
<div style={{marginTop:20}}>
<div style={S.sectionTitle}>Case map</div>
<div style={S.coreGrid}>
<div style={S.corePanel}>
<div style={S.chartH}>Prepositions</div>
{prepositionGroups.map(([key, label, words]) => (
<div key={key} style={{fontSize:12, color:'#555', lineHeight:1.65, marginTop:8}}>
<strong style={{color:CASE_C[key]}}>{label}:</strong> {words.join(', ')}
</div>
))}
</div>
<div style={S.corePanel}>
<div style={S.chartH}>Definite Articles</div>
<table style={S.miniChart}>
<thead>
<tr>
<th style={S.miniTh}></th>
{genders.map(g => <th key={g} style={{...S.miniTh, color:GENDER_C[g]}}>{g.slice(0,4)}</th>)}
</tr>
</thead>
<tbody>
{CORE_ARTICLE_CASES.map(c => (
<tr key={c}>
<td style={{...S.miniTd, textAlign:'left', color:CASE_C[c], fontWeight:700}}>{CL[c]}</td>
{genders.map(g => <td key={g} style={S.miniTd}>{DEF[g][c]}</td>)}
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}
function PaywallScreen({ message, onCheckout, onBack }) {
return (
<div style={{paddingTop:18}}>
<div style={S.paywallCard}>
<div style={S.heroMark}>Beug Premium</div>
<h2 style={S.payTitle}>Take the whole thing.</h2>
<p style={S.payCopy}>
Possessives, pronouns, contractions, genitive articles, and mixed rounds. Built for people who want the case system handled, fast.
</p>
<div style={S.benefits}>
{['Possessives and pronouns', 'Mixed article rounds', 'Contractions and genitive', 'Saved progress on this device'].map(item => (
<div key={item} style={S.benefit}>✓ {item}</div>
))}
</div>
<div style={S.planGrid}>
{STRIPE_PLANS.map(plan => (
<button key={plan.id} style={{...S.planCard, ...(plan.id === 'yearly' ? S.planFeatured : {})}} onClick={()=>onCheckout(plan)}>
<span style={S.planNote}>{plan.note}</span>
<span style={S.planName}>{plan.name}</span>
<span style={S.planPrice}>{plan.price}</span>
<span style={S.planCadence}>{plan.cadence}</span>
</button>
))}
</div>
{message && <div style={S.payMsg}>{message}</div>}
<div style={S.payFine}>No account required for v1. Stripe redirects back here and unlocks this browser.</div>
<button style={{...S.linkBtn, marginTop:16}} onClick={onBack}>Maybe later</button>
</div>
</div>
);
}
// ═══════════════════════════════════════════
// 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 (
<div style={S.articleGrid}>
{choices.map((choice, i) => (
<button key={choice} style={S.articleBtn} onClick={()=>onPick(choice)}>
<div style={{fontSize: choice.includes(' ') ? 17 : 22, fontWeight:700}}>{choice}</div>
{!IS_TOUCH && <div style={{fontSize:11,color:'#999',marginTop:4}}><kbd style={S.kbdSmall}>{i+1}</kbd></div>}
</button>
))}
</div>
);
}
// One-tap correct answer: no solution shown, just a tick before auto-skip.
function QuickTick() {
return (
<div style={{textAlign:'center', marginTop:22, animation:'fadeUp 0.15s ease-out'}}>
<div style={{display:'inline-flex', alignItems:'center', justifyContent:'center',
width:60, height:60, borderRadius:30, background:GREEN, color:'#fff',
fontSize:32, fontWeight:700, lineHeight:1}}>✓</div>
</div>
);
}
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 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'}}>
<div style={{fontSize:15, fontWeight:600, marginBottom:6, color:ok?GREEN:'#1a1a1a'}}>
{ok ? '✓ Correct' : <>✗ <span style={{textDecoration:'line-through', color:'#999', fontWeight:400}}>{inp}</span> <span style={{color:'#999', fontWeight:400}}>→</span> <span style={{color:GREEN}}>{q.ans}</span></>}
</div>
<div style={{fontSize:13, color:'#666', marginBottom:!ok?4:10, lineHeight:1.5}}>{q.rule}</div>
{!ok && (
<div style={{fontSize:12, color:'#999', marginBottom:10, lineHeight:1.5}}>
{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>
)}
<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>
);
}
function PrepFeedback({ q, inp, ok }) {
const motEx = q.ans === 'wechsel' ? wechselExample(q.prepKey, true) : null;
const locEx = q.ans === 'wechsel' ? wechselExample(q.prepKey, false) : null;
return (
<div style={{...S.feedback, animation:'fadeUp 0.2s ease-out', maxWidth:420}}>
<div style={{fontSize:15, fontWeight:600, marginBottom:6, color:ok?GREEN:'#1a1a1a'}}>
{ok ? <>✓ <span style={{color:CASE_C[q.ans]}}>{CL[q.ans]}</span></> : <>✗ <span style={{textDecoration:'line-through',color:'#999',fontWeight:400}}>{CL[inp]||inp}</span> <span style={{color:'#999',fontWeight:400}}>→</span> <span style={{color:CASE_C[q.ans]}}>{CL[q.ans]}</span></>}
</div>
<div style={{fontSize:13, color:'#666', marginBottom:8, lineHeight:1.5}}>
„{q.prepKey}" → {q.ans === 'wechsel' ? 'Wechselpräposition (Akkusativ for motion, Dativ for location)' : `always ${CL[q.ans]}`}
</div>
{q.ans !== 'wechsel' && (
<div style={{fontSize:13, color:'#999', fontStyle:'italic', marginTop:4}}>
Example: {q.example} <span style={{color:'#bbb'}}>({q.exampleFull ? q.exampleEn : `${q.prepEn} the ${q.exampleEn}`})</span>
</div>
)}
{q.ans === 'wechsel' && (
<div style={{fontSize:12, color:'#999', marginTop:4, lineHeight:1.7}}>
<div>Motion: <em>Er geht <strong>{motEx.de}</strong></em> (<span style={{color:CASE_C.accusative}}>Akk</span>) — "{motEx.en}"</div>
<div>Location: <em>Er ist <strong>{locEx.de}</strong></em> (<span style={{color:CASE_C.dative}}>Dat</span>) — "{locEx.en}"</div>
</div>
)}
</div>
);
}
function WechselContextFeedback({ q, inp, ok, onRule }) {
return (
<div style={{...S.feedback, animation:'fadeUp 0.2s ease-out', maxWidth:420}}>
<div style={{fontSize:15, fontWeight:600, marginBottom:8, color:ok?GREEN:'#1a1a1a'}}>
{ok ? <>✓ <span style={{color:CASE_C[q.ans]}}>{CL[q.ans]}</span></> : <>✗ <span style={{textDecoration:'line-through',color:'#999',fontWeight:400}}>{CL[inp]||inp}</span> <span style={{color:'#999',fontWeight:400}}>→</span> <span style={{color:CASE_C[q.ans]}}>{CL[q.ans]}</span></>}
</div>
<div style={{fontSize:15, fontWeight:600, color:'#1a1a1a', marginBottom:8, lineHeight:1.45}}>
{q.answerSentence}
</div>
{q.contraction && (
<div style={{fontSize:12, color:'#999', marginBottom:8}}>
{q.prepKey} + {q.article} fuses to <strong style={{color:'#666'}}>{q.contraction}</strong> in real German.
</div>
)}
<div style={{fontSize:13, color:'#666', marginBottom:6, lineHeight:1.5}}>
„{q.verb === 'ankommen' ? 'ankommen' : q.verb}" {q.isMotion
? 'puts something somewhere new (wohin?)'
: q.verb === 'ankommen'
? 'keeps the Dativ even though you arrive — the an- prefix already carries the arrival'
: 'just says where it all happens (wo?)'} → <span style={{color:CASE_C[q.ans], fontWeight:600}}>{CL[q.ans]}</span>
</div>
<div style={{fontSize:12, color:'#999', lineHeight:1.6}}>
Before this happened, was it already {PREPS[q.prepKey].en.split('/')[0]} the {q.nounEn}?
{' '}{q.isMotion
? <>No — it got there because of the action. That is <span style={{color:CASE_C.accusative}}>Akkusativ</span>.</>
: <>Yes — it was already there and stays. That is <span style={{color:CASE_C.dative}}>Dativ</span>.</>}
</div>
{onRule && (
<button style={{...S.ruleBtn, marginLeft:0, marginTop:10}} onClick={onRule}>ⓘ The whole rule</button>
)}
</div>
);
}
// The wohin/wo rule, the trap, the verb pairs, and the one real exception.
function WechselRuleModal({ onClose }) {
const PAIRS = [
['legen', 'lay it down', 'liegen', 'be lying'],
['stellen', 'stand it up', 'stehen', 'be standing'],
['setzen', 'sit it down', 'sitzen', 'be sitting'],
['hängen', 'hang it up (hängte)', 'hängen', 'be hanging (hing)'],
['stecken', 'stick it in', 'stecken', 'be stuck in'],
];
return (
<div style={S.overlay} onClick={onClose}>
<div style={S.modal} onClick={e=>e.stopPropagation()}>
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:14}}>
<div style={{fontSize:17,fontWeight:700}}>Wohin oder wo?</div>
<button onClick={onClose} style={{background:'none',border:'none',fontSize:18,color:'#999',cursor:'pointer'}}>✕</button>
</div>
<div style={S.ruleLead}>
Yes — there is a rule, and it is not “is something moving?”. Ask instead:
does the phrase say where the thing <strong>ends up</strong>, or where the
whole action <strong>happens</strong>?
</div>
<div style={{...S.ruleCard, borderLeft:`3px solid ${CASE_C.accusative}`}}>
<div style={{fontWeight:700, color:CASE_C.accusative, fontSize:13, marginBottom:4}}>Akkusativ — wohin?</div>
<div style={S.ruleBody}>The action puts something into a new place. It wasn’t there before; it is now.</div>
<div style={S.ruleEx}>Er legt das Buch auf <strong>den</strong> Tisch. — the book arrives on the table.</div>
</div>
<div style={{...S.ruleCard, borderLeft:`3px solid ${CASE_C.dative}`}}>
<div style={{fontWeight:700, color:CASE_C.dative, fontSize:13, marginBottom:4}}>Dativ — wo?</div>
<div style={S.ruleBody}>The phrase just locates the scene. It was already there and stays there.</div>
<div style={S.ruleEx}>Das Buch liegt auf <strong>dem</strong> Tisch. — it was already on the table.</div>
</div>
<div style={S.ruleH}>The test that actually works</div>
<div style={S.ruleBody}>
<strong>“Before this happened, was it already there?”</strong><br/>
Already there → <span style={{color:CASE_C.dative,fontWeight:600}}>Dativ</span>.
Got there because of the action → <span style={{color:CASE_C.accusative,fontWeight:600}}>Akkusativ</span>.
</div>
<div style={S.ruleH}>The trap: movement is not the test</div>
<div style={S.ruleBody}>
Plenty of motion verbs take the Dativ, because the phrase is the arena the
movement happens in, not a destination:
</div>
<div style={S.ruleEx}>
Er schwimmt in <strong>dem</strong> See (im See) — swimming around <em>in</em> the lake.<br/>
Er springt in <strong>den</strong> See — he was out, now he’s in.
</div>
<div style={S.ruleBody}>Same story with tanzen, spielen, laufen, rennen.</div>
<div style={S.ruleH}>The verb pairs — learn these cold</div>
<div style={{fontSize:11, color:'#999', marginBottom:8, lineHeight:1.5}}>
The Akkusativ ones are transitive: they always have a direct object being moved.
If something is being put somewhere, the preposition takes Akkusativ too.
</div>
<table style={S.fullChart}>
<thead><tr>
<th style={{...S.fcTh, textAlign:'left', color:CASE_C.accusative}}>puts it there · Akk</th>
<th style={{...S.fcTh, textAlign:'left', color:CASE_C.dative}}>is already there · Dat</th>
</tr></thead>
<tbody>
{PAIRS.map(([a, aEn, d, dEn]) => (
<tr key={a + d}>
<td style={{...S.fcTd, textAlign:'left'}}>
<strong>{a}</strong> <span style={{color:'#aaa', fontSize:11}}>{aEn}</span>
</td>
<td style={{...S.fcTd, textAlign:'left'}}>
<strong>{d}</strong> <span style={{color:'#aaa', fontSize:11}}>{dEn}</span>
</td>
</tr>
))}
</tbody>
</table>
<div style={{fontSize:11, color:'#999', marginTop:6, lineHeight:1.5}}>
hängen and stecken are the mean ones — same present tense either way, so the
only clue is whether there is a direct object.
</div>
<div style={S.ruleH}>The exception worth knowing</div>
<div style={S.ruleBody}>
<strong>ankommen</strong> takes the <span style={{color:CASE_C.dative,fontWeight:600}}>Dativ</span>,
even though arriving is the most obvious movement there is:
</div>
<div style={S.ruleEx}>Wir kommen an <strong>dem</strong> Bahnhof an (am Bahnhof).</div>
<div style={S.ruleBody}>
The <em>an-</em> prefix already carries the arrival, so the phrase only says where
you ended up being. <em>befestigen an</em> + Dativ works the same way.
</div>
<button style={{...S.nextBtn, width:'100%', marginTop:16}} onClick={onClose}>
Got it{!IS_TOUCH && <span style={{opacity:0.5,fontSize:12,marginLeft:4}}>esc</span>}
</button>
</div>
</div>
);
}
function ChunkFeedback({ q, inp, ok }) {
const article = DEF[q.noun.g][q.cas];
return (
<div style={{...S.feedback, animation:'fadeUp 0.2s ease-out', maxWidth:420}}>
<div style={{fontSize:15, fontWeight:600, marginBottom:6, color:ok?GREEN:'#1a1a1a'}}>
{ok
? <>✓ <span style={{color:GREEN}}>{q.ans}</span></>
: <>✗ <span style={{textDecoration:'line-through', color:'#999', fontWeight:400}}>{inp}</span> <span style={{color:'#999', fontWeight:400}}>→</span> <span style={{color:GREEN}}>{q.ans}</span></>}
</div>
<div style={{fontSize:13, color:'#666', marginBottom:6, lineHeight:1.5}}>
{q.contracts
? <><strong>{q.prepKey}</strong> + <strong>{article}</strong> = <strong style={{color:'#1a1a1a'}}>{q.ans}</strong></>
: <>„{q.prepKey}" + <span style={{color:CASE_C[q.cas]}}>{CL[q.cas]}</span> → <strong style={{color:'#1a1a1a'}}>{q.ans}</strong></>}
</div>
<div style={{fontSize:12, lineHeight:1.5, color: q.contracts ? GREEN : '#999'}}>
{q.contracts
? 'Preferred — these fuse, so use the contraction in speech and writing.'
: <><strong>{article}</strong> doesn’t 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>
);
}
function ContractionFeedback({ q, inp, ok }) {
return (
<div style={{...S.feedback, animation:'fadeUp 0.2s ease-out', maxWidth:420}}>
<div style={{fontSize:15, fontWeight:600, marginBottom:8, color:ok?GREEN:'#1a1a1a'}}>
{ok
? <>✓ {q.prep} + {q.art} = <span style={{color:GREEN}}>{q.ans}</span></>
: <>✗ <span style={{textDecoration:'line-through', color:'#999', fontWeight:400}}>{inp}</span> <span style={{color:'#999', fontWeight:400}}>→</span> <span style={{color:GREEN}}>{q.ans}</span></>}
</div>
<div style={{fontSize:13, color:'#666', marginBottom:6, lineHeight:1.5}}>
{q.prep} + {q.art} = <strong style={{color:'#1a1a1a'}}>{q.ans}</strong>
</div>
<div style={{fontSize:12, color: q.pref ? GREEN : '#d97706', lineHeight:1.5}}>
{q.pref
? '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>
);
}
function PronounFeedback({ q, inp, ok }) {
const cases = ['nominative','accusative','dative'];
const labels = { nominative:'Nom', accusative:'Acc', dative:'Dat' };
const keys = { nominative:'nom', accusative:'acc', dative:'dat' };
return (
<div style={{...S.feedback, animation:'fadeUp 0.2s ease-out', maxWidth:420}}>
<div style={{fontSize:15, fontWeight:600, marginBottom:8, color:ok?GREEN:'#1a1a1a'}}>
{ok ? '✓ Correct' : <>✗ <span style={{textDecoration:'line-through', color:'#999', fontWeight:400}}>{inp}</span> <span style={{color:'#999', fontWeight:400}}>→</span> <span style={{color:GREEN}}>{q.ans}</span></>}
</div>
<div style={{fontSize:12, color:'#999', marginBottom:10, lineHeight:1.5}}>
{q.pron.en} ({q.pron.tag}) — <span style={{color: CASE_C[q.cas]}}>{CL[q.cas]}</span> form
</div>
<table style={S.stripTbl}>
<thead><tr>
<th style={{...S.stripH, textAlign:'left', fontWeight:600}}>{q.pron.nom}</th>
{cases.map(c => <th key={c} style={{...S.stripH, color: CASE_C[c]}}>{labels[c]}</th>)}
</tr></thead>
<tbody><tr>
<td style={{...S.stripD, textAlign:'left', color:'#aaa', fontSize:11}}>{q.pron.en}</td>
{cases.map(c => {
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: (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>
</div>
);
}
// ═══════════════════════════════════════════
// CHART MODAL
// ═══════════════════════════════════════════
function ChartModal({ section, q, onClose, onRule }) {
const genders = ['masculine','feminine','neuter','plural'];
const cases = ['nominative','accusative','dative','genitive'];
return (
<div style={S.overlay} onClick={onClose}>
<div style={S.modal} onClick={e=>e.stopPropagation()}>
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:14}}>
<div style={{fontSize:17,fontWeight:700}}>
{section==='article'?'Articles':section==='prep'?'Prepositions':section==='poss'?'Possessives':section==='pronoun'?'Personal Pronouns':'Charts'}
</div>
<button onClick={onClose} style={{background:'none',border:'none',fontSize:18,color:'#999',cursor:'pointer'}}>✕</button>
</div>
{section === 'article' && <>
<div style={{marginBottom:22}}>
<div style={S.chartH}>All-in-One Declension Chart</div>
<div style={{fontSize:11, color:'#999', marginBottom:8}}>Strong (S): no article carries the case. Weak (W): after a definite article.</div>
<table style={S.fullChart}>
<thead>
<tr>
<th rowSpan={2} style={{...S.fcTh, textAlign:'left'}}></th>
{genders.map(g => <th key={g} colSpan={2} style={{...S.fcTh, color: GENDER_C[g], borderBottom:'1px solid #eee'}}>{g}</th>)}
</tr>
<tr>
{genders.map(g => (
<Frag2 key={g}>
<th style={{...S.fcTh, fontSize:10, color:'#aaa', fontWeight:600}}>S</th>
<th style={{...S.fcTh, fontSize:10, color:'#aaa', fontWeight:600}}>W</th>
</Frag2>
))}
</tr>
</thead>
<tbody>
{cases.map(c => (
<tr key={c}>
<td style={{...S.fcTd, textAlign:'left', fontWeight:700, fontSize:11, color:CASE_C[c], textTransform:'uppercase', letterSpacing:0.3}}>{CL[c]}</td>
{genders.map(g => {
const hl = q && (q.type==='article'||q.type==='poss') && c === q.cas && g === q.noun?.g;
return (
<Frag2 key={g}>
<td style={{...S.fcTd, fontWeight: hl?700:400, color: hl?GREEN:'#444', background: hl?GREEN+'15':'transparent', borderRadius: 3}}>{STRONG[g][c]}</td>
<td style={{...S.fcTd, fontWeight: hl?700:400, color: hl?GREEN:'#999', background: hl?GREEN+'15':'transparent', borderRadius: 3}}>{WEAK[g][c]}</td>
</Frag2>
);
})}
</tr>
))}
</tbody>
</table>
</div>
<div style={{marginBottom:22}}>
<div style={S.chartH}>Unbestimmter Artikel (ein/eine/kein)</div>
<SimpleChart table={INDEF} highlightCase={q?.cas} highlightGender={q?.noun?.g} active={q?.type==='article' && q?.artType==='indefinite'}/>
</div>
<div style={{marginBottom:18}}>
<div style={S.chartH}>Bestimmter Artikel (der/die/das)</div>
<SimpleChart table={DEF} highlightCase={q?.cas} highlightGender={q?.noun?.g} active={q?.type==='article' && q?.artType==='definite'}/>
</div>
</>}
{section === 'prep' && (
<div style={{marginBottom:18}}>
<div style={S.chartH}>Prepositions by Case</div>
<div style={{fontSize:13, lineHeight:2.2, color:'#444'}}>
<div><strong style={{color:CASE_C.accusative}}>Akkusativ:</strong> durch, für, gegen, ohne, um, bis</div>
<div><strong style={{color:CASE_C.dative}}>Dativ:</strong> aus, außer, bei, gegenüber, mit, nach, seit, von, zu</div>
<div><strong style={{color:CASE_C.genitive}}>Genitiv:</strong> trotz, während, wegen, statt, anstatt, außerhalb, innerhalb</div>
<div><strong style={{color:CASE_C.wechsel}}>Wechsel:</strong> {PREPS_BY_CASE.wechsel.join(', ')}</div>
</div>
<div style={{fontSize:12, color:'#999', marginTop:14, lineHeight:1.6}}>
<strong>Wechsel</strong>: Akkusativ when the phrase is where something ends up
(wohin?), Dativ when it is where the action happens (wo?) — movement alone does
not decide it.
{onRule && <button style={{...S.ruleBtn, marginLeft:0, marginTop:8, display:'block'}} onClick={onRule}>ⓘ Wohin oder wo?</button>}
</div>
</div>
)}
{section === 'poss' && (
<div style={{marginBottom:18}}>
<div style={S.chartH}>Possessive Determiners</div>
<div style={{fontSize:12, color:'#888', marginBottom:10}}>
{POSSESSIVES.map(p => <div key={p.stem} style={{marginBottom:2}}><strong style={{color:'#444'}}>{p.stem}-</strong> <span style={{color:'#999'}}>{p.en}</span></div>)}
</div>
<div style={{fontSize:11, color:'#999', marginBottom:10}}>Endings follow the ein/kein pattern. <em>euer</em> drops to <em>eur-</em> when an ending is added.</div>
<PossessiveChart highlightCase={q?.cas} highlightGender={q?.noun?.g} active={q?.type==='poss'} highlightStem={q?.stem}/>
</div>
)}
{section === 'pronoun' && (
<div style={{marginBottom:18}}>
<div style={S.chartH}>Personal Pronouns</div>
<PronounsChartCombined highlightCase={q?.cas} highlightKey={q?.pronKey} active={q?.type==='pronoun'}/>
</div>
)}
<button style={{...S.nextBtn, width:'100%', marginTop:10}} onClick={onClose}>
Close{!IS_TOUCH && <span style={{opacity:0.5,fontSize:12,marginLeft:4}}>esc</span>}
</button>
</div>
</div>
);
}
function Frag2({ children }) { return <>{children}</>; }
function SimpleChart({ table, highlightCase, highlightGender, active }) {
const genders = ['masculine','feminine','neuter','plural'];
const cases = ['nominative','accusative','dative','genitive'];
return (
<table style={S.fullChart}>
<thead><tr><th style={S.fcTh}></th>{genders.map(g => <th key={g} style={{...S.fcTh, color:GENDER_C[g]}}>{g}</th>)}</tr></thead>
<tbody>
{cases.map(c => (
<tr key={c}>
<td style={{...S.fcTd, textAlign:'left', fontWeight:700, fontSize:11, color:CASE_C[c], textTransform:'uppercase', letterSpacing:0.3}}>{CL[c]}</td>
{genders.map(g => {
const hl = active && c === highlightCase && g === highlightGender;
return <td key={g} style={{...S.fcTd, fontSize:14, fontWeight: hl?700:400, color: hl?GREEN:'#555', background: hl?GREEN+'15':'transparent', borderRadius: 3}}>{table[g][c]}</td>;
})}
</tr>
))}
</tbody>
</table>
);
}
function PossessiveChart({ highlightCase, highlightGender, active, highlightStem }) {
const genders = ['masculine','feminine','neuter','plural'];
const cases = ['nominative','accusative','dative','genitive'];
const stem = highlightStem || 'mein';
return (
<div>
<table style={S.fullChart}>
<thead><tr>
<th style={S.fcTh}></th>
{genders.map(g => <th key={g} style={{...S.fcTh, color:GENDER_C[g]}}>{g}</th>)}
</tr></thead>
<tbody>
{cases.map(c => (
<tr key={c}>
<td style={{...S.fcTd, textAlign:'left', fontWeight:700, fontSize:11, color:CASE_C[c], textTransform:'uppercase', letterSpacing:0.3}}>{CL[c]}</td>
{genders.map(g => {
const hl = active && c === highlightCase && g === highlightGender;
const display = combinePoss(stem, POSS_ENDINGS[g][c]);
return <td key={g} style={{...S.fcTd, fontSize:14, fontWeight: hl?700:400, color: hl?GREEN:'#555', background: hl?GREEN+'15':'transparent', borderRadius: 3}}>{display}</td>;
})}
</tr>
))}
</tbody>
</table>
<div style={{fontSize:11, color:'#aaa', marginTop:6}}>Showing forms for <strong style={{color:'#666'}}>{stem}-</strong>. Same endings apply to all possessive bases.</div>
</div>
);
}
function PronounsChartCombined({ highlightCase, highlightKey, active }) {
const cases = ['nominative','accusative','dative'];
const labels = { nominative:'Nom', accusative:'Acc', dative:'Dat' };
const keys = { nominative:'nom', accusative:'acc', dative:'dat' };
return (
<table style={S.fullChart}>
<thead><tr>
<th style={{...S.fcTh, textAlign:'left'}}>english</th>
<th style={{...S.fcTh, textAlign:'left', color:'#aaa'}}>person</th>
{cases.map(c => <th key={c} style={{...S.fcTh, color: CASE_C[c]}}>{labels[c]}</th>)}
</tr></thead>
<tbody>
{PRONOUNS.map((p, idx) => {
const sep = idx === 6;
return (
<tr key={p.key} style={{borderTop: sep ? '2px solid #ddd' : 'none'}}>
<td style={{...S.fcTd, textAlign:'left', fontSize:12, color:'#888'}}>{p.en}</td>
<td style={{...S.fcTd, textAlign:'left', fontSize:11, color:'#bbb'}}>{p.tag}</td>
{cases.map(c => {
const hl = active && c === highlightCase && p.key === highlightKey;
return <td key={c} style={{...S.fcTd, fontSize:14, fontWeight: hl?700:400, color: hl?GREEN:'#555', background: hl?GREEN+'15':'transparent', borderRadius: 3}}>{p[keys[c]]}</td>;
})}
</tr>
);
})}
</tbody>
</table>
);
}
// ═══════════════════════════════════════════
// STYLES
// ═══════════════════════════════════════════
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, 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 },
hero: { background:'#fff', border:'1px solid #eee', borderRadius:12, padding:'24px 22px', textAlign:'left' },
heroMark: { fontSize:12, color:GREEN, fontWeight:800, textTransform:'uppercase', letterSpacing:0.8, marginBottom:10 },
heroTitle: { fontSize:38, lineHeight:1.05, margin:'0 0 12px', color:'#1a1a1a', letterSpacing:0, fontWeight:800 },
heroCopy: { fontSize:15, lineHeight:1.55, color:'#666', margin:'0 0 20px' },
heroActions: { display:'flex', gap:10, flexWrap:'wrap' },
sectionTitle: { fontSize:12, color:'#999', textTransform:'uppercase', letterSpacing:1, fontWeight:800, margin:'0 0 8px' },
coreGrid: { display:'grid', gridTemplateColumns:'1fr', gap:10 },
corePanel: { background:'#fff', border:'1px solid #eee', borderRadius:10, padding:'16px 16px' },
miniChart: { width:'100%', borderCollapse:'collapse', marginTop:10 },
miniTh: { padding:'5px 4px', fontSize:10, color:'#aaa', textAlign:'center', textTransform:'uppercase', fontWeight:700 },
miniTd: { padding:'7px 4px', fontSize:13, color:'#444', borderBottom:'1px solid #f3f3f3', textAlign:'center' },
lvRow: { display:'flex', alignItems:'center', gap:14, padding:'13px 16px', marginBottom:6, background:'#fff', borderRadius:10, border:'1px solid #eee' },
lvRowBlurred: { filter:'blur(0.8px)', opacity:0.82 },
lvName: { fontSize:14, fontWeight:700, color:'#1a1a1a' },
lvDesc: { fontSize:12, color:'#999', marginTop:1 },
lvPct: { fontSize:14, fontWeight:700 },
chartLinkBtn: { width:'100%', padding:12, marginTop:20, background:'#fff', border:'1px solid #eee', borderRadius:10, color:'#888', fontSize:13, cursor:'pointer', fontFamily:'inherit' },
sentence: { fontSize:26, fontWeight:600, color:'#1a1a1a', lineHeight:1.5, margin:'0 0 10px', letterSpacing:'-0.3px' },
blank: { display:'inline-block', minWidth:80, borderBottom:'2px solid', textAlign:'center', padding:'0 6px', margin:'0 2px', fontWeight:700, transition:'all 0.15s' },
translation: { fontSize:14, color:'#999', marginBottom:10 },
keyInfo: { fontSize:16, color:'#333', fontWeight:600, marginBottom:4 },
keyInfoMute: { color:'#aaa', fontWeight:400 },
secondaryContext: { fontSize:12, color:'#aaa', marginBottom:16, lineHeight:1.5 },
inputWrap: { display:'flex', flexDirection:'column', alignItems:'center', gap:6 },
inputRow: { display:'flex', gap:8, alignItems:'center' },
input: { width:170, padding:'12px 14px', borderRadius:8, border:'1.5px solid #ddd', background:'#fff', color:'#1a1a1a', fontSize:18, fontWeight:600, outline:'none', textAlign:'center', fontFamily:'inherit' },
checkBtn: { padding:'12px 20px', borderRadius:8, background:GREEN, color:'#fff', border:'none', fontSize:14, fontWeight:700, cursor:'pointer', fontFamily:'inherit' },
hintText: { fontSize:11, color:'#bbb' },
kbd: { background:'#f0f0f0', padding:'1px 5px', borderRadius:3, fontSize:10, color:'#888', border:'1px solid #e0e0e0' },
hintToggle: { background:'none', border:'none', padding:0, color:'#c2c2c2', fontSize:10, fontWeight:600, textTransform:'uppercase', letterSpacing:0.5, cursor:'pointer', fontFamily:'inherit', textDecoration:'underline', textDecorationStyle:'dotted', textUnderlineOffset:3 },
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' },
// 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',
...(IS_TOUCH ? {minHeight:66, display:'flex', flexDirection:'column', alignItems:'center', justifyContent:'center'} : {}) },
ruleBtn: { marginLeft:8, background:'none', border:'1px solid #ddd', borderRadius:999, padding:'3px 10px', color:'#888', fontSize:11, fontWeight:600, cursor:'pointer', fontFamily:'inherit' },
ruleLead: { fontSize:13, color:'#444', lineHeight:1.6, marginBottom:14 },
ruleCard: { background:'#fafafa', borderRadius:8, padding:'12px 14px', marginBottom:10 },
ruleH: { fontSize:12, fontWeight:800, textTransform:'uppercase', letterSpacing:0.6, color:'#bbb', margin:'18px 0 6px' },
ruleBody: { fontSize:13, color:'#555', lineHeight:1.6 },
ruleEx: { fontSize:13, color:'#1a1a1a', lineHeight:1.7, background:'#f5f5f5', borderRadius:6, padding:'8px 10px', margin:'8px 0' },
feedback: { textAlign:'left', background:'#fff', borderRadius:10, padding:'14px 18px', margin:'0 auto', maxWidth:400, border:'1px solid #eee' },
stripTbl: { width:'100%', borderCollapse:'collapse', marginTop:10 },
stripH: { padding:'4px 6px', fontSize:10, fontWeight:600, textTransform:'uppercase', letterSpacing:0.3, color:'#bbb', textAlign:'center' },
stripD: { padding:'6px', textAlign:'center', fontSize:13 },
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' },
paywallCard: { background:'#fff', border:'1px solid #eee', borderRadius:12, padding:'24px 20px', textAlign:'left' },
payTitle: { fontSize:30, lineHeight:1.1, margin:'0 0 10px', color:'#1a1a1a', letterSpacing:0, fontWeight:800 },
payCopy: { fontSize:14, lineHeight:1.55, color:'#666', margin:'0 0 16px' },
benefits: { display:'grid', gap:7, margin:'0 0 18px' },
benefit: { fontSize:13, color:'#444', fontWeight:600 },
planGrid: { display:'grid', gridTemplateColumns:'1fr', gap:10 },
planCard: { position:'relative', display:'grid', gridTemplateColumns:'1fr auto', alignItems:'end', gap:'2px 12px', width:'100%', padding:'15px 14px', borderRadius:10, border:'1.5px solid #e6e6e6', background:'#fff', color:'#1a1a1a', cursor:'pointer', fontFamily:'inherit', textAlign:'left' },
planFeatured: { borderColor:GREEN, background:'#f3ffe8', boxShadow:'0 0 0 3px rgba(88,167,0,0.14)' },
planNote: { gridColumn:'1 / -1', fontSize:10, fontWeight:800, letterSpacing:0.6, textTransform:'uppercase', color:GREEN, marginBottom:2 },
planName: { fontSize:14, fontWeight:800 },
planPrice: { fontSize:20, fontWeight:800, textAlign:'right' },
planCadence: { fontSize:11, color:'#999' },
payMsg: { marginTop:12, border:'1px solid #f5d0d0', background:'#fff7f7', color:'#b91c1c', borderRadius:8, padding:'10px 12px', fontSize:12, lineHeight:1.4 },
payFine: { marginTop:12, fontSize:11, color:'#aaa', lineHeight:1.4 },
footerContact: { borderTop:'1px solid #e9e9e9', marginTop:28, paddingTop:18, display:'flex', gap:12, justifyContent:'space-between', alignItems:'center', color:'#777', fontSize:12, lineHeight:1.4 },
footerMail: { flex:'0 0 auto', color:GREEN, fontSize:12, fontWeight:800, textDecoration:'none' },
overlay: { position:'fixed', inset:0, background:'rgba(0,0,0,0.35)', display:'flex', alignItems:'center', justifyContent:'center', zIndex:100, padding:16 },
modal: { background:'#fff', borderRadius:14, padding:'22px 22px', maxWidth:520, width:'100%', maxHeight:'88vh', overflow:'auto', boxShadow:'0 8px 30px rgba(0,0,0,0.12)' },
chartH: { fontSize:13, fontWeight:700, color:'#1a1a1a', marginBottom:6 },
fullChart: { width:'100%', borderCollapse:'collapse', fontSize:14 },
fcTh: { padding:'6px 4px', fontSize:11, fontWeight:600, textAlign:'center', color:'#888', textTransform:'capitalize' },
fcTd: { padding:'7px 4px', textAlign:'center', fontSize:14, borderBottom:'1px solid #f3f3f3' },
};