Files
creator/frontend/src/components/BausteinPanel.vue
2026-06-24 09:25:14 +02:00

989 lines
46 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
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.
<script setup>
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
import { chatBaustein, pruefeBaustein, fetchFrageMuster } from '../api.js'
import { usePruefSlot } from '../pruefungCache.js'
import { renderMarkdown, renderMarkdownInline } from '../markdown.js'
import { stufeFuer, malusRegel } from '../stufen.js'
import { useChat, istUnten } from '../composables/useChat.js'
const props = defineProps({
topic: { type: String, required: true },
baustein: { type: String, required: true },
section: { type: String, default: '' }, // ausführliche Fassung
sectionKompakt: { type: String, default: '' }, // kompakte Fassung (Merksätze) — Prüfungs-/Chat-Kontext
provider: { type: String, default: 'claude' },
status: { type: Object, default: null }, // {gute_antworten, streak, absolviert, verstanden, gemeistert}
cap: { type: Number, default: 6 }, // Score-Deckel = Max des höchsten Formats (6/12/18/30)
// 'trigger' (Liste): nur Tab-Leiste, Klick öffnet den Vollbild-Fokus.
// 'full' (Fokus): Tabs + Panel-Inhalt wie gehabt.
mode: { type: String, default: 'trigger' },
initialTab: { type: String, default: null },
ansicht: { type: String, default: 'kompakt' }, // kompakt | erklärend — steuert die linke Guide-Spalte
})
const emit = defineEmits(['statusChanged', 'openFokus', 'setAnsicht'])
// Lernstufen relativ zum cap (stufen.js): grün/blau/lila/gold @ 20/40/60/100 %.
// Prüfung IMMER zufällig aus 5 Formen (mit Muster); cap = 2×relevante Subbausteine.
const MODI = ['quiz_leicht', 'quiz_schwer', 'lueck_leicht', 'lueck_schwer', 'erklaeren']
const st = computed(() => props.status || { gute_antworten: 0, streak: 0, cap: props.cap })
const score = computed(() => st.value.gute_antworten || 0)
const cap = computed(() => st.value.cap || props.cap)
const scoreAnzeige = computed(() => Math.min(score.value, cap.value)) // Altbestand kann cap übersteigen
const stufe = computed(() => stufeFuer(score.value, cap.value)) // Objekt {key,label,kurz,farbe} | null
const schwerWahl = ref(false) // ungenutzt im Zufallsmodus (immer zufällig)
const amDeckel = computed(() => score.value >= cap.value)
// Mit Frage-Muster zufällig aus 5 Formen; ohne Muster (alter Baustein) nur Erklären.
const aktuellesBand = computed(() => musterModus.value ? 'zufall' : 'erklaeren')
const imZufall = computed(() => aktuellesBand.value === 'zufall')
const aktiverModus = computed(() => aktuellesBand.value) // 'zufall' | 'erklaeren'
const aktiveForm = computed(() => imZufall.value ? 'quiz' : 'erklaeren') // Platzhalter; gezogenes Objekt überschreibt
const schwer = computed(() => false)
// Angezeigte Form: folgt der aktiven Form, friert ein, solange eine gezogene Frage sichtbar ist.
const anzeigeForm = ref(null)
const zeigeForm = computed(() => anzeigeForm.value || aktiveForm.value)
const anzeigeBand = ref(null)
const abschnittWechsel = computed(() => anzeigeBand.value && anzeigeBand.value !== aktuellesBand.value)
// Schwierigkeit des gerade gezeigten Widgets (vom gezogenen Objekt).
const aktSchwer = computed(() => {
if (zeigeForm.value === 'quiz' && quizAktuell.value) return quizAktuell.value.schwer
if (zeigeForm.value === 'lueckentext' && lueckAktuell.value) return lueckAktuell.value.schwer
return false
})
const modusRegel = computed(() => {
const m = malusRegel(score.value, cap.value) // '0' | '1' | '2' | '3' nach Fortschritt
if (zeigeForm.value === 'quiz') return aktSchwer.value ? `x von 4 · +3/${m}` : `1 von 4 · +1/${m}`
if (zeigeForm.value === 'lueckentext') return aktSchwer.value ? `frei tippen · +3/${m}` : `Begriff aus 4 · +1/${m}`
return `+1/+2/+3 · Fehler ${m}`
})
const bandZiel = computed(() => cap.value)
const FORM_NAME = { quiz: 'Quiz', lueckentext: 'Lückentext', erklaeren: 'Erklären' }
// --- Toggle-Bereich ---
const activeTab = ref(props.mode === 'full' ? props.initialTab : null) // null | 'chat' | 'pruefung'
// Klick auf einen Tab: in der Liste den Fokus öffnen, im Fokus den Tab umschalten.
function tabKlick(tab) {
if (props.mode === 'trigger') { emit('openFokus', tab); return }
activeTab.value = activeTab.value === tab ? null : tab
}
// --- Bausteinchat (flüchtig) ---
const chat = useChat((msgs) => chatBaustein({
topic: props.topic, baustein: props.baustein, section: props.section, section_kompakt: props.sectionKompakt,
messages: msgs, provider: props.provider,
}))
// --- Prüfung: gesteuerter Dialog ---
// Phasen: 'idle' (Frage anfordern) | 'frage_offen' (antworten/nachfragen) | 'bewertet' (diskutieren/neu bewerten/weiter)
// Durabler State liegt in einem GETEILTEN Slot pro Baustein (Modul-Store): überlebt
// das Remounten und eine spät eintreffende Frage ist sofort reaktiv sichtbar.
const pruefKey = `${props.topic}::${props.baustein}`
const slot = usePruefSlot(pruefKey)
const pruefMessages = slot.messages // {role, kind: 'frage'|'nachfrage'|'antwort'|'feedback'|'diskussion'|'fehler', content, bewertung?}
const pruefPhase = slot.phase
const aktuelleFrage = slot.aktuelleFrage // ankert Bewertung/Diskussion
const letztesFeedback = slot.letztesFeedback // Kontext für die Diskussion über eine Bewertung
const musterQuelle = slot.musterQuelle // unveränderliche Voll-Liste der Frage-Muster (leer = Fallback)
const musterPool = slot.musterPool // Arbeitskopie, ohne Zurücklegen; leer → Reset
const musterGeladen = slot.musterGeladen
const poolForm = slot.poolForm // Form, für die der Pool gefüllt ist
const quizAktuell = slot.quizAktuell // laufende Quiz-Frage (Widget-State)
const lueckAktuell = slot.lueckAktuell // laufende Lückentext-Aufgabe (Widget-State)
// Transient (pro Instanz): Eingabe-Entwurf, Lade-Spinner.
const pruefInput = ref('')
const pruefLoading = ref(false)
const nachgefragt = ref(false) // für die laufende Frage wurde nachgefragt → Gewinn max +1
const gruendlichMsg = ref(null) // Feedback-Bubble, für die das Gründlich-Feld offen ist
const gruendlichText = ref('') // optionale Begründung für „Gründlich prüfen"
const pruefMessagesEl = ref(null)
const pruefInputEl = ref(null)
const pruefStick = ref(true) // nur auto-scrollen, wenn der Nutzer (fast) unten ist
let pruefRun = 0
function onPruefScroll() {
if (pruefMessagesEl.value) pruefStick.value = istUnten(pruefMessagesEl.value)
}
function applyPruefung(res) {
// res.streak nur bei antwort_pruefen gesetzt (verbindlich, persistiert); sonst Streak unverändert lassen.
emit('statusChanged', { baustein: props.baustein, ...st.value, gute_antworten: res.gute_antworten, streak: res.streak ?? st.value.streak, cap: res.cap ?? cap.value })
}
async function pruefScroll() {
await nextTick()
if (pruefMessagesEl.value && pruefStick.value) pruefMessagesEl.value.scrollTop = pruefMessagesEl.value.scrollHeight
}
// Nur echte Gesprächs-Turns ans Backend; Feedback bleibt reines UI-Artefakt.
function pruefDialog() {
return pruefMessages.value
.filter((m) => m.kind !== 'feedback' && m.kind !== 'fehler')
.map((m) => ({ role: m.role, content: m.content }))
}
async function pruefSenden(payload, onOk) {
const run = ++pruefRun
pruefStick.value = true // eigene Aktion = ans Ende; Hochscrollen während des Wartens setzt es wieder false
pruefLoading.value = true
pruefScroll()
try {
const res = await pruefeBaustein({
topic: props.topic, baustein: props.baustein, section: props.section, section_kompakt: props.sectionKompakt,
provider: props.provider, messages: pruefDialog(), ...payload,
})
if (run !== pruefRun) return
onOk(res)
applyPruefung(res)
pruefScroll()
nextTick(() => pruefInputEl.value?.focus())
} catch {
if (run === pruefRun) pruefMessages.value.push({ role: 'assistant', kind: 'fehler', content: 'Hat nicht geklappt — bitte erneut.' })
} finally {
if (run === pruefRun) pruefLoading.value = false
}
}
// Frage anzeigen (frisch oder vorgemerkt). Basis/Streak führt der Server je Frage.
function frageZeigen(text) {
aktuelleFrage.value = text
letztesFeedback.value = ''
nachgefragt.value = false
gruendlichMsg.value = null
pruefMessages.value.push({ role: 'assistant', kind: 'frage', content: text })
pruefPhase.value = 'frage_offen'
}
// Dedup: schon gestellte Fragen erkennen (Generator wiederholt sich auf kleinen Bausteinen).
function normFrage(t) { return (t || '').toLowerCase().replace(/\s+/g, ' ').trim() }
function gestellteFragen() {
return new Set(pruefMessages.value.filter((m) => m.kind === 'frage').map((m) => normFrage(m.content)))
}
// --- Fragen-Pool: ZIEL vorformulierte Frage-OBJEKTE der aktiven Form (im Slot) ---
// Jedes Objekt: {form, frage, optionen?|satz?|loesung?|alternativen?}. Wechselt die
// aktive Form, wird der Pool geleert und für die neue Form neu gefüllt.
const POOL_ZIEL = 5
const pool = slot.pool
const inflight = slot.inflight
const pruefFehler = ref('') // Fehlerhinweis für Quiz/Lückentext (kein Bubble-Verlauf)
let poolMiss = 0 // nur Fallback: aufeinanderfolgende Dubletten/Fehler — Cap gegen Endlosschleife
// Muster-Modus aktiv, sobald geladen und Muster vorhanden; sonst Fallback (nur Erklären).
const musterModus = computed(() => musterGeladen.value && musterQuelle.value.length > 0)
function objText(o) { return (o && (o.frage || o.satz)) || '' }
// Lückensatz rendern: an „___" splitten, Teile inline (mit $…$-Mathe), Lücke als Span.
// So bleibt die Lücke erhalten und Markdown fasst die Unterstriche nicht an.
function lueckSatzHtml(satz) {
satz = String(satz || '')
const m = satz.match(/_{3,}/) // erste Lücke (drei oder mehr Unterstriche)
if (!m) return renderMarkdownInline(satz)
// Liegt die Lücke INNERHALB einer $…$-Formel (ungerade Zahl $ davor)? Dann nicht splitten
// (das zerreißt die Formel) — stattdessen durch eine KaTeX-Linie ersetzen, Satz am Stück rendern.
const inFormel = ((satz.slice(0, m.index).match(/\$/g) || []).length % 2) === 1
if (inFormel) return renderMarkdownInline(satz.replace(/_{3,}/, '\\rule{2.5em}{0.4pt}'))
// Lücke im Text: splitten + Lücken-Span.
return satz.split(/_{3,}/).map((t) => renderMarkdownInline(t)).join('<span class="bp-luecke">______</span>')
}
function istBekannt(o) {
const n = normFrage(objText(o))
return !n || gestellteFragen().has(n) || pool.value.some((f) => normFrage(objText(f)) === n)
}
// Schon gestellte + vorgemerkte Fragen — nur Fallback-Modus (Erklären ohne Muster).
function vermeideListe() {
const gestellt = pruefMessages.value.filter((m) => m.kind === 'frage').map((m) => m.content)
return [...gestellt, ...pool.value.map(objText)]
}
// Muster-Sidecar einmal je Baustein laden (Slot-cached). Leer → Fallback.
async function musterLaden() {
if (musterGeladen.value) return
try {
const res = await fetchFrageMuster(props.topic, props.baustein)
musterQuelle.value = (res.muster || []).map((m) => (m.frage || '').trim()).filter(Boolean)
} catch {
musterQuelle.value = []
}
musterGeladen.value = true
}
function mischen(arr) { // Fisher-Yates
const a = [...arr]
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[a[i], a[j]] = [a[j], a[i]]
}
return a
}
// Ein Muster ohne Zurücklegen ziehen; Pool leer → Reset (neu mischen). null = Fallback.
function musterNehmen() {
if (!musterQuelle.value.length) return null
if (!musterPool.value.length) musterPool.value = mischen(musterQuelle.value)
return musterPool.value.shift()
}
// Pool-Schlüssel = Form + Schwierigkeit; im Zufallband ein gemischter Pool ('zufall').
const poolKey = computed(() => imZufall.value ? 'zufall' : `${aktiveForm.value}/${schwer.value}`)
// Modus der nächsten Frage: im Zufallband (Score 1930) zufällig aus allen 5 Modi.
function naechsterModus() {
return imZufall.value ? MODI[Math.floor(Math.random() * MODI.length)] : aktiverModus.value
}
// Ein Frage-Objekt des aktiven Modus generieren (aus gezogener Saat; Erklären hat Fallback).
function frageEinzeln() {
const modus = naechsterModus()
const form = modus.startsWith('quiz') ? 'quiz' : modus.startsWith('lueck') ? 'lueckentext' : 'erklaeren'
const istSchwer = modus.endsWith('_schwer')
const muster = musterNehmen()
const basis = {
topic: props.topic, baustein: props.baustein, section: props.section,
section_kompakt: props.sectionKompakt, provider: props.provider,
}
if (form === 'quiz' && muster) {
return pruefeBaustein({ ...basis, aktion: 'quiz_frage', muster, schwer: istSchwer })
.then((r) => ({ form, schwer: istSchwer, frage: r.frage, optionen: r.optionen }))
}
if (form === 'lueckentext' && muster) {
return pruefeBaustein({ ...basis, aktion: 'lueck_frage', muster, schwer: istSchwer })
.then((r) => istSchwer
? ({ form, schwer: true, frage: r.satz, satz: r.satz, loesung: r.loesung, alternativen: r.alternativen })
: ({ form, schwer: false, frage: r.satz, satz: r.satz, optionen: r.optionen }))
}
if (muster) { // erklaeren aus Muster
return pruefeBaustein({ ...basis, aktion: 'frage', muster }).then((r) => ({ form: 'erklaeren', frage: r.frage }))
}
// Fallback (kein Muster-Sidecar): Erklären live mit Vermeide-Liste.
return pruefeBaustein({ ...basis, aktion: 'frage', messages: pruefDialog(), vermeide: vermeideListe() })
.then((r) => ({ form: 'erklaeren', frage: r.frage }))
}
// Pool leeren, wenn Form oder Schwierigkeit gewechselt hat (Bandgrenze oder ALT+E).
function poolFormPruefen() {
if (poolForm.value !== poolKey.value) {
pool.value = []
poolForm.value = poolKey.value
poolMiss = 0
}
}
// Pool PARALLEL auffüllen: jeder zieht ein DISTINKTES Muster → keine Kollision.
function poolFuellen() {
poolFormPruefen()
const keyBeginn = poolKey.value
const missCap = musterModus.value ? Infinity : 3
while (pool.value.length + inflight.value < POOL_ZIEL && poolMiss < missCap) {
inflight.value++
frageEinzeln()
.then((obj) => {
if (obj && objText(obj) && poolKey.value === keyBeginn && (musterModus.value || !istBekannt(obj))) { pool.value.push(obj); poolMiss = 0 }
else poolMiss++
})
.catch(() => { poolMiss++ })
.finally(() => { inflight.value--; poolFuellen() })
}
}
// Ein Frage-Objekt aus dem Pool nehmen; ist er leer, auf das erste eintreffende warten.
async function poolNehmen() {
if (!pool.value.length) {
const missCap = musterModus.value ? Infinity : 3
pruefLoading.value = true
try {
while (!pool.value.length && (inflight.value > 0 || poolMiss < missCap)) {
if (!inflight.value) poolFuellen()
await new Promise((r) => setTimeout(r, 120))
}
} finally { pruefLoading.value = false }
}
return pool.value.length ? pool.value.shift() : null
}
// Ein gezogenes Objekt der passenden Form anzeigen (Erklären-Bubble bzw. Quiz/Lück-Widget).
function frageObjZeigen(obj) {
pruefFehler.value = ''
anzeigeForm.value = obj.form // Anzeige an die gezogene Frage binden (Form einfrieren)
anzeigeBand.value = aktuellesBand.value // Band zum Zieh-Zeitpunkt (vor der Antwort)
if (obj.form === 'quiz') {
quizAktuell.value = { frage: obj.frage, optionen: obj.optionen, schwer: obj.schwer, gewaehlt: [], fertig: false, punkte: null, bewertung: null, feedback: '' }
} else if (obj.form === 'lueckentext') {
lueckAktuell.value = { satz: obj.satz, schwer: obj.schwer, optionen: obj.optionen || null, loesung: obj.loesung || '', alternativen: obj.alternativen || [], eingabe: '', gewaehlt: [], fertig: false, punkte: null, bewertung: null, feedback: '' }
if (obj.schwer) nextTick(() => document.getElementById('bp-lueck-input')?.focus())
} else {
frageZeigen(obj.frage)
nextTick(() => pruefInputEl.value?.focus())
}
}
// Erste / nächste Frage: Muster laden, Pool füllen, schnellste zuerst zeigen.
async function naechsteFrageZeigen() {
if (pruefLoading.value) return
poolMiss = 0
pruefFehler.value = ''
await musterLaden()
poolFuellen()
const obj = await poolNehmen()
if (obj) {
frageObjZeigen(obj)
pruefScroll()
} else if (aktiveForm.value === 'erklaeren') {
pruefMessages.value.push({ role: 'assistant', kind: 'fehler', content: 'Keine Frage — bitte erneut.' })
} else {
pruefFehler.value = 'Keine Frage — bitte erneut.'
}
poolFuellen()
}
const frageAnfordern = naechsteFrageZeigen
// Meilenstein überschritten: Einfrieren lösen (Ergebnis war sichtbar), ohne neue Frage zu
// ziehen → Leerlauf des neuen Bands („Frage anfordern").
function zumNaechstenAbschnitt() {
anzeigeForm.value = null
anzeigeBand.value = null
quizAktuell.value = null
lueckAktuell.value = null
pruefMessages.value = [] // alten Erklären-Verlauf verwerfen (neuer Abschnitt)
pruefPhase.value = 'idle'
pruefFehler.value = ''
}
// --- Quiz: leicht = Single-Select (genau 1) · schwer = Multi-Select. Deterministisch. ---
function quizToggle(i) {
const q = quizAktuell.value
if (!q || q.fertig) return
if (!q.schwer) { q.gewaehlt = q.gewaehlt.includes(i) ? [] : [i]; return }
const idx = q.gewaehlt.indexOf(i)
if (idx >= 0) q.gewaehlt.splice(idx, 1)
else q.gewaehlt.push(i)
}
async function quizAntworten() {
const q = quizAktuell.value
if (!q || q.fertig || pruefLoading.value) return
pruefLoading.value = true
pruefFehler.value = ''
try {
const korrekt = q.optionen.map((o, i) => (o.korrekt ? i : -1)).filter((i) => i >= 0)
const res = await pruefeBaustein({
topic: props.topic, baustein: props.baustein, section: props.section, section_kompakt: props.sectionKompakt,
provider: props.provider, aktion: 'quiz_antwort', frage: q.frage, cap: props.cap,
auswahl: q.gewaehlt, korrekt, schwer: q.schwer,
})
q.fertig = true; q.punkte = res.punkte; q.bewertung = res.bewertung; q.feedback = res.feedback
applyPruefung(res)
} catch {
pruefFehler.value = 'Hat nicht geklappt — bitte erneut.'
} finally {
pruefLoading.value = false
}
}
// --- Lückentext: leicht = Begriff aus 4 (Single-Select) · schwer = frei tippen ---
function lueckToggle(i) {
const l = lueckAktuell.value
if (!l || l.fertig || l.schwer) return
l.gewaehlt = l.gewaehlt.includes(i) ? [] : [i]
}
async function lueckAntworten() {
const l = lueckAktuell.value
if (!l || l.fertig || pruefLoading.value) return
if (l.schwer ? !l.eingabe.trim() : !l.gewaehlt.length) return
pruefLoading.value = true
pruefFehler.value = ''
try {
const spez = l.schwer
? { schwer: true, loesung: l.loesung, alternativen: l.alternativen, eingabe: l.eingabe }
: { schwer: false, auswahl: l.gewaehlt, korrekt: l.optionen.map((o, i) => (o.korrekt ? i : -1)).filter((i) => i >= 0) }
const res = await pruefeBaustein({
topic: props.topic, baustein: props.baustein, section: props.section, section_kompakt: props.sectionKompakt,
provider: props.provider, aktion: 'lueck_antwort', frage: l.satz, cap: props.cap, ...spez,
})
l.fertig = true; l.punkte = res.punkte; l.bewertung = res.bewertung; l.feedback = res.feedback
applyPruefung(res)
} catch {
pruefFehler.value = 'Hat nicht geklappt — bitte erneut.'
} finally {
pruefLoading.value = false
}
}
function nachfragen() {
const text = pruefInput.value.trim()
if (!text || pruefLoading.value) return
nachgefragt.value = true // Hilfe genutzt → Gewinn für diese Frage max +1
pruefMessages.value.push({ role: 'user', kind: 'nachfrage', content: text })
pruefInput.value = ''
pruefSenden(
{ aktion: 'diskussion', frage: aktuelleFrage.value, letzte_bewertung: letztesFeedback.value },
(res) => pruefMessages.value.push({ role: 'assistant', kind: 'diskussion', content: res.reply }),
)
}
let letzteFeedbackMsg = null // zuletzt gezeigte Bewertungs-Bubble
let pruefenRun = 0 // nur die jüngste Schnell-Bewertung darf anzeigen
function bewertungPayload() {
return { frage: aktuelleFrage.value, cap: props.cap, nachgefragt: nachgefragt.value }
}
// Agent 1 (schnell): sofortiges Niveau + Punkte zeigen, dann Agent 2 (genau) im Hintergrund.
async function schnellBewerten() {
const meine = ++pruefenRun
pruefStick.value = true
pruefLoading.value = true
pruefScroll()
try {
const res = await pruefeBaustein({
topic: props.topic, baustein: props.baustein, section: props.section, section_kompakt: props.sectionKompakt,
provider: props.provider, messages: pruefDialog(), aktion: 'antwort', ...bewertungPayload(),
})
if (meine !== pruefenRun) return
// Agent 1 ändert den Score NICHT — nur Feedback + voraussichtliche Punkte zeigen.
letztesFeedback.value = res.feedback || ''
pruefMessages.value.push({ role: 'assistant', kind: 'feedback', content: res.feedback || '', bewertung: res.bewertung, punkte: res.punkte, geprueft: false })
letzteFeedbackMsg = pruefMessages.value[pruefMessages.value.length - 1]
pruefPhase.value = 'bewertet'
pruefScroll()
nextTick(() => pruefInputEl.value?.focus())
genauBewerten() // Agent 2 wertet verbindlich (setzt den Score)
} catch {
if (meine === pruefenRun) pruefMessages.value.push({ role: 'assistant', kind: 'fehler', content: 'Hat nicht geklappt — bitte erneut.' })
} finally {
if (meine === pruefenRun) pruefLoading.value = false
}
}
// Agent 2 (genau): Bewerter + Kritiker. Korrigiert Bubble + Score (Server-Wahrheit, immer übernehmen).
// gruendlich=true: starkes Modell, optional mit Begründung des Lerners.
async function genauBewerten(gruendlich = false, begruendung = '') {
const ziel = letzteFeedbackMsg
if (gruendlich) pruefLoading.value = true
try {
const res = await pruefeBaustein({
topic: props.topic, baustein: props.baustein, section: props.section, section_kompakt: props.sectionKompakt,
provider: props.provider, messages: pruefDialog(), aktion: 'antwort_pruefen', ...bewertungPayload(), gruendlich, begruendung,
})
applyPruefung(res)
if (ziel) {
ziel.content = res.feedback || ziel.content
ziel.bewertung = res.bewertung
ziel.punkte = res.punkte
ziel.geprueft = true
}
} catch {
if (ziel) ziel.geprueft = true
} finally {
if (gruendlich) pruefLoading.value = false
}
}
function antwortAbgeben() {
const text = pruefInput.value.trim()
if (!text || pruefLoading.value) return
pruefMessages.value.push({ role: 'user', kind: 'antwort', content: text })
pruefInput.value = ''
schnellBewerten()
}
// Lief schon eine Diskussion? Dann darf der Verlauf direkt bewertet werden.
const hatDiskussion = computed(() => pruefMessages.value.some((m) => m.kind === 'nachfrage' || m.kind === 'diskussion'))
// „Verlauf bewerten": den bisherigen Dialog als Antwort werten (Feld wird ignoriert).
function verlaufBewerten() {
if (pruefLoading.value) return
schnellBewerten()
}
// Rechtsklick auf eine Bewertung → Gründlich-Feld öffnen; Absenden re-bewertet (mit Begründung).
function gruendlichOeffnen(msg) {
gruendlichText.value = ''
gruendlichMsg.value = msg
nextTick(() => document.getElementById('bp-gruendlich-input')?.focus())
}
function gruendlichAbsenden() {
if (pruefLoading.value) return
letzteFeedbackMsg = gruendlichMsg.value || letzteFeedbackMsg
const text = gruendlichText.value.trim()
gruendlichMsg.value = null
genauBewerten(true, text)
}
function punkteLabel(p) {
if (p == null) return ''
return p > 0 ? `+${p}` : p < 0 ? String(p) : '±0'
}
// Abbruch wie im Chat: Run-Counter hochzählen → laufendes Ergebnis (pruefSenden
// und schnellBewerten) wird verworfen, Buttons sofort frei. Agent läuft serverseitig aus.
function pruefAbbrechen() {
if (!pruefLoading.value) return
pruefRun++
pruefenRun++
pruefLoading.value = false
pruefMessages.value.push({ role: 'assistant', kind: 'fehler', content: 'Abgebrochen.' })
}
// ESC bricht ab — window-Listener, da nach Button-Klick der Fokus das Panel verlässt.
function onWindowKey(e) {
if (e.key === 'Escape' && pruefLoading.value) { e.preventDefault(); pruefAbbrechen() }
}
onMounted(() => {
if (props.mode !== 'full') return // Trigger-Modus (Liste): nur Tab-Leiste, keine Prüfungs-/Text-Logik
window.addEventListener('keydown', onWindowKey)
window.addEventListener('keydown', onPruefKey)
musterLaden() // GET (kein Agent) — setzt die aktive Form früh korrekt (Quiz/Lückentext/Erklären)
})
onUnmounted(() => {
window.removeEventListener('keydown', onWindowKey)
window.removeEventListener('keydown', onPruefKey)
})
// ALT+E: leicht↔schwer der Form (Quiz/Lückentext). Im Erklären- und Zufallband ohne Wirkung.
function variantWechseln() {
if (imZufall.value) return
if (aktiveForm.value !== 'quiz' && aktiveForm.value !== 'lueckentext') return
schwerWahl.value = !schwerWahl.value
// Einfrieren + altes Widget verwerfen → frische Frage der neuen Variante anfordern.
anzeigeForm.value = null
anzeigeBand.value = null
quizAktuell.value = null
lueckAktuell.value = null
}
// Quiz / Lückentext-Auswahl ist ein Wahl-Widget mit 14-Tasten; sonst Textfeld/Bubble.
const wahlWidget = computed(() => {
if (zeigeForm.value === 'quiz') return quizAktuell.value
if (zeigeForm.value === 'lueckentext' && lueckAktuell.value && !lueckAktuell.value.schwer) return lueckAktuell.value
return null
})
// Nach einer Antwort: bei Meilenstein-Überschreitung in den neuen Abschnitt, sonst nächste Frage.
function weiterAktion() {
if (abschnittWechsel.value) zumNaechstenAbschnitt()
else naechsteFrageZeigen()
}
// Primäre Aktion je Form: keine Frage → anfordern · offen → antworten · fertig → weiter.
function primaerAktion() {
if (pruefLoading.value) return
const f = zeigeForm.value
if (f === 'quiz') { if (!quizAktuell.value) frageAnfordern(); else if (!quizAktuell.value.fertig) quizAntworten(); else weiterAktion(); return }
if (f === 'lueckentext') { if (!lueckAktuell.value) frageAnfordern(); else if (!lueckAktuell.value.fertig) lueckAntworten(); else weiterAktion(); return }
if (pruefPhase.value === 'idle') frageAnfordern()
else if (pruefPhase.value === 'frage_offen') { if (pruefInput.value.trim()) antwortAbgeben() }
else weiterAktion()
}
// 14 (nackt): Option im Wahl-Widget toggeln. Alt allein: ins Eingabefeld springen.
// Alt+2 primär (antworten/nächste) · Alt+Q Ansicht · Alt+E Variante · Alt+1/3 Erklären.
function onPruefKey(e) {
if (e.repeat) return
if (props.mode !== 'full' || activeTab.value !== 'pruefung') return
// Alt allein toggelt der Fokus (BausteinFokus lone-tap) → hier NICHT fokussieren,
// sonst kämpfen keydown-Fokus (hier) und keyup-Toggle (Fokus) → „nur beim Halten".
if (e.key === 'Alt') return
if (!e.altKey) {
// Nackte 14: Wahl-Widget toggeln — aber nicht, während ein Textfeld den Fokus hat.
if (['1', '2', '3', '4'].includes(e.key) && wahlWidget.value && !wahlWidget.value.fertig) {
const ae = document.activeElement
if (ae && (ae.tagName === 'INPUT' || ae.tagName === 'TEXTAREA')) return
e.preventDefault()
const i = Number(e.key) - 1
if (i < wahlWidget.value.optionen.length) (zeigeForm.value === 'quiz' ? quizToggle : lueckToggle)(i)
}
return
}
if (e.code === 'KeyQ') {
e.preventDefault()
emit('setAnsicht', props.ansicht === 'kompakt' ? 'erklärend' : 'kompakt')
return
}
if (e.code === 'KeyE') { e.preventDefault(); variantWechseln(); return }
if (e.key === '2') { e.preventDefault(); primaerAktion(); return }
// Erklären-Sondertasten
if (e.key === '1') { e.preventDefault(); if (zeigeForm.value === 'erklaeren' && pruefPhase.value === 'frage_offen' && pruefInput.value.trim()) nachfragen(); return }
if (e.key === '3') { e.preventDefault(); if (zeigeForm.value === 'erklaeren' && pruefPhase.value === 'frage_offen' && hatDiskussion.value) verlaufBewerten() }
}
</script>
<template>
<div class="bp">
<div class="bp-toggles">
<!-- Ansicht-Umschalter (nur Vollbild): steuert die linke Guide-Spalte, links vor den Tabs -->
<div v-if="mode === 'full'" class="bp-ansicht">
<button :class="{ active: ansicht === 'kompakt' }" title="Merksätze" @click="emit('setAnsicht', 'kompakt')">Kompakt</button>
<button :class="{ active: ansicht === 'erklärend' }" title="Ausführliche Erklärung" @click="emit('setAnsicht', 'erklärend')">Erklärend</button>
</div>
<button :class="{ active: activeTab === 'chat' }" @click="tabKlick('chat')">
Chat
</button>
<button :class="{ active: activeTab === 'pruefung' }" @click="tabKlick('pruefung')">
Prüfung
<span v-if="stufe" class="bp-chip" :style="{ borderColor: stufe.farbe, color: stufe.farbe }" :title="`${stufe.label} (${cap})`">{{ stufe.kurz }} {{ scoreAnzeige }}/{{ cap }}</span>
<span v-else-if="score" class="bp-chip">{{ scoreAnzeige }}/{{ cap }}</span>
</button>
</div>
<div v-if="mode === 'full' && activeTab" class="bp-panel">
<!-- Bausteinchat -->
<div v-if="activeTab === 'chat'">
<div :ref="chat.messagesEl" class="bp-messages" @scroll="chat.onScroll">
<p v-if="!chat.messages.value.length" class="bp-hint">Frag etwas zu diesem Baustein. Der Verlauf wird nicht gespeichert.</p>
<template v-for="(m, i) in chat.messages.value" :key="i">
<div v-if="m.role === 'assistant'" class="bp-msg assistant markdown" v-html="renderMarkdown(m.content)"></div>
<div v-else class="bp-msg user">{{ m.content }}</div>
</template>
<div v-if="chat.loading.value" class="bp-msg assistant bp-typing">Denkt</div>
</div>
<div class="bp-input">
<textarea
:ref="chat.inputEl"
v-model="chat.input.value"
rows="2"
placeholder="Frage zum Baustein…"
@keydown.enter.exact.prevent="chat.send"
></textarea>
<button :disabled="!chat.input.value.trim() && !chat.loading.value" :class="{ cancel: chat.loading.value }" @click="chat.send">
{{ chat.loading.value ? '' : '' }}
</button>
</div>
</div>
<!-- Prüfung: gesteuerter Dialog -->
<div v-else>
<p class="bp-hint">
<template v-if="amDeckel">{{ FORM_NAME[zeigeForm] }} · {{ scoreAnzeige }}/{{ cap }} <strong>Max</strong>.</template>
<template v-else-if="imZufall && !anzeigeForm">Zufällig · {{ scoreAnzeige }}/{{ cap }}</template>
<template v-else-if="imZufall">{{ FORM_NAME[zeigeForm] }} · {{ scoreAnzeige }}/{{ cap }} · {{ modusRegel }}</template>
<template v-else>{{ FORM_NAME[zeigeForm] }} · {{ score }}/{{ bandZiel }} · {{ modusRegel }}</template>
</p>
<button
v-if="!amDeckel && !imZufall && zeigeForm !== 'erklaeren'"
class="bp-variant"
title="Alt+E"
@click="variantWechseln"
>{{ aktSchwer ? 'Schwer' : 'Leicht' }} · wechseln</button>
<!-- Quiz: Frage + Mehrfachauswahl (Widget bleibt auch am Deckel Üben ohne Punkte) -->
<template v-if="zeigeForm === 'quiz'">
<div v-if="!quizAktuell" class="bp-actions">
<button v-if="pruefLoading" class="bp-action cancel" title="ESC" @click="pruefAbbrechen">Abbrechen</button>
<button v-else class="bp-action primary" @click="frageAnfordern">Frage anfordern</button>
</div>
<div v-else class="bp-quiz">
<p class="bp-quiz-frage markdown" v-html="renderMarkdownInline(quizAktuell.frage)"></p>
<div class="bp-quiz-grid">
<button
v-for="(o, i) in quizAktuell.optionen" :key="i"
type="button"
class="bp-quiz-opt"
:class="{ gewaehlt: quizAktuell.gewaehlt.includes(i), richtig: quizAktuell.fertig && o.korrekt, falsch: quizAktuell.fertig && !o.korrekt && quizAktuell.gewaehlt.includes(i) }"
:disabled="quizAktuell.fertig"
:title="`${i + 1}`"
@click="quizToggle(i)"
>
<span class="markdown" v-html="renderMarkdownInline(o.text)"></span>
</button>
</div>
<p v-if="pruefFehler" class="bp-error">{{ pruefFehler }}</p>
<div class="bp-actions">
<button v-if="!quizAktuell.fertig" class="bp-action primary" title="Alt+2" :disabled="pruefLoading" @click="quizAntworten">Antworten</button>
<template v-else>
<span class="bp-niveau" :class="quizAktuell.bewertung">{{ punkteLabel(quizAktuell.punkte) }}</span>
<span class="bp-form-feedback">{{ quizAktuell.feedback }}</span>
<button v-if="abschnittWechsel" class="bp-action primary" @click="zumNaechstenAbschnitt">Zum nächsten Abschnitt</button>
<button v-else class="bp-action primary" @click="naechsteFrageZeigen">Nächste</button>
</template>
</div>
</div>
</template>
<!-- Lückentext: Satz mit Lücke + Eingabe -->
<template v-else-if="zeigeForm === 'lueckentext'">
<div v-if="!lueckAktuell" class="bp-actions">
<button v-if="pruefLoading" class="bp-action cancel" title="ESC" @click="pruefAbbrechen">Abbrechen</button>
<button v-else class="bp-action primary" @click="frageAnfordern">Frage anfordern</button>
</div>
<div v-else class="bp-lueck">
<p class="bp-lueck-satz markdown" v-html="lueckSatzHtml(lueckAktuell.satz)"></p>
<!-- schwer: frei tippen -->
<input
v-if="lueckAktuell.schwer"
id="bp-lueck-input"
v-model="lueckAktuell.eingabe"
:disabled="lueckAktuell.fertig"
placeholder="Begriff für die Lücke…"
@keyup.enter="lueckAntworten"
/>
<p v-if="lueckAktuell.schwer && lueckAktuell.fertig && !lueckAktuell.feedback.startsWith('Richtig')" class="bp-lueck-loesung">Lösung: <span class="markdown" v-html="renderMarkdownInline(lueckAktuell.loesung)"></span></p>
<!-- leicht: Begriff aus 4 wählen -->
<div v-if="!lueckAktuell.schwer" class="bp-quiz-grid">
<button
v-for="(o, i) in lueckAktuell.optionen" :key="i"
type="button"
class="bp-quiz-opt"
:class="{ gewaehlt: lueckAktuell.gewaehlt.includes(i), richtig: lueckAktuell.fertig && o.korrekt, falsch: lueckAktuell.fertig && !o.korrekt && lueckAktuell.gewaehlt.includes(i) }"
:disabled="lueckAktuell.fertig"
:title="`${i + 1}`"
@click="lueckToggle(i)"
>
<span class="markdown" v-html="renderMarkdownInline(o.text)"></span>
</button>
</div>
<p v-if="pruefFehler" class="bp-error">{{ pruefFehler }}</p>
<div class="bp-actions">
<button v-if="!lueckAktuell.fertig" class="bp-action primary" title="Alt+2" :disabled="pruefLoading || (lueckAktuell.schwer ? !lueckAktuell.eingabe.trim() : !lueckAktuell.gewaehlt.length)" @click="lueckAntworten">Antworten</button>
<template v-else>
<span class="bp-niveau" :class="lueckAktuell.bewertung">{{ punkteLabel(lueckAktuell.punkte) }}</span>
<span class="bp-form-feedback">{{ lueckAktuell.feedback }}</span>
<button v-if="abschnittWechsel" class="bp-action primary" @click="zumNaechstenAbschnitt">Zum nächsten Abschnitt</button>
<button v-else class="bp-action primary" @click="naechsteFrageZeigen">Nächste</button>
</template>
</div>
</div>
</template>
<!-- Erklären: gesteuerter Dialog (bestehend) -->
<template v-else>
<div v-if="pruefMessages.length" ref="pruefMessagesEl" class="bp-messages" @scroll="onPruefScroll">
<template v-for="(m, i) in pruefMessages" :key="i">
<div v-if="m.kind === 'feedback'" class="bp-feedback" :class="m.bewertung" title="Klick: gründlich prüfen" @click="gruendlichOeffnen(m)">
<span v-if="m.punkte != null" class="bp-niveau">{{ punkteLabel(m.punkte) }}</span>{{ m.content }}<span v-if="!m.geprueft" class="bp-pruefend"> · wird geprüft</span>
<div v-if="gruendlichMsg === m" class="bp-gruendlich" @click.stop>
<input id="bp-gruendlich-input" v-model="gruendlichText" placeholder="Warum unzufrieden? (optional)" @keyup.enter="gruendlichAbsenden" />
<button class="bp-action primary" @click="gruendlichAbsenden">Gründlich prüfen</button>
<button class="bp-action" @click="gruendlichMsg = null">×</button>
</div>
</div>
<div v-else-if="m.kind === 'fehler'" class="bp-error">{{ m.content }}</div>
<div v-else-if="m.role === 'assistant'" class="bp-msg assistant markdown" v-html="renderMarkdown(m.content)"></div>
<div v-else class="bp-msg user">{{ m.content }}</div>
</template>
<div v-if="pruefLoading" class="bp-msg assistant bp-typing"></div>
</div>
<div v-if="pruefPhase === 'idle'" class="bp-actions">
<button v-if="pruefLoading" class="bp-action cancel" title="ESC" @click="pruefAbbrechen">Abbrechen</button>
<button v-else class="bp-action primary" @click="frageAnfordern">Frage anfordern</button>
</div>
<template v-else>
<div v-if="pruefPhase === 'frage_offen'" class="bp-input">
<textarea
ref="pruefInputEl"
v-model="pruefInput"
rows="2"
placeholder="Antwort — oder Nachfrage bei Unklarheit…"
></textarea>
</div>
<div class="bp-actions">
<button v-if="pruefLoading" class="bp-action cancel" title="ESC" @click="pruefAbbrechen">Abbrechen</button>
<template v-else-if="pruefPhase === 'frage_offen'">
<button class="bp-action" title="Alt+1" :disabled="!pruefInput.trim()" @click="nachfragen"><span class="bp-kbd">1</span>Nachfragen</button>
<button class="bp-action primary" title="Alt+2" :disabled="!pruefInput.trim()" @click="antwortAbgeben"><span class="bp-kbd">2</span>Antwort abgeben</button>
<button v-if="hatDiskussion" class="bp-action" title="Alt+3" @click="verlaufBewerten"><span class="bp-kbd">3</span>Verlauf bewerten</button>
</template>
<template v-else>
<button v-if="abschnittWechsel" class="bp-action primary" title="Alt+2" @click="zumNaechstenAbschnitt"><span class="bp-kbd">2</span>Zum nächsten Abschnitt</button>
<button v-else class="bp-action primary" title="Alt+2" @click="naechsteFrageZeigen"><span class="bp-kbd">2</span>Nächste Frage</button>
</template>
</div>
</template>
</template>
</div>
</div>
</div>
</template>
<style scoped>
.bp { margin-top: 0.75rem; }
.bp-toggles { display: flex; gap: 0.4rem; }
.bp-toggles button {
display: inline-flex; align-items: center; gap: 0.35rem;
padding: 0.25rem 0.7rem;
font-size: 0.8rem;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--panel-soft);
color: var(--text-muted);
cursor: pointer;
}
.bp-toggles button:hover { border-color: var(--border-strong); color: var(--text); }
.bp-toggles button.active { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
/* Ansicht-Umschalter (Kompakt/Erklärend) — segmentiert, links vor den Tabs */
.bp-ansicht { display: inline-flex; margin-right: 0.5rem; }
.bp-ansicht button {
padding: 0.25rem 0.6rem;
font-size: 0.75rem;
border: 1px solid var(--border);
background: var(--panel-soft);
color: var(--text-muted);
cursor: pointer;
}
.bp-ansicht button:first-child { border-radius: 999px 0 0 999px; }
.bp-ansicht button:last-child { border-radius: 0 999px 999px 0; }
.bp-ansicht button + button { border-left: none; }
.bp-ansicht button:hover { color: var(--text); }
.bp-ansicht button.active { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
/* Schwierigkeits-Umschalter (Alt+E) — eigener Button, mobil tippbar */
.bp-variant {
margin: 0 0 0.6rem;
padding: 0.2rem 0.7rem;
font-size: 0.72rem;
border: 1px solid var(--border-strong);
border-radius: 999px;
background: var(--panel-soft);
color: var(--text-muted);
cursor: pointer;
}
.bp-variant:hover { border-color: var(--accent); color: var(--text); }
.bp-chip {
font-size: 0.7rem;
padding: 0 0.35rem;
border-radius: 999px;
background: var(--panel);
border: 1px solid var(--border);
color: var(--text-muted);
}
.bp-chip.done { background: var(--success-soft); border-color: var(--success-border); color: var(--success); }
.bp-chip.lila { background: color-mix(in srgb, #8b5cf6 16%, var(--panel)); border-color: #8b5cf6; color: #6d28d9; }
.bp-chip.gold { background: color-mix(in srgb, #d4af37 20%, var(--panel)); border-color: #d4af37; color: #8a6d12; }
.bp-panel {
margin-top: 0.6rem;
padding: 0.75rem 0.9rem;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--panel-soft);
}
.bp-hint { font-size: 0.85rem; color: var(--text-muted); margin: 0 0 0.5rem; }
.bp-hint-key { font-size: 0.72rem; opacity: 0.7; white-space: nowrap; }
.bp-error { font-size: 0.85rem; color: var(--danger); margin: 0.5rem 0 0; }
.bp-action {
margin-top: 0.5rem;
padding: 0.3rem 0.8rem;
font-size: 0.8rem;
border: 1px solid var(--border-strong);
border-radius: 6px;
background: var(--panel);
color: var(--text);
cursor: pointer;
}
.bp-action:hover { border-color: var(--accent); }
.bp-action:disabled { opacity: 0.5; cursor: default; }
.bp-action.primary { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
.bp-action.primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
.bp-action.cancel { background: var(--danger); border-color: var(--danger); color: var(--on-accent); }
.bp-actions { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: 0.5rem; }
.bp-actions .bp-action { margin-top: 0; }
.bp-kbd {
display: inline-flex; align-items: center; justify-content: center;
min-width: 1rem; height: 1rem; padding: 0 0.2rem; margin-right: 0.35rem;
font-size: 0.65rem; font-weight: 600; border-radius: 3px;
background: var(--panel); border: 1px solid var(--border); color: var(--text-muted);
}
.bp-action.primary .bp-kbd { background: color-mix(in srgb, var(--on-accent) 20%, transparent); border-color: transparent; color: var(--on-accent); }
.bp-messages { display: flex; flex-direction: column; gap: 0.4rem; max-height: 320px; overflow-y: auto; }
.bp-msg {
max-width: 88%;
padding: 0.4rem 0.65rem;
border-radius: 10px;
font-size: 0.88rem;
line-height: 1.45;
overflow-wrap: anywhere;
}
.bp-msg.user { align-self: flex-end; background: var(--accent); color: var(--on-accent); white-space: pre-wrap; }
.bp-msg.assistant { align-self: flex-start; background: var(--panel); border: 1px solid var(--border); }
.bp-typing { color: var(--text-faint); font-style: italic; }
/* Bewertung der letzten Antwort — getrennt über der nächsten Frage */
.bp-feedback {
align-self: flex-start;
max-width: 88%;
padding: 0.3rem 0.6rem;
border-radius: 8px;
font-size: 0.82rem;
line-height: 1.4;
border: 1px solid var(--border);
}
.bp-pruefend { font-style: italic; opacity: 0.7; font-size: 0.92em; }
.bp-feedback { cursor: pointer; }
.bp-feedback.gut { background: var(--success-soft); border-color: var(--success-border); color: var(--success); }
.bp-feedback.neutral { background: var(--warning-soft); border-color: var(--warning-border); color: var(--warning); }
.bp-feedback.schlecht { background: var(--danger-soft, #fee2e2); border-color: var(--danger-border, #f87171); color: var(--danger); }
.bp-niveau { font-weight: 700; text-transform: uppercase; font-size: 0.62rem; letter-spacing: 0.03em; margin-right: 0.4rem; opacity: 0.85; }
.bp-niveau.gut { color: var(--success); }
.bp-niveau.neutral { color: var(--warning); }
.bp-niveau.schlecht { color: var(--danger); }
/* Quiz: Frage + Mehrfachauswahl */
.bp-quiz, .bp-lueck { margin-top: 0.6rem; }
.bp-quiz-frage, .bp-lueck-satz { font-size: 0.9rem; font-weight: 600; margin: 0 0 0.5rem; line-height: 1.5; }
.bp-luecke { font-weight: 700; letter-spacing: 1px; color: var(--accent); padding: 0 0.15rem; }
.bp-quiz-opt .markdown, .bp-quiz-frage.markdown, .bp-lueck-satz.markdown { display: inline; }
.bp-quiz-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; margin-bottom: 0.5rem; }
.bp-quiz-opt {
display: flex; gap: 0.45rem; align-items: flex-start; text-align: left;
padding: 0.5rem 0.6rem;
border: 1px solid var(--border); border-radius: 8px;
background: var(--panel); color: var(--text); cursor: pointer; font-size: 0.85rem;
}
.bp-quiz-opt:disabled { cursor: default; }
.bp-quiz-opt.gewaehlt { border-color: var(--accent); background: var(--accent-soft, rgba(99, 102, 241, 0.12)); }
.bp-quiz-opt.richtig { background: var(--success-soft); border-color: var(--success-border); }
.bp-quiz-opt.falsch { background: var(--danger-soft, #fee2e2); border-color: var(--danger-border, #f87171); }
.bp-form-feedback { font-size: 0.82rem; color: var(--text-muted); flex: 1; }
/* Lückentext: Satz + Eingabe */
.bp-lueck input {
width: 100%; box-sizing: border-box; padding: 0.45rem 0.6rem; font: inherit; font-size: 0.88rem;
border: 1px solid var(--border); border-radius: 8px; background: var(--panel); color: var(--text);
}
.bp-lueck input:disabled { opacity: 0.7; }
.bp-lueck-loesung { font-size: 0.82rem; color: var(--success); margin: 0.35rem 0 0; }
/* Gründlich-prüfen-Feld (per Rechtsklick) innerhalb der Bewertungs-Bubble */
.bp-gruendlich { display: flex; gap: 0.3rem; margin-top: 0.4rem; align-items: center; }
.bp-gruendlich input {
flex: 1; min-width: 0; padding: 0.3rem 0.5rem; font: inherit; font-size: 0.8rem;
border: 1px solid var(--border-strong); border-radius: 6px; background: var(--panel); color: var(--text);
}
.bp-gruendlich .bp-action { margin-top: 0; }
.bp-input { display: flex; gap: 0.4rem; margin-top: 0.55rem; align-items: flex-end; }
.bp-input textarea {
flex: 1;
resize: none;
padding: 0.45rem 0.6rem;
font: inherit;
font-size: 0.88rem;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--panel);
color: var(--text);
}
.bp-input button {
padding: 0.45rem 0.7rem;
border: none;
border-radius: 8px;
background: var(--accent);
color: var(--on-accent);
cursor: pointer;
}
.bp-input button:disabled { opacity: 0.5; cursor: default; }
.bp-input button.cancel { background: var(--danger); }
</style>