From 123f9d082c71ebd6c083a5a97760d0a20372ac78 Mon Sep 17 00:00:00 2001 From: Qognio Bot Extract Date: Wed, 29 Apr 2026 01:35:48 +0200 Subject: [PATCH] init: extract libra-handwerks-coach from qognio-bot-widget-template@d2c816f Source files (src/) and rendered bundle (www/) extracted on 2026-04-29T01:35:48+02:00. Adds nginx:alpine Dockerfile + docker-compose.yml (Caddy-labels) so the bot runs stand-alone or as a per-customer template clone. Parent monorepo commit: d2c816f3edbc9760802a11b29ff4151c7aad4b46 Bot version: 2026-04-25 --- .dockerignore | 7 + .gitignore | 4 + Dockerfile | 13 + README.md | 67 ++ bot.json | 14 + docker-compose.yml | 20 + nginx.conf | 27 + src/check-badges.js | 16 + src/config.yaml | 38 + src/curricula.json | 358 ++++++++ src/levels-fallback.js | 4 + src/welcome.html | 29 + www/app.js | 1747 ++++++++++++++++++++++++++++++++++++++++ www/curricula.json | 358 ++++++++ www/index.html | 125 +++ www/styles.css | 1038 ++++++++++++++++++++++++ 16 files changed, 3865 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 bot.json create mode 100644 docker-compose.yml create mode 100644 nginx.conf create mode 100644 src/check-badges.js create mode 100644 src/config.yaml create mode 100644 src/curricula.json create mode 100644 src/levels-fallback.js create mode 100644 src/welcome.html create mode 100644 www/app.js create mode 100644 www/curricula.json create mode 100644 www/index.html create mode 100644 www/styles.css diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9e02047 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +.git +.gitignore +README.md +bot.json +src/ +docker-compose.yml +*.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6758e09 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.DS_Store +*.log +*.tmp +node_modules/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1f9c43c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +# Static-bundle bot — nginx:alpine serves www/ on port 80. +FROM nginx:1.27-alpine + +# nginx config: gzip + cache headers + index.html no-store +COPY nginx.conf /etc/nginx/conf.d/default.conf + +# Static bundle +COPY www/ /usr/share/nginx/html/ + +# Run as non-root via nginx's built-in unprivileged image features +EXPOSE 80 +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget -q --spider http://127.0.0.1/index.html || exit 1 diff --git a/README.md b/README.md new file mode 100644 index 0000000..3555861 --- /dev/null +++ b/README.md @@ -0,0 +1,67 @@ +# LIBRA — Handwerks-Kalkulations-Coach + +LIBRA — der Kalkulations-Sparringspartner für SHK/Elektro/Maler/Tischler. Stundensätze, Materialaufschläge, Nachkalkulation. Im deutschen Bunker. + +``` +slug : libra-handwerks-coach +version : 2026-04-25 +accent : #d97706 +runtime : nginx:alpine (static bundle) +template : qognio-bot-template-core (former qognio-bot-widget-template) +``` + +## Layout + +``` +. +├── src/ source — config.yaml, welcome.html, curricula.json, etc. +├── www/ rendered, directly servable static bundle +├── Dockerfile nginx:alpine + www/ → port 80 +├── docker-compose.yml bot-host pattern (Caddy-labels, restart unless-stopped) +├── nginx.conf gzip + cache + SPA fallback +└── bot.json metadata + parent_core_commit +``` + +## Run locally + +```bash +docker compose up --build +# → http://localhost (you'll need to tweak ports for local-only use) +``` + +## Re-render after upstream core changes + +This repo only stores src + rendered output; the rendering engine lives in +`qognio-bot-template-core`. To pull in core changes: + +```bash +cd /path/to/qognio-bot-template-core +./scripts/render.sh libra-handwerks-coach --bot-repo /path/to/this/repo +git -C /path/to/this/repo commit -am "render: refresh from core@" +``` + +## Per-customer copy (template usage) + +This repo is a **template**. To clone for a customer: + +```bash +git clone my-customer-libra-handwerks-coach +cd my-customer-libra-handwerks-coach +# tweak src/config.yaml (slug, bot_key_value, accent), src/welcome.html, src/curricula.json +docker compose -f docker-compose.yml up --build +``` + +## Deploy to qognio bot-host (.42 LXC pattern — legacy) + +The bot-manager spawns LXC containers named after the slug. Push www/ via: + +```bash +ssh fmh@46.243.203.42 +sudo lxc file push /tmp/www/* libra-handwerks-coach/var/www/html/ +``` + +(Or run the docker-compose pattern on a Docker host — same network as Caddy.) + +--- + +Generated by `qognio-bot-template-core/scripts/extract-to-repo.sh` on 2026-04-29T01:35:48+02:00. diff --git a/bot.json b/bot.json new file mode 100644 index 0000000..c4b9327 --- /dev/null +++ b/bot.json @@ -0,0 +1,14 @@ +{ + "slug": "libra-handwerks-coach", + "name": "LIBRA", + "title": "Handwerks-Kalkulations-Coach", + "tagline": "Kalkulationscoach", + "description": "LIBRA — der Kalkulations-Sparringspartner für SHK/Elektro/Maler/Tischler. Stundensätze, Materialaufschläge, Nachkalkulation. Im deutschen Bunker.", + "version": "2026-04-25", + "accent": "#d97706", + "extracted_from": "qognio-bot-widget-template", + "parent_core_commit": "d2c816f3edbc9760802a11b29ff4151c7aad4b46", + "extracted_at": "2026-04-29T01:35:48+02:00", + "runtime": "nginx:alpine", + "default_port": 80 +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..21f8b2a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,20 @@ +# Stand-alone bot container. +# Designed for the "caddy" external network on the bot host (qognio pattern). +# Override the hostname via SLUG env var if you reuse this template per customer. +services: + bot: + build: . + image: qognio/bot-libra-handwerks-coach:${TAG:-latest} + container_name: bot-libra-handwerks-coach + restart: unless-stopped + networks: + - caddy + labels: + caddy: "libra-handwerks-coach.on.qognio.com" + caddy.reverse_proxy: "{{upstreams 80}}" + qognio.bot.slug: "libra-handwerks-coach" + qognio.bot.version: "2026-04-25" + +networks: + caddy: + external: true diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..30cda98 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,27 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + # gzip + gzip on; + gzip_vary on; + gzip_types text/css application/javascript application/json image/svg+xml text/plain; + gzip_min_length 512; + + # index.html: never cache (so welcome screen / wiring updates land instantly) + location = /index.html { + add_header Cache-Control "no-store, must-revalidate" always; + } + + # static assets: cache 1h + location ~* \.(?:css|js|json|svg|png|jpe?g|webp|gif|ico|woff2?)$ { + add_header Cache-Control "public, max-age=3600" always; + try_files $uri =404; + } + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/src/check-badges.js b/src/check-badges.js new file mode 100644 index 0000000..89b8f17 --- /dev/null +++ b/src/check-badges.js @@ -0,0 +1,16 @@ + // Erste Kalkulation — 1 Quiz im Lohnnebenkosten-Modul (Stundensatz-Curriculum) + if ((state.moduleCorrect && state.moduleCorrect['lohnnebenkosten'] >= 1)) unlockBadge('erste_kalkulation'); + // Gewerks-Meister — 5 Quiz korrekt im SHK-Modul (Kern-Gewerk) + if ((state.moduleCorrect && state.moduleCorrect['shk'] >= 5)) unlockBadge('gewerks_meister'); + // Marge-Optimierer — 3 Quiz korrekt im Soll-Ist-Vergleich-Modul (Nachkalkulation) + if ((state.moduleCorrect && state.moduleCorrect['soll-ist-vergleich'] >= 3)) unlockBadge('marge_optimierer'); + // Fallen-Kenner — 5 Quiz korrekt im Fallen-Allgemein-Modul + if ((state.moduleCorrect && state.moduleCorrect['fallen-allgemein'] >= 5)) unlockBadge('fallen_kenner'); + // LIBRA-Meister — 18 von 23 Modulen mit ≥80% Quiz-Score abgeschlossen (~78% Master-Coverage) + if ((state.completedCurricula || []).length >= 18) unlockBadge('libra_meister'); + // Streak-7 — 7-Tage-Streak + if (state.maxStreak >= 7) unlockBadge('streak_7'); + // Night Owl & Early Bird (beibehalten) + const h = new Date().getHours(); + if (h >= 22) unlockBadge('night_owl'); + if (h < 7) unlockBadge('early_bird'); \ No newline at end of file diff --git a/src/config.yaml b/src/config.yaml new file mode 100644 index 0000000..4a0e854 --- /dev/null +++ b/src/config.yaml @@ -0,0 +1,38 @@ +slug: libra-handwerks-coach +bot_name: LIBRA +bot_title: Handwerks-Kalkulations-Coach +brand_letter: L +title: "LIBRA · Dein Handwerks-Kalkulationscoach" +tagline: Handwerks-Kalkulationscoach +tagline_short: Kalkulationscoach +meta_description: "LIBRA — der Kalkulations-Sparringspartner für SHK/Elektro/Maler/Tischler. Stundensätze, Materialaufschläge, Nachkalkulation. Im deutschen Bunker." +bot_key_var: __LIBRA_KEY__ +bot_key_value: qb_pz7hy9gkl1du +ls_prefix: libra +bot_version: "2026-04-25" + +# Color theme — warm amber, distinct from Max (#f59e0b) +accent: "#d97706" +accent_2: "#f59e0b" +accent_dark: "#b45309" +accent_rgb: "217, 119, 6" +accent_rgb_compact: "217,119,6" +success_color: "#22c55e" +msg_strong_color: "#fcd34d" + +# UI Labels +tab_flash_label: Karten +tab_curriculum_label: Gewerke +curriculum_long_label: Gewerke-Bibliothek + +# Bot-personality strings +quiz_intro_hint: "Wähle ein Modul — LIBRA generiert Szenario-Fragen aus dem Handwerksalltag." +quiz_verb: erstellt +quiz_noun: "Szenario-Fragen" +flash_intro_hint: "Karteikarten zu Stundensatz-Formeln, Aufmaß, Gewerks-Spezifika — mit Spaced-Repetition." +flash_verb: generiert + +# Levels-fallback, welcome.html, check-badges.js are sibling files +# in this directory — render.sh injects their full contents into the +# corresponding {{LEVELS_FALLBACK}}, {{WELCOME_HTML}}, {{CHECK_BADGES_BODY}} +# placeholders verbatim. diff --git a/src/curricula.json b/src/curricula.json new file mode 100644 index 0000000..be6ee99 --- /dev/null +++ b/src/curricula.json @@ -0,0 +1,358 @@ +{ + "version": "2026-04-25", + "updated": "2026-04-25", + "curricula": [ + { + "id": "stundensatz", + "title": "1 · Stundensatz & Lohnnebenkosten", + "short": "Vollkostendeckender SVS bilden", + "icon": "clock", + "color": "#d97706", + "description": "Vom Bruttolohn zum vollkostendeckenden Stundenverrechnungssatz: Lohnnebenkosten korrekt aufschlagen, produktive Stunden realistisch ansetzen, Gemeinkosten verteilen, Wagnis- und Gewinnzuschlag.", + "source_md": "01-stundensatz-bilden.md", + "modules": [ + { + "id": "lohnnebenkosten", + "title": "Lohnnebenkosten korrekt aufschlagen", + "objectives": [ + "Standard-Aufschlag 75-80% auf Brutto kennen", + "SOKA-Bau-Spezifikum verstehen (Faktor bis 105%)", + "Berufsgenossenschaft je Gewerk einordnen" + ], + "topics": ["Sozialabgaben", "Urlaub", "Krankheit", "BG", "SOKA-Bau"], + "difficulty": "mittel", + "source_heading": "Schritt 2 — Lohnnebenkosten aufschlagen" + }, + { + "id": "produktive-stunden", + "title": "Produktive Stunden realistisch", + "objectives": [ + "1.400-1.650 h/Jahr als Realität akzeptieren", + "Abzüge sauber rechnen (Urlaub, Krankheit, Schulung, Werkstatt)", + "Wirkung von 200 h Differenz auf SVS verstehen" + ], + "topics": ["Produktivstunden", "Auslastung", "1.436-h-Beispiel"], + "difficulty": "mittel", + "source_heading": "Schritt 3 — Produktive Stunden ermitteln" + }, + { + "id": "gemeinkosten", + "title": "Gemeinkosten verteilen", + "objectives": [ + "Werkstatt + Fuhrpark + Verwaltung + Versicherungen + Marketing erfassen", + "Pro produktiver Stunde umlegen", + "4-Mann-Beispielrechnung durchgehen (122k €/Jahr → 28,30 €/h)" + ], + "topics": ["Werkstatt", "Fuhrpark", "Verwaltung", "Umlage"], + "difficulty": "schwer", + "source_heading": "Schritt 4 — Gemeinkosten verteilen" + }, + { + "id": "wagnis-gewinn", + "title": "Wagnis- und Gewinnzuschlag", + "objectives": [ + "Wagniszuschlag 5-10% als Pflicht erkennen", + "Gewinnzuschlag 8-15% als Substanz-Erhalt verstehen", + "5%-Falle vermeiden (Substanzverzehr)" + ], + "topics": ["Wagnis", "Gewinn", "Forderungsausfall", "Substanz"], + "difficulty": "mittel", + "source_heading": "Schritt 5 — Wagniszuschlag" + }, + { + "id": "svs-formel", + "title": "SVS-Endformel + Marktvergleich", + "objectives": [ + "Vollständige SVS-Formel anwenden können", + "75-Euro-Standard kritisch hinterfragen", + "Regionale Spreizung kennen (Berlin/München vs Sachsen/Thüringen)" + ], + "topics": ["SVS-Formel", "Marktpreis", "regionale Spreizung"], + "difficulty": "schwer", + "source_heading": "Schritt 7 — Vollständige SVS-Formel" + } + ] + }, + { + "id": "material", + "title": "2 · Materialaufschlag & Skonto", + "short": "Aufschlag, Marge, Verschnitt, Skonto-Strategie", + "icon": "package", + "color": "#f59e0b", + "description": "Materialaufschlag richtig kalkulieren: Aufschlag vs. Marge, Skonto als Margen-Killer, Verschnitt-Reserve, Sondermaterial-Beschaffungspauschale, Lieferanten-Lock-in vermeiden.", + "source_md": "02-materialaufschlag.md", + "modules": [ + { + "id": "aufschlag-grundlagen", + "title": "Was deckt der Materialaufschlag", + "objectives": [ + "6 Kostenblöcke des Aufschlags benennen", + "Übliche Aufschläge nach Materialklasse kennen", + "Faustregel 25% auf EK als Standard-Mitte" + ], + "topics": ["Bestellung", "Transport", "Lager", "Verschnitt", "Vorfinanzierung", "Gewährleistung"], + "difficulty": "einfach", + "source_heading": "Was deckt der Materialaufschlag" + }, + { + "id": "aufschlag-vs-marge", + "title": "Aufschlag vs. Marge — kein Verwechslungsfehler", + "objectives": [ + "25% Aufschlag = 20% Marge sicher umrechnen", + "Bei Lieferanten-Gespräch korrekt nachfragen", + "5%-Lücke pro 100k Material berechnen können" + ], + "topics": ["Aufschlag-Formel", "Marge-Formel"], + "difficulty": "mittel", + "source_heading": "Aufschlag vs. Marge — der ewige Verwechslungsfehler" + }, + { + "id": "skonto", + "title": "Skonto-Strategie", + "objectives": [ + "Skonto immer in Kalkulation einrechnen", + "Skonto immer ziehen (Liquiditäts-Disziplin)", + "2% Skonto als 30-50% des Gewinns einordnen" + ], + "topics": ["2/14 Tage", "Kalkulationsskonto", "Liquidität"], + "difficulty": "mittel", + "source_heading": "Skonto — der unterschätzte Margenkiller" + }, + { + "id": "verschnitt", + "title": "Verschnitt-Reserve pro Material", + "objectives": [ + "Materialspezifische Verschnitt-Quoten kennen", + "Verschnitt kalkulieren, nicht abrechnen", + "Tapeten-Rapport bis 25% beachten" + ], + "topics": ["Kupferrohr", "Kabel", "Tapeten", "Fliesen", "Holz"], + "difficulty": "einfach", + "source_heading": "Verschnitt-Reserve" + } + ] + }, + { + "id": "gewerks-spezifika", + "title": "3 · Gewerke-Spezifika", + "short": "SHK / Elektro / Maler / Tischler im Detail", + "icon": "layers", + "color": "#b45309", + "description": "Pro Gewerk: typische Stundensätze, Material-Mix, Kalkulationsfallen, Rechtsrahmen (DIN VDE, DIN 1988, DIN 18363), VOB/B vs BGB, Gewährleistung 5J Bau / 2J BGB.", + "source_md": "07-rollen-spezifisch-shk-elektro-maler-tischler.md", + "modules": [ + { + "id": "shk", + "title": "SHK — Sanitär/Heizung/Klima", + "objectives": [ + "Stundensatz 75-115 EUR/h einordnen", + "DIN 1988-200 Spülung + Druckprüfung als Pflicht erkennen", + "GEG 2024 Heizungstausch-Implikationen" + ], + "topics": ["DIN 1988-200", "AVBWasserV", "GEG 2024", "Notdienst"], + "difficulty": "mittel", + "source_heading": "SHK (Sanitär-Heizung-Klima)" + }, + { + "id": "elektro", + "title": "Elektro", + "objectives": [ + "Stundensatz 68-105 EUR/h einordnen", + "DIN VDE 0100-600 Messung + Protokoll als Pflicht erkennen", + "DGUV V3 wiederkehrende Prüfung als Auftragsquelle" + ], + "topics": ["DIN VDE 0100", "DGUV V3", "TAB", "Wallbox §14a EnWG"], + "difficulty": "mittel", + "source_heading": "Elektro" + }, + { + "id": "maler", + "title": "Maler / Stuckateur", + "objectives": [ + "Stundensatz 62-95 EUR/h einordnen", + "Untergrundprüfung nach DIN 18363 als Pflicht erkennen", + "Bedenkenanzeige nach §4 Abs. 3 VOB/B sicher anwenden" + ], + "topics": ["DIN 18363", "Bedenkenanzeige", "Untergrund", "Tapeten-Rapport"], + "difficulty": "schwer", + "source_heading": "Maler / Stuckateur" + }, + { + "id": "tischler", + "title": "Tischler / Schreiner", + "objectives": [ + "Stundensatz 63-100 EUR/h einordnen", + "Werkstattzeit vs Montagezeit unterscheiden", + "Aufmaß-Toleranz vor Werkstatt-Fertigung beachten" + ], + "topics": ["DIN 18355", "Maßanfertigung", "Aufmaß", "Beschläge"], + "difficulty": "mittel", + "source_heading": "Tischler / Schreiner" + }, + { + "id": "gewerks-vergleich", + "title": "Gewerks-Vergleich Stundensatz", + "objectives": [ + "Tabelle Geselle/Meister-Sätze 2025-26 kennen", + "Regionale Spreizung beachten", + "Marktpreis ungleich kostendeckender Preis" + ], + "topics": ["Marktvergleich", "Region", "DACH 2025-26"], + "difficulty": "einfach", + "source_heading": "Aktuelle Marktsätze 2025/2026" + } + ] + }, + { + "id": "nachkalkulation", + "title": "4 · Nachkalkulation & Pareto", + "short": "Aus jedem Auftrag systematisch lernen", + "icon": "trending-up", + "color": "#22c55e", + "description": "Soll-Ist-Vergleich pro Auftrag: Stunden-Differenz, Material-Mehrverbrauch, Marge real ermitteln. Pareto-Analyse identifiziert die 3-5 wiederkehrenden Margen-Killer. Soll-Stunden-Faktor (SSF) als Profi-Kennzahl.", + "source_md": "06-nachkalkulation.md", + "modules": [ + { + "id": "soll-ist-vergleich", + "title": "Soll-Ist-Vergleich Stunden + Material", + "objectives": [ + "Stunden-Differenz pro Position erfassen", + "Material EK Soll vs Ist vergleichen (>10% = Alarm)", + "Marge in EUR und % differenziert ausweisen" + ], + "topics": ["Soll-Werte", "Ist-Werte", "Differenz-Analyse"], + "difficulty": "mittel", + "source_heading": "Die 4 Nachkalkulations-Dimensionen" + }, + { + "id": "pareto-margenanalyse", + "title": "Pareto-Analyse Margen-Killer", + "objectives": [ + "10 schlechteste Aufträge pro Quartal clustern", + "3-5 wiederkehrende Posten identifizieren", + "Systematische Kalkulationslücke schließen" + ], + "topics": ["80/20", "Cluster", "wiederkehrende Posten"], + "difficulty": "schwer", + "source_heading": "Pareto-Analyse — die 80/20-Regel der Margen-Killer" + }, + { + "id": "ssf-kennzahl", + "title": "Soll-Stunden-Faktor (SSF)", + "objectives": [ + "SSF berechnen: Ist-Stunden / Soll-Stunden", + "Zielkorridor 1,05-1,15 als guter Wert", + ">1,30 als Anlass für Stunden-Sätze-Neuerhebung" + ], + "topics": ["SSF", "Lerngeschwindigkeit", "Kalkulations-Reife"], + "difficulty": "schwer", + "source_heading": "Soll-Stunden-Faktor (SSF)" + }, + { + "id": "cluster-analyse", + "title": "Auftragstyp-Cluster analysieren", + "objectives": [ + "Aufträge nach Typ aggregieren (Bad-Sanierung, Notdienst, etc.)", + "Cluster mit chronisch niedriger Marge erkennen", + "Strategische Entscheidung Kalkulation anpassen vs Cluster abstoßen" + ], + "topics": ["Cluster", "Auftragstyp", "Marge-Trend"], + "difficulty": "schwer", + "source_heading": "Nachkalkulation pro Gewerk-/Auftragstyp clustern" + } + ] + }, + { + "id": "fallen", + "title": "5 · Typische Fallen & Verträge", + "short": "12 Kalkulationsfallen + VOB/B vs BGB", + "icon": "alert-triangle", + "color": "#ef4444", + "description": "Die 12 häufigsten Kalkulationsfallen aller Gewerke plus Vertragsformen: BGB-Werkvertrag (5J Gewährleistung Bau, 2J andere) vs VOB/B (4J Bau, neue 2J nach Mängelbeseitigung). Eventualpositionen, Stundenlohnarbeiten, Bedenkenanzeige.", + "source_md": "05-typische-kalkulationsfallen.md", + "modules": [ + { + "id": "fallen-allgemein", + "title": "Allgemeine Kalkulationsfallen", + "objectives": [ + "Lohnnebenkosten zu niedrig (Faktor 1,3 vs 1,78)", + "Wagniszuschlag fehlt komplett", + "Skonto eingerechnet aber nicht gezogen" + ], + "topics": ["Lohnnebenkosten", "Wagnis", "Skonto", "Gerüst", "Anfahrt"], + "difficulty": "mittel", + "source_heading": "Allgemein (alle Gewerke)" + }, + { + "id": "fallen-gewerk", + "title": "Gewerks-spezifische Fallen", + "objectives": [ + "SHK: Spülung/Druckprüfung im Stundenlohn versteckt", + "Elektro: Abschlussmessung nicht extra ausgewiesen", + "Maler: Untergrundprüfung im m²-Preis versteckt", + "Tischler: Aufmaß-Toleranz nicht eingerechnet" + ], + "topics": ["Spülung", "DGUV V3", "Untergrund", "Aufmaß-Toleranz"], + "difficulty": "schwer", + "source_heading": "SHK-spezifisch" + }, + { + "id": "vob-bgb", + "title": "VOB/B vs BGB-Werkvertrag", + "objectives": [ + "Geltung: VOB nur wenn vereinbart", + "Gewährleistung: 5J BGB Bau / 4J VOB Bau / 2J andere", + "Frist-Lauf: VOB neue 2J nach Mängelbeseitigung" + ], + "topics": ["BGB §631", "VOB/B §13", "Gewährleistung", "Mängelrüge"], + "difficulty": "mittel", + "source_heading": "VOB/B vs. BGB — wann was?" + }, + { + "id": "vob-c-praxis", + "title": "VOB/C — Aufmaß + Eventualpositionen", + "objectives": [ + "DIN 18299 Allgemeine Regelungen kennen", + "OZ-Nummern hierarchisch aufbauen", + "Eventualpositionen bei unklaren Mengen sinnvoll einsetzen", + "Stundenlohnarbeiten nach §15 VOB/B vorab vereinbaren" + ], + "topics": ["DIN 18299", "OZ-Nummern", "Eventualpositionen", "Stundenlohnarbeiten"], + "difficulty": "schwer", + "source_heading": "DIN 18299 — die wichtigsten 5 Abschnitte" + }, + { + "id": "preisdruecker", + "title": "Preisdrücker-Kunden + Notdienst-Tarife", + "objectives": [ + "Wert statt Rabatt anbieten", + "Roter Stoppschild-Satz formulieren", + "Notdienst mit 100-150% Zuschlag korrekt kalkulieren" + ], + "topics": ["Preisdrücker", "Skonto-Geste", "Notdienst-Zuschlag"], + "difficulty": "mittel", + "source_heading": "Wie reagiere ich auf Preisdrücker-Kunden?" + } + ] + } + ], + "badges": [ + {"id": "erste_kalkulation", "title": "Erste Kalkulation", "icon": "calculator", "description": "1. Quiz im Stundensatz-Modul erfolgreich."}, + {"id": "gewerks_meister", "title": "Gewerks-Meister", "icon": "hammer", "description": "5 Quiz im Gewerke-Spezifika-Modul korrekt."}, + {"id": "marge_optimierer", "title": "Marge-Optimierer", "icon": "trending-up", "description": "3 Quiz im Nachkalkulations-Modul korrekt."}, + {"id": "fallen_kenner", "title": "Fallen-Kenner", "icon": "alert-triangle", "description": "5 Quiz im Fallen-Modul korrekt — kennt die typischen Margen-Killer."}, + {"id": "libra_meister", "title": "LIBRA-Meister", "icon": "crown", "description": "Alle 5 Curricula mit >=80% abgeschlossen."}, + {"id": "streak_7", "title": "Wochenstreak", "icon": "flame", "description": "7 Tage in Folge aktiv gewesen."}, + {"id": "night_owl", "title": "Nachteule", "icon": "moon", "description": "Nach 22 Uhr gelernt."}, + {"id": "early_bird", "title": "Frühaufsteher", "icon": "sun", "description": "Vor 7 Uhr gelernt."} + ], + "levels": [ + {"min": 0, "title": "Lehrling"}, + {"min": 50, "title": "Geselle"}, + {"min": 200, "title": "Vorarbeiter"}, + {"min": 500, "title": "Meister"}, + {"min": 1250, "title": "Betriebsleiter"}, + {"min": 2500, "title": "Inhaber"}, + {"min": 5000, "title": "Handwerks-Unternehmer"} + ] +} diff --git a/src/levels-fallback.js b/src/levels-fallback.js new file mode 100644 index 0000000..4a1ca18 --- /dev/null +++ b/src/levels-fallback.js @@ -0,0 +1,4 @@ + { min: 0, title: 'Lehrling' }, { min: 50, title: 'Geselle' }, + { min: 200, title: 'Vorarbeiter' }, { min: 500, title: 'Meister' }, + { min: 1250, title: 'Betriebsleiter' }, { min: 2500, title: 'Inhaber' }, + { min: 5000, title: 'Handwerks-Unternehmer' } diff --git a/src/welcome.html b/src/welcome.html new file mode 100644 index 0000000..5333311 --- /dev/null +++ b/src/welcome.html @@ -0,0 +1,29 @@ +

Willkommen bei LIBRA!

+

Hi, ich bin LIBRA. Ich rette deine Marge, bevor du das Angebot rausgibst. Sag mir Gewerk und Auftragstyp — ich rechne mit dir durch: Stundensatz, Material, Fahrtzeit, Wagniszuschlag. Plus: nach jedem Auftrag Nachkalkulation, lerne aus den 5 Posten, die immer Marge fressen. Alles im deutschen Bunker — keine Daten verlassen deinen Betrieb.

+
+ + + + + + +
+

In 3 Sätzen: Chat zum Verstehen → Quiz zum Testen → Flashcards zum Merken. Fortschritt zeigt dir, wo du stehst; die Gewerke-Bibliothek gibt dir 5 kuratierte Module für SHK, Elektro, Maler und Tischler.

\ No newline at end of file diff --git a/www/app.js b/www/app.js new file mode 100644 index 0000000..b7c8cef --- /dev/null +++ b/www/app.js @@ -0,0 +1,1747 @@ +/* LIBRA — Handwerks-Kalkulations-Coach Widget + * Vanilla JS, no build, no framework, keine externen Fonts/Analytics. + * Chat | Quiz | Flashcards | Fortschritt | Gewerke-Bibliothek — localStorage only. + */ +(() => { + 'use strict'; + + // ==== Config ==== + const API = 'https://llm.qognio.com/api/bots/libra-handwerks-coach/chat'; + const RAW_KEY = window.__LIBRA_KEY__ || ''; + const KEY = /^qb_[a-zA-Z0-9]{6,}$/.test(RAW_KEY) ? RAW_KEY : ''; + const LS_KEY = 'libra.state.v1'; + const LS_CHAT = 'libra.chat.v1'; + const LS_FLASH = 'libra.flash.v1'; + + // ==== State ==== + let CURRICULA = null; + let state = loadState(); + let chatHistory = loadChatHistory(); + let flashCards = loadFlashCards(); // { [topicId]: [{front, back, hint, ef, interval, due, reps}] } + + // ==== Utils ==== + const $ = (s, r = document) => r.querySelector(s); + const $$ = (s, r = document) => Array.from(r.querySelectorAll(s)); + const now = () => Date.now(); + const today = () => { + const d = new Date(); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + }; + function daysBetween(dateA, dateB) { + const a = new Date(dateA); const b = new Date(dateB); + return Math.round((b - a) / 86400000); + } + + // ==== Persistence ==== + function loadState() { + try { + const s = JSON.parse(localStorage.getItem(LS_KEY) || '{}'); + return { + xp: s.xp || 0, + totalAnswers: s.totalAnswers || 0, + correctAnswers: s.correctAnswers || 0, + currentStreak: s.currentStreak || 0, + maxStreak: s.maxStreak || 0, + lastActive: s.lastActive || null, + quizStreak: s.quizStreak || 0, + maxQuizStreak: s.maxQuizStreak || 0, + mastery: s.mastery || {}, // { [curriculumId]: { correct, total } } + moduleCorrect: s.moduleCorrect || {}, // { [moduleId]: correctCount } + moduleTotal: s.moduleTotal || {}, // { [moduleId]: totalAnswered } + modulePassedFlash: s.modulePassedFlash || {}, // { [moduleId]: true when all cards rated >= "gut"} + completedQuizzes: s.completedQuizzes || 0, + flashCardsRated: s.flashCardsRated || 0, + badges: s.badges || {}, + seenWelcome: s.seenWelcome || false, + completedCurricula: s.completedCurricula || [] + }; + } catch (e) { + return { + xp: 0, totalAnswers: 0, correctAnswers: 0, + currentStreak: 0, maxStreak: 0, lastActive: null, + quizStreak: 0, maxQuizStreak: 0, mastery: {}, + moduleCorrect: {}, moduleTotal: {}, modulePassedFlash: {}, + completedQuizzes: 0, flashCardsRated: 0, + badges: {}, seenWelcome: false, completedCurricula: [] + }; + } + } + function saveState() { localStorage.setItem(LS_KEY, JSON.stringify(state)); } + + function loadChatHistory() { + try { return JSON.parse(localStorage.getItem(LS_CHAT) || '[]'); } catch (e) { return []; } + } + function saveChatHistory() { + // Cap at last 40 exchanges to keep API payload small + const trimmed = chatHistory.slice(-40); + localStorage.setItem(LS_CHAT, JSON.stringify(trimmed)); + } + + function loadFlashCards() { + try { return JSON.parse(localStorage.getItem(LS_FLASH) || '{}'); } catch (e) { return {}; } + } + function saveFlashCards() { localStorage.setItem(LS_FLASH, JSON.stringify(flashCards)); } + + // ==== Streak / activity tracking ==== + function touchActivity() { + const t = today(); + if (state.lastActive === t) return; + if (state.lastActive) { + const diff = daysBetween(state.lastActive, t); + if (diff === 1) state.currentStreak += 1; + else if (diff > 1) state.currentStreak = 1; + else state.currentStreak = Math.max(1, state.currentStreak); + } else { + state.currentStreak = 1; + } + state.lastActive = t; + if (state.currentStreak > state.maxStreak) state.maxStreak = state.currentStreak; + checkBadges(); + saveState(); + } + + // ==== XP / Level ==== + function addXP(n, reason = '') { + state.xp += n; + saveState(); + showXPGain(`+${n} XP${reason ? ' · ' + reason : ''}`); + } + function levelInfo() { + const levels = (CURRICULA && CURRICULA.levels) || [ + { min: 0, title: 'Lehrling' }, { min: 50, title: 'Geselle' }, + { min: 200, title: 'Vorarbeiter' }, { min: 500, title: 'Meister' }, + { min: 1250, title: 'Betriebsleiter' }, { min: 2500, title: 'Inhaber' }, + { min: 5000, title: 'Handwerks-Unternehmer' } + ]; + let cur = levels[0]; + for (const l of levels) if (state.xp >= l.min) cur = l; + const idx = levels.indexOf(cur); + const next = levels[idx + 1] || null; + const pct = next ? Math.min(100, ((state.xp - cur.min) / (next.min - cur.min)) * 100) : 100; + return { levelNum: idx + 1, title: cur.title, pct, next }; + } + + // ==== Badges ==== + function unlockBadge(id) { + if (state.badges[id]) return false; + state.badges[id] = today(); + saveState(); + const badge = (CURRICULA && CURRICULA.badges || []).find(b => b.id === id); + if (badge) toast('🏆 Neues Abzeichen: ' + badge.title, 'success', 4500); + return true; + } + function checkBadges() { + // Erste Kalkulation — 1 Quiz im Lohnnebenkosten-Modul (Stundensatz-Curriculum) + if ((state.moduleCorrect && state.moduleCorrect['lohnnebenkosten'] >= 1)) unlockBadge('erste_kalkulation'); + // Gewerks-Meister — 5 Quiz korrekt im SHK-Modul (Kern-Gewerk) + if ((state.moduleCorrect && state.moduleCorrect['shk'] >= 5)) unlockBadge('gewerks_meister'); + // Marge-Optimierer — 3 Quiz korrekt im Soll-Ist-Vergleich-Modul (Nachkalkulation) + if ((state.moduleCorrect && state.moduleCorrect['soll-ist-vergleich'] >= 3)) unlockBadge('marge_optimierer'); + // Fallen-Kenner — 5 Quiz korrekt im Fallen-Allgemein-Modul + if ((state.moduleCorrect && state.moduleCorrect['fallen-allgemein'] >= 5)) unlockBadge('fallen_kenner'); + // LIBRA-Meister — 18 von 23 Modulen mit ≥80% Quiz-Score abgeschlossen (~78% Master-Coverage) + if ((state.completedCurricula || []).length >= 18) unlockBadge('libra_meister'); + // Streak-7 — 7-Tage-Streak + if (state.maxStreak >= 7) unlockBadge('streak_7'); + // Night Owl & Early Bird (beibehalten) + const h = new Date().getHours(); + if (h >= 22) unlockBadge('night_owl'); + if (h < 7) unlockBadge('early_bird'); + } + + // ==== Toast ==== + function toast(msg, kind = '', ms = 3200) { + const stack = $('#toast-stack'); + const t = document.createElement('div'); + t.className = 'toast ' + kind; + t.textContent = msg; + stack.appendChild(t); + setTimeout(() => { + t.style.opacity = '0'; + t.style.transition = 'opacity .25s'; + setTimeout(() => t.remove(), 260); + }, ms); + } + function showXPGain(txt) { + const el = document.createElement('div'); + el.className = 'xp-gain'; + el.textContent = txt; + document.body.appendChild(el); + setTimeout(() => el.remove(), 1600); + } + + // ==== Simple markdown renderer ==== + function renderMD(md) { + if (!md) return ''; + let s = md; + // Escape HTML first + s = s.replace(/&/g, '&').replace(//g, '>'); + // Code fences + s = s.replace(/```(\w+)?\n([\s\S]*?)```/g, (_, lang, code) => + `
${code}
`); + // Inline code + s = s.replace(/`([^`\n]+)`/g, '$1'); + // GFM tables: header / separator / body + s = s.replace(/(?:^|\n)((?:\|[^\n]*\|[ \t]*\n){2,})/g, (block, content) => { + const lines = content.trim().split('\n'); + if (lines.length < 2) return block; + const sep = lines[1]; + if (!/^\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)+\|?\s*$/.test(sep)) return block; + const parseRow = (ln) => ln.replace(/^\|/, '').replace(/\|\s*$/, '').split('|').map(c => c.trim()); + const header = parseRow(lines[0]); + const aligns = parseRow(sep).map(s => /^:-+:$/.test(s) ? 'center' : /-+:$/.test(s) ? 'right' : 'left'); + const rows = lines.slice(2).map(parseRow); + let html = '\n'; + header.forEach((h, i) => { html += ``; }); + html += ''; + rows.forEach(r => { + html += ''; + for (let i = 0; i < Math.max(r.length, header.length); i++) { + html += ``; + } + html += ''; + }); + html += '
${h}
${r[i] || ''}
\n'; + return html; + }); + // Bold + s = s.replace(/\*\*([^*\n]+)\*\*/g, '$1'); + s = s.replace(/__([^_\n]+)__/g, '$1'); + // Italic + s = s.replace(/(^|[^*])\*([^*\n]+)\*/g, '$1$2'); + // Headings + s = s.replace(/^### (.+)$/gm, '

$1

'); + s = s.replace(/^## (.+)$/gm, '

$1

'); + s = s.replace(/^# (.+)$/gm, '

$1

'); + // Links + s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, '$1'); + // Unordered lists + s = s.replace(/(?:^|\n)((?:[*\-] .+\n?)+)/g, (m) => { + const items = m.trim().split('\n').map(ln => ln.replace(/^[*\-] /, '')).map(li => `
  • ${li}
  • `).join(''); + return '\n
      ' + items + '
    '; + }); + // Ordered lists + s = s.replace(/(?:^|\n)((?:\d+\. .+\n?)+)/g, (m) => { + const items = m.trim().split('\n').map(ln => ln.replace(/^\d+\. /, '')).map(li => `
  • ${li}
  • `).join(''); + return '\n
      ' + items + '
    '; + }); + // Paragraphs: split on blank lines + s = s.split(/\n{2,}/).map(block => { + if (/^<(h\d|ul|ol|pre|blockquote|table)/.test(block.trim())) return block; + return '

    ' + block.replace(/\n/g, '
    ') + '

    '; + }).join('\n'); + return s; + } + + // ==== Chat API ==== + async function chatAPI(message, history, attachments) { + const body = { message, history }; + if (Array.isArray(attachments) && attachments.length > 0) body.attachments = attachments; + const r = await fetch(API, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer ' + KEY + }, + body: JSON.stringify(body) + }); + let data; + try { data = await r.json(); } catch (e) { throw new Error('Server-Antwort nicht lesbar'); } + if (!r.ok) throw new Error(data.error || ('HTTP ' + r.status)); + return data; + } + + // ==== Attachments (file upload) ==== + const ATTACH_MAX_COUNT = 5; + const ATTACH_MAX_BYTES = 8 * 1024 * 1024; + const ATTACH_ACCEPTED_RE = /\.(pdf|txt|md|csv|json|xml|yaml|yml|log|png|jpg|jpeg|webp|gif)$/i; + let pendingAttachments = []; // [{ name, mimeType, dataUrl, size }] + + function fmtSize(bytes) { + if (bytes < 1024) return bytes + ' B'; + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; + return (bytes / 1024 / 1024).toFixed(1) + ' MB'; + } + + function fileToDataUrl(file) { + return new Promise((resolve, reject) => { + const fr = new FileReader(); + fr.onload = () => resolve(fr.result); + fr.onerror = () => reject(new Error('Datei konnte nicht gelesen werden')); + fr.readAsDataURL(file); + }); + } + + function renderAttachStrip() { + const strip = $('#attach-strip'); + if (!strip) return; + strip.innerHTML = ''; + pendingAttachments.forEach((a, idx) => { + const chip = document.createElement('span'); + chip.className = 'attach-chip'; + const ico = a.mimeType.startsWith('image/') ? '🖼' : (a.mimeType === 'application/pdf' || /\.pdf$/i.test(a.name)) ? '📄' : '📝'; + chip.innerHTML = `${ico}${a.name.replace(/[<>"']/g, '')}${fmtSize(a.size)}`; + const rm = document.createElement('button'); + rm.type = 'button'; + rm.className = 'attach-chip-remove'; + rm.setAttribute('aria-label', 'Anhang entfernen'); + rm.textContent = '×'; + rm.addEventListener('click', () => { + pendingAttachments.splice(idx, 1); + renderAttachStrip(); + }); + chip.appendChild(rm); + strip.appendChild(chip); + }); + } + + async function addFiles(fileList) { + const files = Array.from(fileList || []); + for (const f of files) { + if (pendingAttachments.length >= ATTACH_MAX_COUNT) { + toast(`Max ${ATTACH_MAX_COUNT} Anhänge — weitere ignoriert`, 'warn'); + break; + } + if (!ATTACH_ACCEPTED_RE.test(f.name) && !f.type.startsWith('image/') && !/text|json|xml|pdf/i.test(f.type)) { + toast(`${f.name}: Format nicht unterstützt`, 'error'); + continue; + } + if (f.size > ATTACH_MAX_BYTES) { + toast(`${f.name}: ${fmtSize(f.size)} > Limit ${fmtSize(ATTACH_MAX_BYTES)}`, 'error'); + continue; + } + try { + const dataUrl = await fileToDataUrl(f); + pendingAttachments.push({ + name: f.name, + mimeType: f.type || 'application/octet-stream', + dataUrl, + size: f.size, + }); + } catch (e) { + toast(`${f.name}: ${e.message}`, 'error'); + } + } + renderAttachStrip(); + } + + // ==== Chat UI ==== + function addMsg(role, content, { markdown = false, pending = false, attachments = null } = {}) { + const box = $('#chat-box'); + const el = document.createElement('div'); + el.className = 'msg ' + role; + if (pending) { + el.innerHTML = ''; + } else if (markdown) { + el.innerHTML = renderMD(content); + } else { + el.textContent = content; + } + if (Array.isArray(attachments) && attachments.length > 0) { + const wrap = document.createElement('div'); + wrap.className = 'msg-attachments'; + attachments.forEach(a => { + const ico = a.mimeType && a.mimeType.startsWith('image/') ? '🖼' : (a.mimeType === 'application/pdf' || /\.pdf$/i.test(a.name)) ? '📄' : '📝'; + const span = document.createElement('span'); + span.className = 'att-name'; + span.textContent = `${ico} ${a.name} (${fmtSize(a.size)})`; + wrap.appendChild(span); + }); + el.appendChild(wrap); + } + box.appendChild(el); + box.scrollTop = box.scrollHeight; + setTimeout(() => { $('.main').scrollTop = $('.main').scrollHeight; }, 20); + return el; + } + + function clearChatUI() { + $('#chat-box').innerHTML = ''; + } + + function renderWelcome() { + if (state.xp === 0 && chatHistory.length === 0 && !state.seenWelcome) { + $('#welcome-screen').classList.remove('hidden'); + $('#welcome-screen').setAttribute('aria-hidden', 'false'); + } else { + $('#welcome-screen').classList.add('hidden'); + $('#welcome-screen').setAttribute('aria-hidden', 'true'); + } + } + + function restoreChat() { + clearChatUI(); + for (const m of chatHistory) { + addMsg(m.role === 'assistant' ? 'bot' : 'user', m.content, { markdown: m.role === 'assistant' }); + } + renderWelcome(); + } + + async function sendChat(text) { + const attaches = pendingAttachments.slice(); + if (!text.trim() && attaches.length === 0) return; + $('#welcome-screen').classList.add('hidden'); + state.seenWelcome = true; + + addMsg('user', text || '(nur Anhang)', { attachments: attaches }); + chatHistory.push({ + role: 'user', + content: text + (attaches.length ? '\n[Anhänge: ' + attaches.map(a => a.name).join(', ') + ']' : ''), + }); + pendingAttachments = []; + renderAttachStrip(); + + const pend = addMsg('bot', '', { pending: true }); + $('#composer-send').disabled = true; + + try { + const hist = chatHistory.slice(-20, -1); + const data = await chatAPI(text, hist, attaches.length ? attaches.map(a => ({ name: a.name, mimeType: a.mimeType, dataUrl: a.dataUrl })) : null); + pend.classList.remove('pending'); + const structured = _tryParseStructuredReply(data.reply || ''); + if (structured) { + pend.innerHTML = _renderStructuredInChat(structured); + } else { + pend.innerHTML = renderMD(data.reply || ''); + } + if (Array.isArray(data.attachment_notes) && data.attachment_notes.length) { + const notice = document.createElement('div'); + notice.className = 'attachment-notice'; + notice.textContent = '📎 ' + data.attachment_notes.join(' · '); + pend.appendChild(notice); + } + chatHistory.push({ role: 'assistant', content: data.reply }); + saveChatHistory(); + touchActivity(); + + // Soft-Gate: postMessage to parent window after N user→assistant + // exchanges (configurable via ?showcase=1 query param). The outer + // qognio.com/showcase page listens for this event and triggers the + // lead-form modal. Sent only when running in an iframe. + try { + const isShowcase = new URLSearchParams(window.location.search).get('showcase') === '1'; + if (isShowcase && window.parent && window.parent !== window) { + const userMessages = chatHistory.filter((m) => m.role === 'user').length; + const SOFT_GATE_AT = 3; + if (userMessages === SOFT_GATE_AT) { + window.parent.postMessage( + { type: 'qognio:soft-gate', afterMessages: userMessages, botSlug: window.location.hostname.split('.')[0] }, + '*' + ); + } + } + } catch (sgErr) { + // non-fatal — swallow + } + } catch (e) { + pend.className = 'msg sys'; + pend.textContent = '⚠ Fehler: ' + (e.message || 'unbekannt'); + toast('Verbindung fehlgeschlagen', 'error'); + } finally { + $('#composer-send').disabled = false; + $('#composer').focus(); + } + } + + // ==== Structured request helper ==== + function _extractJSON(reply) { + let s = (reply || '').trim(); + const fence = s.match(/```(?:json)?\s*([\s\S]*?)\s*```/); + if (fence) s = fence[1].trim(); + const b = s.indexOf('{'); const e = s.lastIndexOf('}'); + if (b >= 0 && e > b) s = s.slice(b, e + 1); + return s; + } + function _repairJSON(s) { + s = s.replace(/,(\s*[}\]])/g, '$1'); + s = s.replace(/,\s*,/g, ','); + s = s.replace(/(\})\s*\n\s*(\{)/g, '$1,\n$2'); + s = s.replace(/(")(\s*\n\s*)(")/g, '$1,$2$3'); + return s; + } + + // --- Structured-Reply-Fallback fuer Chat (2026-04-25) --- + // Wenn der Bot versehentlich QUIZ/FLASHCARD/CASE/EXAM-JSON liefert statt Markdown, + // parse + render hier lesbar statt Raw-JSON im Chat-Bubble anzuzeigen. + function _tryParseStructuredReply(reply) { + let s = (reply || '').trim(); + const fence = s.match(/```(?:json)?\s*([\s\S]*?)\s*```/); + if (fence) s = fence[1].trim(); + if (!s.startsWith('{')) return null; + const b = s.indexOf('{'), e = s.lastIndexOf('}'); + if (b < 0 || e <= b) return null; + const raw = s.slice(b, e + 1); + let obj; + try { obj = JSON.parse(raw); } catch { + try { obj = JSON.parse(_repairJSON(raw)); } catch { return null; } + } + if (!obj || typeof obj !== 'object') return null; + const KNOWN = [ + 'case','quiz','flashcards','exam','lesson','presentation', + // Bot-spezifische Strukturen (KURT/VESTIGIA/PAUL/Pia/Otto/Eli/LIMEN/Zita/LIBRA/IDA) + 'audit','privacy_check','mail_check','plan','validate','interview','decode','write','calc','unterweisung' + ]; + if (!KNOWN.includes(obj.type)) return null; + return obj; + } + function _renderStructuredInChat(obj) { + const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); + const label = ['A','B','C','D','E','F','G','H']; + const TYPE_BADGE = { + case:'📖 Fallbeispiel', quiz:'🧠 Quiz-Fragen', flashcards:'📇 Karteikarten', + exam:'📝 Prüfung', lesson:'🎓 Lektion', presentation:'🖼 Präsentation', + audit:'🛡 AI-Act Audit-Trail', privacy_check:'🩺 Pflege-Datenschutz-Check', + mail_check:'📧 Mail-Check', plan:'🗓 Plan', validate:'✓ Validierung', + interview:'🎙 Wissens-Interview', decode:'🔍 Zeugnis-Decoder', + write:'✍ Zeugnis-Entwurf', calc:'🧮 Kalkulation', + unterweisung:'🛠 AdA-Unterweisung', + }; + let html = `
    ${TYPE_BADGE[obj.type] || '📄 Strukturierte Antwort'}
    `; + if (obj.topic) html += `
    ${esc(obj.topic)}
    `; + if (obj.type === 'case') { + if (obj.scenario) html += `
    Szenario:
    ${esc(obj.scenario)}
    `; + const fragen = obj.fragen || obj.questions || []; + fragen.forEach((f, i) => { + html += `
    Frage ${i+1}: ${esc(f.frage || f.q || '')}`; + const opts = f.options || []; + if (opts.length) { + html += '
      '; + opts.forEach((o, j) => { + const correct = (f.correct === j); + html += `${label[j]||(j+1)}) ${esc(o)}${correct ? ' ✓' : ''}`; + }); + html += '
    '; + } + const ex = f.explain || f.explanation; + if (ex) html += `
    Erklärung: ${esc(ex)}
    `; + html += '
    '; + }); + const lessons = obj.lessons || []; + if (lessons.length) { + html += '
    Lessons:
      '; + lessons.forEach(l => { html += `
    • ${esc(l)}
    • `; }); + html += '
    '; + } + const norm = obj.paragraphen || obj.normen || obj.artikel || []; + if (norm.length) { + html += '
    Rechtsnormen: ' + norm.map(n => `${esc(n)}`).join(' · ') + '
    '; + } + } else if (obj.type === 'quiz' || obj.type === 'exam') { + // Exam-spezifisch: Dauer-Anzeige + HF-Tag pro Frage (IDA AdA-Schein) + if (obj.type === 'exam' && obj.duration_min) { + html += `
    Dauer: ${esc(obj.duration_min)} Min · Fragen: ${(obj.questions||[]).length}
    `; + } + const qs = obj.questions || []; + qs.forEach((q, i) => { + const hfTag = (obj.type === 'exam' && q.hf != null) ? ` [HF ${esc(q.hf)}]` : ''; + html += `
    Frage ${i+1}:${hfTag} ${esc(q.q || q.frage || '')}`; + const opts = q.options || []; + if (opts.length) { + html += '
      '; + opts.forEach((o, j) => { + const correct = (q.correct === j); + html += `${label[j]||(j+1)}) ${esc(o)}${correct ? ' ✓' : ''}`; + }); + html += '
    '; + } + const ex = q.explain || q.explanation; + if (ex) html += `
    Erklärung: ${esc(ex)}
    `; + html += '
    '; + }); + } else if (obj.type === 'flashcards') { + (obj.cards || []).forEach((c, i) => { + html += `
    Karte ${i+1}: ${esc(c.front || '')}
    ${esc(c.back || '')}
    `; + if (c.hint) html += `
    Hinweis: ${esc(c.hint)}
    `; + html += '
    '; + }); + } else if (obj.type === 'lesson' || obj.type === 'presentation') { + if (obj.objectives || obj.learning_objectives) { + const objs = obj.objectives || obj.learning_objectives; + html += '
    Lernziele:
      '; + objs.forEach(o => { html += `
    • ${esc(o)}
    • `; }); + html += '
    '; + } + (obj.slides || []).forEach((s, i) => { + html += `
    ${i+1}. ${esc(s.title || '')}`; + if (s.content_md || s.content) html += `
    ${renderMD(s.content_md || s.content || '')}
    `; + if (s.key_point) html += `
    💡 ${esc(s.key_point)}
    `; + html += '
    '; + }); + } else if (obj.type === 'audit') { + // KURT / VESTIGIA — AI-Act Audit-Trail + if (obj.system) html += `
    System: ${esc(obj.system)}
    `; + const cls = obj.ai_act_risk_class || obj.risk_class; + if (cls) { + const clsColor = { prohibited:'#dc2626', high:'#dc2626', limited:'#eab308', minimal:'#22c55e' }[cls] || '#8b8a99'; + html += `
    Risiko-Klasse: ${esc(cls)}
    `; + } + if (obj.role) html += `
    Rolle: ${esc(obj.role)}
    `; + if (obj.dsgvo_relevant != null) html += `
    DSGVO-relevant: ${obj.dsgvo_relevant ? 'ja' : 'nein'}
    `; + if (obj.art22_check) html += `
    Art. 22 DSGVO: ${esc(obj.art22_check)}
    `; + const arts = obj.required_artifacts || []; + if (arts.length) { + html += '
    Erforderliche Artefakte:'; + arts.forEach(a => { + const sColor = { required:'#dc2626', optional:'#eab308', 'not-required':'#22c55e' }[a.status] || '#8b8a99'; + html += ``; + }); + html += '
    ArtefaktStatusBasis
    ${esc(a.name||'')}${esc(a.status||'')}${esc(a.based_on||'')}
    '; + } + const cw = obj.crosswalk_savings || []; + if (cw.length) { + html += '
    Crosswalk-Einsparung:
      '; + cw.forEach(c => { html += `
    • ${esc(c)}
    • `; }); + html += '
    '; + } + const dl = obj.deadlines || []; + if (dl.length) { + html += '
    Fristen:
      '; + dl.forEach(d => { html += `
    • ${esc(d.date||'')} — ${esc(d.what||'')}
    • `; }); + html += '
    '; + } + const ws = obj.warnings || obj.warnung || []; + if (ws.length) { + html += '
    ⚠ Warnungen:
      '; + ws.forEach(w => { html += `
    • ${esc(w)}
    • `; }); + html += '
    '; + } + } else if (obj.type === 'privacy_check') { + // PAUL — Pflege-Datenschutz-Ampel + const ampel = obj.ampel || 'yellow'; + const ampelColor = { green:'#22c55e', yellow:'#eab308', red:'#dc2626' }[ampel] || '#8b8a99'; + const ampelLabel = { green:'🟢 GRÜN — OK', yellow:'🟡 GELB — Vorsicht', red:'🔴 ROT — Akute Gefahr' }[ampel] || ampel; + html += `
    ${ampelLabel}
    `; + if (obj.situation) html += `
    Situation: ${esc(obj.situation)}
    `; + if (obj.schweigepflicht) html += `
    Schweigepflicht (§203 StGB): ${esc(obj.schweigepflicht)}
    `; + if (obj.dsgvo_basis) html += `
    DSGVO-Basis: ${esc(obj.dsgvo_basis)}
    `; + const acts = obj.handlung || obj.handlungen || []; + if (acts.length) { + html += '
    Handlung:
      '; + acts.forEach(a => { html += `
    1. ${esc(a)}
    2. `; }); + html += '
    '; + } + const wns = obj.warnung || obj.warnungen || []; + if (wns.length) { + html += `
    ⚠ Achtung:
      `; + wns.forEach(w => { html += `
    • ${esc(w)}
    • `; }); + html += '
    '; + } + } else if (obj.type === 'mail_check') { + // Pia — Phishing-Prüferin Mail-Bewertung + const ampel = obj.ampel || 'yellow'; + const ampelColor = { green:'#22c55e', yellow:'#eab308', red:'#dc2626' }[ampel] || '#8b8a99'; + const ampelLabel = { green:'🟢 GRÜN — vermutlich legitim', yellow:'🟡 GELB — verdächtig, zweite Meinung holen', red:'🔴 ROT — sehr wahrscheinlich Phishing/BEC' }[ampel] || ampel; + html += `
    ${ampelLabel}
    `; + if (obj.kurz_begruendung) html += `
    Kurz: ${esc(obj.kurz_begruendung)}
    `; + if (obj.pattern) html += `
    Pattern: ${esc(obj.pattern)}
    `; + const flags = obj.red_flags || []; + if (flags.length) { + html += '
    Red Flags:
      '; + flags.forEach(f => { html += `
    • ${esc(f)}
    • `; }); + html += '
    '; + } + const recs = obj.empfohlene_aktion || obj.aktionen || []; + if (recs.length) { + html += '
    Empfohlene Aktion:
      '; + recs.forEach(a => { html += `
    1. ${esc(a)}
    2. `; }); + html += '
    '; + } + if (obj.weiterleiten_an) html += `
    Weiterleiten an: ${esc(obj.weiterleiten_an)}
    `; + } else if (obj.type === 'plan') { + // Otto — 90-Tage-Onboarding-Plan + if (obj.role) html += `
    Rolle: ${esc(obj.role)}
    `; + const items = obj.weeks || obj.days || obj.steps || obj.phases || []; + if (items.length) { + items.forEach((it, i) => { + const label = it.day || it.week || it.phase || `Phase ${i+1}`; + const focus = it.focus || it.title || ''; + html += `
    ${esc(label)}${focus?` — ${esc(focus)}`:''}
    `; + const tasks = it.tasks || it.aufgaben || []; + if (tasks.length) { + html += '
      '; + tasks.forEach(t => { html += `
    • ${esc(typeof t === 'string' ? t : (t.task || t.text || JSON.stringify(t)))}
    • `; }); + html += '
    '; + } + if (it.success_signal || it.success) html += `
    ✓ ${esc(it.success_signal || it.success)}
    `; + html += '
    '; + }); + } + } else if (obj.type === 'validate') { + // Eli — E-Rechnungs-Validator + const status = obj.status || 'ok'; + const statusColor = { ok:'#22c55e', warnings:'#eab308', errors:'#dc2626' }[status] || '#8b8a99'; + const statusLabel = { ok:'✅ Validierung OK', warnings:'⚠ Warnungen', errors:'❌ Fehler — nicht konform' }[status] || status; + html += `
    ${statusLabel}
    `; + if (obj.file || obj.dateiname) html += `
    Datei: ${esc(obj.file || obj.dateiname)}
    `; + if (obj.format) html += `
    Format: ${esc(obj.format)}
    `; + const issues = obj.issues || obj.findings || []; + if (issues.length) { + html += '
    Befunde:'; + issues.forEach(i => { + const sev = i.severity || 'info'; + const sevColor = { error:'#dc2626', warning:'#eab308', info:'#06b6d4' }[sev] || '#8b8a99'; + html += ``; + }); + html += '
    SeverityMeldungFix
    ${esc(sev)}${esc(i.message||i.msg||'')}${esc(i.fix||'')}
    '; + } + } else if (obj.type === 'interview') { + // LIMEN — Wissens-Interview entlang Achse + if (obj.achse) html += `
    Wissens-Achse: ${esc(obj.achse)}
    `; + const fs = obj.fragen || obj.questions || []; + fs.forEach((f, i) => { + html += `
    Frage ${i+1}: ${esc(f.f || f.frage || f.q || '')}`; + if (f.tipp_aktiv_zuhören || f.tipp_zuhoeren) html += `
    👂 Tipp Aktiv-Zuhören: ${esc(f.tipp_aktiv_zuhören || f.tipp_zuhoeren)}
    `; + if (f.tipp_nachfass) html += `
    ↳ Tipp Nachfass: ${esc(f.tipp_nachfass)}
    `; + html += '
    '; + }); + } else if (obj.type === 'decode') { + // Zita — Zeugnis-Decoder + if (obj.zeugnis_text) html += `
    Zeugnis-Text:
    ${esc(obj.zeugnis_text)}
    `; + const gradeNum = (g) => { + const n = typeof g === 'number' ? g : parseFloat(String(g).replace(',','.')); + return isNaN(n) ? null : n; + }; + const gradeColor = (g) => { + const n = gradeNum(g); + if (n != null) return n <= 2 ? '#22c55e' : n <= 3 ? '#eab308' : '#dc2626'; + return ({ sehr_gut:'#22c55e', gut:'#22c55e', befriedigend:'#eab308', ausreichend:'#eab308', mangelhaft:'#dc2626', ungenuegend:'#dc2626' }[String(g||'')] || '#8b8a99'); + }; + if (obj.overall_grade != null && obj.overall_grade !== '') { + const ogColor = gradeColor(obj.overall_grade); + html += `
    Gesamt-Note: ${esc(obj.overall_grade)}${obj.grade_label ? ' — ' + esc(obj.grade_label) : ''}
    `; + } + // Sub-Noten (verhalten/schlussformel) + if (obj.verhalten_grade != null || obj.schlussformel_grade != null) { + html += '
    '; + if (obj.verhalten_grade != null) html += `Verhalten: ${esc(obj.verhalten_grade)} `; + if (obj.schlussformel_grade != null) html += ` · Schlussformel: ${esc(obj.schlussformel_grade)}`; + html += '
    '; + } + // Akzeptiere mehrere Schema-Varianten: decodierung/codes/findings + passage/quote, klartext/bedeutung/meaning, note/grade, code/section + const dec = obj.decodierung || obj.codes || obj.findings || []; + if (dec.length) { + html += '
    Code-Decodierung:'; + dec.forEach(d => { + const passage = d.passage || d.quote || d.text || ''; + const klartext = d.klartext || d.bedeutung || d.meaning || ''; + const code = d.code || d.section || ''; + const note = d.note || d.grade || ''; + const risk = d.risk || ''; + const noteColor = note !== '' ? gradeColor(note) : '#8b8a99'; + html += `
    "${esc(passage)}"
    `; + if (code) html += `
    ${esc(code)}
    `; + if (klartext) html += `
    ↳ ${esc(klartext)}
    `; + if (note !== '') html += `
    Note: ${esc(note)}${risk ? ` · Risiko: ${esc(risk)}` : ''}
    `; + html += '
    '; + }); + html += '
    '; + } + const redFlags = obj.red_flags || []; + if (redFlags.length) { + html += '
    🚩 Red Flags:
      '; + redFlags.forEach(f => { html += `
    • ${esc(f)}
    • `; }); + html += '
    '; + } + const missing = obj.missing_elements || []; + if (missing.length) { + html += '
    Fehlende Pflicht-Elemente:
      '; + missing.forEach(m => { html += `
    • ${esc(m)}
    • `; }); + html += '
    '; + } + const rewrites = obj.rewrite_suggestions || []; + if (rewrites.length) { + html += '
    Umschreib-Vorschläge:'; + rewrites.forEach(r => { + html += ``; + }); + html += '
    OriginalVorschlagWarum
    ${esc(r.original||'')}${esc(r.rewrite||'')}${esc(r.why||'')}
    '; + } + const dsources = obj.sources || obj.quellen || []; + if (dsources.length) { + html += '
    Quellen: ' + dsources.map(s => `${esc(s)}`).join(' · ') + '
    '; + } + } else if (obj.type === 'write') { + // Zita — Zeugnis-Schreiber + if (obj.role) html += `
    Rolle: ${esc(obj.role)}
    `; + if (obj.grade != null) { + const gColor = obj.grade <= 2 ? '#22c55e' : obj.grade <= 3 ? '#eab308' : '#dc2626'; + html += `
    Note: ${esc(obj.grade)}
    `; + } + const zeugnisText = obj.zeugnis || obj.zeugnis_text || obj.markdown || obj.text; + if (zeugnisText) { + html += '
    Zeugnis-Entwurf:'; + html += `
    ${esc(zeugnisText)}
    `; + html += '
    '; + } + const notenSignale = obj.noten_signale || []; + if (notenSignale.length) { + html += '
    Noten-Signale:'; + notenSignale.forEach(s => { + html += ``; + }); + html += '
    SatzCodiert
    ${esc(s.satz || '')}${esc(s.codiert || '')}
    '; + } + const paragraphen = obj.verwendete_paragraphen || obj.paragraphen || []; + if (paragraphen.length) { + html += '
    Verwendete Paragraphen: ' + paragraphen.map(p => `${esc(p)}`).join(' · ') + '
    '; + } + const warnings = obj.warnings || obj.warnungen || []; + if (warnings.length) { + html += '
    ⚠ Warnungen:
      '; + warnings.forEach(w => { html += `
    • ${esc(w)}
    • `; }); + html += '
    '; + } + const notes = obj.notes || obj.hinweise || []; + if (notes.length) { + html += '
    Hinweise:
      '; + notes.forEach(n => { html += `
    • ${esc(n)}
    • `; }); + html += '
    '; + } + const wsources = obj.sources || obj.quellen || []; + if (wsources.length) { + html += '
    Quellen: ' + wsources.map(s => `${esc(s)}`).join(' · ') + '
    '; + } + } else if (obj.type === 'calc') { + // LIBRA — Kalkulations-Rechner + if (obj.formel) html += `
    Formel: ${esc(obj.formel)}
    `; + if (obj.inputs && typeof obj.inputs === 'object') { + html += '
    Eingaben:'; + Object.entries(obj.inputs).forEach(([k,v]) => { html += ``; }); + html += '
    ${esc(k)}${esc(v)}
    '; + } + const steps = obj.schritte || obj.steps || []; + if (steps.length) { + html += '
    Rechenweg:
      '; + steps.forEach(s => { html += `
    1. ${esc(typeof s === 'string' ? s : (s.text || JSON.stringify(s)))}
    2. `; }); + html += '
    '; + } + if (obj.ergebnis != null) html += `
    Ergebnis: ${esc(obj.ergebnis)}
    `; + } else if (obj.type === 'unterweisung') { + // IDA — AdA-Unterweisung (4-Stufen / Lehrgespraech / Leittext) + if (obj.methode) html += `
    Methode: ${esc(obj.methode)}
    `; + const lz = obj.lernzielanalyse || obj.lernziele || null; + if (lz && typeof lz === 'object') { + html += '
    Lernzielanalyse:'; + if (lz.richtlernziel) html += ``; + if (lz.groblernziel) html += ``; + if (lz.feinlernziel) html += ``; + if (lz.bereich) html += ``; + html += '
    Richtlernziel${esc(lz.richtlernziel)}
    Groblernziel${esc(lz.groblernziel)}
    Feinlernziel${esc(lz.feinlernziel)}
    Bereich${esc(lz.bereich)}
    '; + } + const phasen = obj.phasen || obj.stufen || obj.phases || []; + if (phasen.length) { + let totalMin = 0; + phasen.forEach(p => { if (typeof p.minuten === 'number') totalMin += p.minuten; }); + html += `
    Phasen${totalMin?` (${totalMin} Min gesamt)`:''}:`; + phasen.forEach((p, i) => { + html += `
    ${i+1}. ${esc(p.name || p.stufe || '')}${p.minuten?` · ${esc(p.minuten)} Min`:''}
    `; + if (p.ausbilder_tut) html += `
    Ausbilder:in: ${esc(p.ausbilder_tut)}
    `; + if (p.azubi_tut) html += `
    Azubi: ${esc(p.azubi_tut)}
    `; + if (p.feedback_check) html += `
    Check: ${esc(p.feedback_check)}
    `; + html += '
    '; + }); + html += '
    '; + } + if (obj.erfolgskontrolle) { + html += `
    Erfolgskontrolle: ${esc(obj.erfolgskontrolle)}
    `; + } + const alt = obj.handlungsalternativen || obj.alternativen || []; + if (alt.length) { + html += '
    Handlungs-Alternativen:
      '; + alt.forEach(a => { html += `
    • ${esc(a)}
    • `; }); + html += '
    '; + } + } + const HINT_BY_TYPE = { + audit:'↳ Crosswalk gespeichert. Brauchst du Tech-Doku Anhang IV (VESTIGIA) oder die DPIA-Tiefe (Cora)?', + privacy_check:'↳ Bei Unsicherheit: PDL/EL informieren, Schweigepflicht §203 StGB hat Vorrang vor DSGVO.', + mail_check:'↳ Im Zweifel: nicht klicken, an IT/SOC weiterleiten. Eskalation > Schaden.', + plan:'↳ Tipp: 1. Tag-Erlebnis ist der wichtigste Baustein — ohne Wow-Moment bröckelt die Probezeit.', + validate:'↳ Bei "errors": Rechnung nicht buchen, Lieferanten kontaktieren, Validator-Output anhängen.', + interview:'↳ Story-First: lass die Person erzählen, hör 3 Sekunden nach der Antwort weiter zu.', + decode:'↳ Bei kritischen Codes: Anwält:in für Arbeitsrecht konsultieren, Zeugnis-Anfechtung prüfen.', + write:'↳ Vor Übergabe: 4-Augen-Review durch HR-Lead, Datum prüfen, korrekt unterschreiben.', + calc:'↳ Stichproben mit echten Aufträgen testen — Theorie-Marge ≠ Praxis-Marge.', + unterweisung:'↳ Methode folgt Lernziel: psychomotorisch → 4-Stufen, kognitiv → Lehrgespräch, komplex → Leittext.', + }; + const hint = HINT_BY_TYPE[obj.type] || '↳ Nutze den Quiz-, Karteikarten- oder Chat-Tab für die interaktive Version.'; + html += `
    ${hint}
    `; + html += '
    '; + return html; + } + + async function requestStructured(prompt) { + const data = await chatAPI(prompt, []); + let raw = _extractJSON(data.reply || ''); + try { return JSON.parse(raw); } catch (e1) { + try { return JSON.parse(_repairJSON(raw)); } catch (e2) { + const heal = await chatAPI(prompt + "\n\nHINWEIS: Dein letzter JSON-Output war invalid (Parse-Fehler). Antworte JETZT NUR mit sauberem JSON. Keine Kommentare, kein Markdown-Fence, keine trailing commas, alle String-Werte in doppelten Anf\u00fchrungszeichen.", []); + const healRaw = _extractJSON(heal.reply || ''); + try { return JSON.parse(healRaw); } catch (e3) { + try { return JSON.parse(_repairJSON(healRaw)); } catch (e4) { + const err = new Error('Konnte JSON nicht parsen \u2014 Modell liefert kaputtes Format. Versuch es noch mal.'); + err.original = e1.message; err.raw = raw.slice(0, 300); + throw err; + } + } + } + } + } + + // ==== Quiz ==== + const quizState = { set: null, idx: 0, correct: 0, topic: null }; + + function renderQuizIntro() { + const host = $('#quiz-host'); + const topics = CURRICULA.curricula.flatMap(c => + c.modules.map(m => ({ id: m.id, curr: c.title, title: m.title, color: c.color })) + ); + host.innerHTML = ` +
    +
    +

    🎯 Quiz-Thema wählen

    +

    Wähle ein Modul — LIBRA generiert Szenario-Fragen aus dem Handwerksalltag.

    +
    +
    + + +
    + +
    +
    +
    + `; + const pillHost = $('#quiz-topic-pills'); + CURRICULA.curricula.forEach(c => { + c.modules.forEach(m => { + const btn = document.createElement('button'); + btn.className = 'topic-pill'; + btn.dataset.curr = c.id; + btn.dataset.mod = m.id; + btn.textContent = m.title; + btn.title = c.title; + btn.addEventListener('click', () => { + $$('#quiz-topic-pills .topic-pill').forEach(p => p.setAttribute('aria-selected', 'false')); + btn.setAttribute('aria-selected', 'true'); + quizState.topic = { curr: c, mod: m }; + }); + pillHost.appendChild(btn); + }); + }); + $('#quiz-start').addEventListener('click', () => { + if (!quizState.topic) { toast('Bitte ein Thema wählen.', 'warn'); return; } + const count = parseInt($('#quiz-count').value, 10); + startQuiz(quizState.topic, count); + }); + } + + async function startQuiz(topic, count) { + const host = $('#quiz-host'); + host.innerHTML = `

    LIBRA erstellt ${count} Szenario-Fragen zu „${topic.mod.title}" …

    `; + try { + const topicText = `${topic.curr.title} — ${topic.mod.title}`; + const data = await requestStructured(`QUIZ_REQUEST topic="${topicText}" count=${count}`); + if (!data.questions || !Array.isArray(data.questions) || data.questions.length === 0) { + throw new Error('Keine Fragen erhalten'); + } + quizState.set = data.questions; + quizState.idx = 0; + quizState.correct = 0; + renderQuizQuestion(); + } catch (e) { + host.innerHTML = `

    ⚠ Konnte Quiz nicht erstellen: ${e.message}

    `; + toast('Quiz-Generierung fehlgeschlagen', 'error'); + } + } + + function renderQuizQuestion() { + const host = $('#quiz-host'); + const q = quizState.set[quizState.idx]; + if (!q) return renderQuizDone(); + const letters = ['A', 'B', 'C', 'D', 'E', 'F']; + host.innerHTML = ` +
    +
    + Frage ${quizState.idx + 1} / ${quizState.set.length} + ✓ ${quizState.correct} +
    +
    ${escapeHTML(q.q)}
    +
    + ${q.options.map((opt, i) => ` + + `).join('')} +
    + + +
    + `; + $$('#quiz-opts .quiz-option').forEach(btn => { + btn.addEventListener('click', () => handleQuizAnswer(btn, q)); + }); + } + + function handleQuizAnswer(btn, q) { + const chosen = parseInt(btn.dataset.idx, 10); + const correct = q.correct; + state.totalAnswers += 1; + const curId = quizState.topic.curr.id; + const modId = quizState.topic.mod.id; + if (!state.mastery[curId]) state.mastery[curId] = { correct: 0, total: 0 }; + state.mastery[curId].total += 1; + state.moduleTotal[modId] = (state.moduleTotal[modId] || 0) + 1; + + $$('#quiz-opts .quiz-option').forEach((b, i) => { + b.disabled = true; + if (i === correct) b.classList.add('correct'); + else if (i === chosen) b.classList.add('wrong'); + }); + const ex = $('#quiz-explain'); + ex.classList.remove('hidden'); + if (chosen === correct) { + quizState.correct += 1; + state.correctAnswers += 1; + state.quizStreak += 1; + if (state.quizStreak > state.maxQuizStreak) state.maxQuizStreak = state.quizStreak; + state.mastery[curId].correct += 1; + state.moduleCorrect[modId] = (state.moduleCorrect[modId] || 0) + 1; + addXP(10, 'Richtig!'); + ex.innerHTML = `✓ Richtig!
    ${escapeHTML(q.explain || '')} +
    + + + + +
    + `; + $$('#quiz-explain .deepdive-btn').forEach(b => b.addEventListener('click', () => quizDeepdive(b.dataset.kind, q, chosen, correct))); + } else { + state.quizStreak = 0; + ex.innerHTML = `✗ Falsch. Richtig wäre ${['A','B','C','D','E','F'][correct]}. ${escapeHTML(q.explain || '')} +
    + + + + +
    + `; + $$('#quiz-explain .deepdive-btn').forEach(b => b.addEventListener('click', () => quizDeepdive(b.dataset.kind, q, chosen, correct))); + } + saveState(); + touchActivity(); + checkBadges(); + $('#quiz-next').classList.remove('hidden'); + $('#quiz-next-btn').addEventListener('click', () => { + quizState.idx += 1; + if (quizState.idx >= quizState.set.length) renderQuizDone(); + else renderQuizQuestion(); + }); + } + + function renderQuizDone() { + const host = $('#quiz-host'); + const pct = Math.round(quizState.correct / quizState.set.length * 100); + state.completedQuizzes += 1; + // Module complete (>=80% correct in this quiz) — bonus XP + track for DSGVO-Master + if (pct >= 80 && quizState.topic) { + const modId = quizState.topic.mod.id; + if (!state.completedCurricula.includes(modId)) { + state.completedCurricula.push(modId); + addXP(20, 'Modul abgeschlossen: ' + quizState.topic.mod.title); + } + // Lernreise: Quiz mit ≥80% beendet → Step kann auto-completed werden. + // Portal-Listener entscheidet, ob er das übernimmt. + sendJourneyBeacon('quiz_passed', { module: modId, score_pct: pct }); + } + saveState(); + checkBadges(); + host.innerHTML = ` +
    +

    Quiz beendet!

    +
    ${quizState.correct} / ${quizState.set.length}
    +

    ${pct}% richtig — ${pct >= 80 ? 'Ausgezeichnet!' : pct >= 60 ? 'Solide!' : 'Probier es noch mal.'}

    +
    + + +
    +
    + `; + $('#quiz-again').addEventListener('click', () => startQuiz(quizState.topic, quizState.set.length)); + $('#quiz-new').addEventListener('click', () => renderQuizIntro()); + } + + // ==== Flashcards (SM-2 Spaced Repetition) ==== + const flashState = { topic: null, deck: null, cur: null, showBack: false }; + + function renderFlashIntro() { + const host = $('#flash-host'); + host.innerHTML = ` +
    +
    +

    🃏 Flashcards

    +

    Karteikarten zu Stundensatz-Formeln, Aufmaß, Gewerks-Spezifika — mit Spaced-Repetition.

    +
    +
    + + +
    + + +
    +
    +
    +
    + `; + const pillHost = $('#flash-topic-pills'); + CURRICULA.curricula.forEach(c => { + c.modules.forEach(m => { + const btn = document.createElement('button'); + btn.className = 'topic-pill'; + btn.textContent = m.title; + btn.title = c.title; + btn.addEventListener('click', () => { + $$('#flash-topic-pills .topic-pill').forEach(p => p.setAttribute('aria-selected', 'false')); + btn.setAttribute('aria-selected', 'true'); + flashState.topic = { curr: c, mod: m }; + updateFlashStats(); + }); + pillHost.appendChild(btn); + }); + }); + $('#flash-start').addEventListener('click', () => { + if (!flashState.topic) { toast('Bitte ein Thema wählen.', 'warn'); return; } + loadNewFlashCards(flashState.topic, parseInt($('#flash-count').value, 10)); + }); + $('#flash-review').addEventListener('click', () => { + if (!flashState.topic) { toast('Bitte ein Thema wählen.', 'warn'); return; } + startReview(flashState.topic); + }); + updateFlashStats(); + } + + function updateFlashStats() { + const el = $('#flash-stats'); + if (!el) return; + if (!flashState.topic) { el.textContent = ''; return; } + const tid = flashState.topic.mod.id; + const cards = flashCards[tid] || []; + const due = cards.filter(c => !c.due || c.due <= now()).length; + el.textContent = cards.length === 0 + ? 'Noch keine Karten in diesem Thema.' + : `${cards.length} Karten gespeichert · ${due} fällig heute`; + } + + async function loadNewFlashCards(topic, count) { + const host = $('#flash-host'); + host.innerHTML = `

    LIBRA generiert Karten zu „${topic.mod.title}" …

    `; + try { + const topicText = `${topic.curr.title} — ${topic.mod.title}`; + const data = await requestStructured(`FLASHCARD_REQUEST topic="${topicText}" count=${count}`); + if (!data.cards || !data.cards.length) throw new Error('Keine Karten erhalten'); + const tid = topic.mod.id; + if (!flashCards[tid]) flashCards[tid] = []; + for (const c of data.cards) { + flashCards[tid].push({ + front: c.front, back: c.back, hint: c.hint || '', + ef: 2.5, interval: 0, reps: 0, due: now() + }); + } + saveFlashCards(); + startReview(topic); + } catch (e) { + host.innerHTML = `

    ⚠ Fehler: ${e.message}

    `; + toast('Karten-Generierung fehlgeschlagen', 'error'); + } + } + + function startReview(topic) { + flashState.topic = topic; + const tid = topic.mod.id; + flashState.deck = (flashCards[tid] || []).filter(c => !c.due || c.due <= now()); + if (flashState.deck.length === 0) { + const host = $('#flash-host'); + host.innerHTML = `

    Keine fälligen Karten in „${escapeHTML(topic.mod.title)}"

    Lege neue Karten an oder komm später wieder.

    `; + $('#back-to-flash').addEventListener('click', renderFlashIntro); + return; + } + flashState.cur = 0; + flashState.showBack = false; + renderFlashCard(); + } + + function renderFlashCard() { + const host = $('#flash-host'); + const card = flashState.deck[flashState.cur]; + if (!card) { + host.innerHTML = `

    Review beendet 🎉

    Alle fälligen Karten in „${escapeHTML(flashState.topic.mod.title)}" durch.

    `; + $('#back-to-flash').addEventListener('click', renderFlashIntro); + return; + } + host.innerHTML = ` +
    + Karte ${flashState.cur + 1} / ${flashState.deck.length} + ${escapeHTML(flashState.topic.mod.title)} +
    +
    + ${flashState.showBack + ? `
    ${escapeHTML(card.front)}${escapeHTML(card.back)}
    ` + : `
    ${escapeHTML(card.front)}
    ${card.hint ? `
    Hinweis: ${escapeHTML(card.hint)}
    ` : ''}
    Klicken oder Leertaste drücken zum Umdrehen
    `} +
    + ${flashState.showBack ? ` +
    + + + + +
    +
    + + + + +
    + + ` : ''} + `; + $('#flash-card').addEventListener('click', flipCard); + $('#flash-card').addEventListener('keydown', (ev) => { + if (ev.key === ' ' || ev.key === 'Enter') { ev.preventDefault(); flipCard(); } + }); + $('#flash-card').focus(); + $$('.flash-btn').forEach(btn => btn.addEventListener('click', () => rateCard(parseInt(btn.dataset.rating, 10)))); + $$('.deepdive-btn').forEach(btn => btn.addEventListener('click', () => cardDeepdive(btn.dataset.kind, card))); + } + async function cardDeepdive(kind, card) { + const prompts = { + more: `Zu dieser Lernkarte — gib mir 2-3 vertiefende Informationen, die über die Karte hinausgehen. Keine Wiederholung.\n\nFrage: ${card.front}\nAntwort: ${card.back}${card.hint ? '\nHinweis: ' + card.hint : ''}\n\nMarkdown erlaubt. Kurz halten (≤180 Wörter).`, + sources: `Für diese Lernkarte — wo kann der Lerner das vertiefen? Nenne 3-5 konkrete öffentliche Quellen (Gesetzestext + Paragraph, Norm-Nummer, Fachbuch mit Autor, Paper, offizielle Webseite, IHK-Leitfaden). Je Quelle EINE Zeile. Keine Allgemeinplätze wie „Fachliteratur".\n\nFrage: ${card.front}\nAntwort: ${card.back}\n\nFormat: Markdown-Liste.`, + example: `Praxis-Beispiel zu dieser Lernkarte — ein konkretes Szenario aus dem Berufsalltag in 3-5 Sätzen, kein Theorie-Kram.\n\nFrage: ${card.front}\nAntwort: ${card.back}`, + ask: null, + }; + const host = $('#flash-deepdive'); + host.classList.remove('hidden'); + if (kind === 'ask') { + const q = window.prompt('Deine Frage zu dieser Karte:', ''); + if (!q || !q.trim()) { host.classList.add('hidden'); return; } + host.innerHTML = `
    denkt …
    `; + try { + const data = await chatAPI(`Zu dieser Lernkarte:\nFrage: ${card.front}\nAntwort: ${card.back}\n\nMeine Zusatzfrage: ${q.trim()}\n\nBeantworte präzise, Markdown erlaubt, ≤200 Wörter.`, []); + host.innerHTML = '
    ' + renderMD(data.reply || '') + '
    '; + } catch (e) { host.innerHTML = `
    Fehler: ${e.message || 'unbekannt'}
    `; } + } else { + host.innerHTML = `
    ${kind === 'sources' ? 'sucht Quellen' : kind === 'example' ? 'baut Beispiel' : 'vertieft'} …
    `; + try { + const data = await chatAPI(prompts[kind], []); + host.innerHTML = '
    ' + renderMD(data.reply || '') + '
    '; + } catch (e) { host.innerHTML = `
    Fehler: ${e.message || 'unbekannt'}
    `; } + } + const cb = $('#flash-deepdive .close-dd'); + if (cb) cb.addEventListener('click', () => host.classList.add('hidden')); + } + async function quizDeepdive(kind, q, chosenIdx, correctIdx) { + const host = $('#quiz-deepdive'); + host.classList.remove('hidden'); + const chosenTxt = q.options[chosenIdx], correctTxt = q.options[correctIdx]; + const prompts = { + more: `Gib mir zu dieser Quiz-Frage 2-3 weiterführende Infos, die über die knappe Erklärung hinausgehen. Kein Wiederholen.\n\nFrage: ${q.q}\nRichtig: ${correctTxt}\nErklärung: ${q.explain || '(—)'}\n\nMarkdown erlaubt, ≤180 Wörter.`, + sources: `Für diese Quiz-Frage — wo kann der Lerner das vertiefen? 3-5 konkrete öffentliche Quellen (Gesetzestext+Paragraph, Norm-Nummer, Fachbuch mit Autor, Paper, offizielle Webseite). Je Quelle EINE Zeile, keine Allgemeinplätze.\n\nFrage: ${q.q}\nRichtig: ${correctTxt}\n\nMarkdown-Liste.`, + why: chosenIdx === correctIdx + ? `Ich hab richtig geantwortet — erkläre mir NOCH tiefer warum das die richtige Wahl ist. Was hätte falsch gelegen bei den anderen Optionen? Je Option 1 Satz.\n\nFrage: ${q.q}\nOptionen: ${q.options.map((o,i)=>(i+1)+'. '+o).join(' | ')}\nRichtig: ${correctTxt}` + : `Ich hab „${chosenTxt}" gewählt, richtig wäre „${correctTxt}". Erkläre mir präzise wo mein Denkfehler lag und wie ich das beim nächsten Mal anders angehe. Sokratisch wenn möglich.\n\nFrage: ${q.q}\nMeine Antwort: ${chosenTxt}\nRichtig: ${correctTxt}`, + ask: null, + }; + if (kind === 'ask') { + const userQ = window.prompt('Deine Frage zu dieser Quiz-Frage:', ''); + if (!userQ || !userQ.trim()) { host.classList.add('hidden'); return; } + host.innerHTML = `
    denkt …
    `; + try { + const data = await chatAPI(`Zur Quiz-Frage: "${q.q}"\nRichtige Antwort: ${correctTxt}\nMeine Antwort: ${chosenTxt}\n\nZusatzfrage: ${userQ.trim()}\n\nBeantworte präzise, Markdown, ≤200 Wörter.`, []); + host.innerHTML = '
    ' + renderMD(data.reply || '') + '
    '; + } catch (e) { host.innerHTML = `
    Fehler: ${e.message || 'unbekannt'}
    `; } + } else { + host.innerHTML = `
    ${kind === 'sources' ? 'sucht Quellen' : kind === 'why' ? 'analysiert' : 'vertieft'} …
    `; + try { + const data = await chatAPI(prompts[kind], []); + host.innerHTML = '
    ' + renderMD(data.reply || '') + '
    '; + } catch (e) { host.innerHTML = `
    Fehler: ${e.message || 'unbekannt'}
    `; } + } + const cb = $('#quiz-deepdive .close-dd'); + if (cb) cb.addEventListener('click', () => host.classList.add('hidden')); + } + function flipCard() { + if (flashState.showBack) return; + const el = $('#flash-card'); + if (el) { el.classList.add('flipping'); setTimeout(() => { flashState.showBack = true; renderFlashCard(); }, 120); } + } + function rateCard(rating) { + const card = flashState.deck[flashState.cur]; + // SM-2 algorithm + const q = rating; // 0..3 (we map: 0→ again, 1→hard, 2→good, 3→easy) + const qMap = [0, 3, 4, 5]; // SM-2 uses 0..5, we map our 0..3 to subset + const sm2q = qMap[q]; + if (sm2q < 3) { + card.reps = 0; + card.interval = 0; + card.due = now() + 60 * 1000; // 1 min + } else { + card.reps += 1; + if (card.reps === 1) card.interval = 1; + else if (card.reps === 2) card.interval = 3; + else card.interval = Math.round(card.interval * card.ef); + card.due = now() + card.interval * 86400 * 1000; + } + card.ef = Math.max(1.3, card.ef + (0.1 - (5 - sm2q) * (0.08 + (5 - sm2q) * 0.02))); + saveFlashCards(); + + // XP + state.flashCardsRated += 1; + state.totalAnswers += 1; + if (q === 2) { addXP(5, 'Flashcard Gut'); state.correctAnswers += 1; } + else if (q === 3) { addXP(2, 'Flashcard Leicht'); state.correctAnswers += 1; } + + // Track per-run ratings for modulePassedFlash (all cards in deck rated >= Gut) + flashState.runRatings = flashState.runRatings || []; + flashState.runRatings.push(q); + + saveState(); + touchActivity(); + checkBadges(); + + flashState.showBack = false; + flashState.cur += 1; + + // If end of deck and topic set, check if all cards rated Gut/Leicht + if (flashState.cur >= flashState.deck.length && flashState.topic) { + const allGood = flashState.runRatings.length >= 3 && flashState.runRatings.every(r => r >= 2); + if (allGood) { + const modId = flashState.topic.mod.id; + state.modulePassedFlash[modId] = true; + saveState(); + checkBadges(); + } + flashState.runRatings = []; + } + + renderFlashCard(); + } + + // ==== Progress ==== + function renderProgress() { + const host = $('#progress-host'); + const li = levelInfo(); + const masteryRows = Object.entries(state.mastery) + .map(([cid, m]) => { + const c = CURRICULA.curricula.find(x => x.id === cid); + if (!c || m.total === 0) return null; + const pct = Math.round(m.correct / m.total * 100); + return { title: c.title, color: c.color, pct, correct: m.correct, total: m.total }; + }) + .filter(Boolean) + .sort((a, b) => b.pct - a.pct); + + const badges = (CURRICULA.badges || []); + host.innerHTML = ` +
    +
    +
    +
    ${state.xp}
    +
    XP gesamt
    +
    +
    +
    Lvl ${li.levelNum} · ${li.title}
    +
    ${li.next ? li.next.min - state.xp + ' XP bis ' + li.next.title : 'Top-Level erreicht'}
    +
    +
    +
    🔥 ${state.currentStreak}
    +
    Tage-Streak (max. ${state.maxStreak})
    +
    +
    +
    +
    +
    ${state.totalAnswers}
    +
    Antworten gesamt
    +
    +
    +
    ${state.totalAnswers === 0 ? '0%' : Math.round(state.correctAnswers / state.totalAnswers * 100) + '%'}
    +
    Trefferquote
    +
    +
    +
    ${state.completedQuizzes}
    +
    Quizze
    +
    +
    + + ${li.next ? ` +
    +

    Fortschritt zu „${li.next.title}"

    +
    +
    ${Math.round(li.pct)}% · ${state.xp} / ${li.next.min} XP
    +
    ` : ''} + +
    +

    Mastery pro Curriculum

    + ${masteryRows.length === 0 + ? '

    Noch keine Daten. Mach ein Quiz, um Mastery aufzubauen.

    ' + : '
    ' + masteryRows.map(r => ` +
    +
    + ${escapeHTML(r.title)} + ${r.pct}% (${r.correct}/${r.total}) +
    +
    +
    + `).join('') + '
    ' + } +
    + +
    +

    Abzeichen (${Object.keys(state.badges).length}/${badges.length})

    +
    + ${badges.map(b => { + const earned = !!state.badges[b.id]; + const icons = { award:'🏆', flame:'🔥', star:'⭐', calendar:'🗓', crown:'👑', moon:'🌙', sun:'☀️', shield:'🛡️', detective:'🕵️', clock:'⏱️', handshake:'🤝', medal:'🎖️', diamond:'💎' }; + return `
    + ${icons[b.icon] || '🎖'} +
    ${escapeHTML(b.title)}
    +
    ${escapeHTML(b.description)}
    +
    `; + }).join('')} +
    +
    + +
    +

    Daten zurücksetzen

    +

    Lokal gespeichert (kein Server-Tracking).

    + +
    +
    + `; + $('#reset-data').addEventListener('click', () => { + if (confirm('Alle lokalen Daten (XP, Streaks, Flashcards, Chat) wirklich löschen?')) { + localStorage.removeItem(LS_KEY); + localStorage.removeItem(LS_CHAT); + localStorage.removeItem(LS_FLASH); + state = loadState(); + chatHistory = []; + flashCards = {}; + restoreChat(); + renderProgress(); + toast('Daten zurückgesetzt.', 'success'); + } + }); + } + + // ==== Curriculum tree ==== + function renderCurriculum() { + const host = $('#curr-host'); + host.innerHTML = '
    '; + const root = $('#curr-tree-root'); + CURRICULA.curricula.forEach(c => { + const rootEl = document.createElement('details'); + rootEl.className = 'curr-root'; + rootEl.innerHTML = ` + + ${c.title.charAt(0)} + + ${escapeHTML(c.title)} + ${escapeHTML(c.short)} · ${c.modules.length} Module + + + +
    + ${c.modules.map(m => ` +
    + ${escapeHTML(m.title)} + +
    + `).join('')} +
    + `; + root.appendChild(rootEl); + }); + $$('.curr-mod').forEach(el => el.addEventListener('click', () => { + const c = CURRICULA.curricula.find(x => x.id === el.dataset.curr); + const m = c.modules.find(x => x.id === el.dataset.mod); + renderModuleDetail(c, m); + })); + } + + function renderModuleDetail(c, m) { + const host = $('#curr-host'); + host.innerHTML = ` +
    + +

    ${escapeHTML(m.title)}

    +

    Lernziele

    +
      ${m.objectives.map(o => `
    • ${escapeHTML(o)}
    • `).join('')}
    +

    Kernthemen

    +
      ${(m.topics || []).map(t => `
    • ${escapeHTML(t)}
    • `).join('')}
    + ${m.hours ? `

    Umfang: ~${m.hours} h

    ` : ''} +
    + + + +
    +
    + `; + $('#breadcrumb-back').addEventListener('click', renderCurriculum); + $$('.mod-actions button').forEach(b => b.addEventListener('click', () => { + const act = b.dataset.action; + if (act === 'quiz') { + quizState.topic = { curr: c, mod: m }; + switchMode('quiz'); + setTimeout(() => startQuiz({ curr: c, mod: m }, 10), 50); + } else if (act === 'flash') { + flashState.topic = { curr: c, mod: m }; + switchMode('flash'); + setTimeout(() => { + const tid = m.id; + if (flashCards[tid] && flashCards[tid].length) startReview({ curr: c, mod: m }); + else loadNewFlashCards({ curr: c, mod: m }, 10); + }, 50); + } else if (act === 'ask') { + switchMode('chat'); + $('#composer').value = `Erklär mir kurz und klar: ${m.title} (aus ${c.title}). Fokus auf ${m.topics.slice(0,3).join(', ')}.`; + $('#composer').focus(); + } + })); + } + + // ==== Mode switching ==== + function switchMode(mode) { + $$('.tab').forEach(t => { + const isActive = t.dataset.mode === mode; + t.setAttribute('aria-selected', isActive ? 'true' : 'false'); + }); + $$('.view').forEach(v => { + v.dataset.active = (v.id === 'view-' + mode) ? 'true' : 'false'; + }); + // The composer is shown only in chat mode + $('#composer-form').classList.toggle('hidden', mode !== 'chat'); + + if (mode === 'quiz') renderQuizIntro(); + if (mode === 'flash') renderFlashIntro(); + if (mode === 'progress') renderProgress(); + if (mode === 'curriculum') renderCurriculum(); + if (mode === 'chat') setTimeout(() => $('#composer').focus(), 50); + } + + // ==== Escape HTML ==== + function escapeHTML(s) { + return String(s).replace(/[&<>"']/g, ch => ({ + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' + }[ch])); + } + + // ==== Boot ==== + // Lernreise-State (URL-Params), einmal beim Boot ausgelesen. + // Wird von renderJourneyBanner() für die UI und von sendJourneyBeacon() + // für die postMessage-Kommunikation zurück zum Portal-Tab benutzt. + const JOURNEY = (() => { + try { + const p = new URLSearchParams(window.location.search); + const slug = p.get('journey'); + if (!slug) return null; + const step = parseInt(p.get('step') || '0', 10); + const total = parseInt(p.get('total') || '0', 10); + let returnOrigin = ''; + try { + if (p.get('return')) returnOrigin = new URL(p.get('return')).origin; + } catch {} + return { slug, step, total, returnOrigin }; + } catch { return null; } + })(); + + // Beacon zurück an den Portal-Tab. Funktioniert nur wenn das Widget über + // `target="_blank" rel="opener"` aus der Lernreise-Seite geöffnet wurde + // (window.opener erreichbar). Bei direktem Aufruf der Bot-URL no-op. + function sendJourneyBeacon(event, extra = {}) { + if (!JOURNEY || !window.opener) return; + if (!JOURNEY.returnOrigin) return; + try { + window.opener.postMessage({ + type: 'qognio:journey-step-event', + journey: JOURNEY.slug, + step: JOURNEY.step, + event, // 'quiz_passed' | 'curriculum_completed' | 'manual' + ...extra, + }, JOURNEY.returnOrigin); + } catch (e) { + console.warn('journey beacon failed', e); + } + } + + // Lernreise-Banner: gerendert wenn der Bot via Portal-Lernreise geöffnet wurde + // (`?journey=&step=&total=&return=`). Nicht-invasiv, weglinkbar. + function renderJourneyBanner() { + try { + const params = new URLSearchParams(window.location.search); + const journey = params.get('journey'); + const step = parseInt(params.get('step') || '0', 10); + const total = parseInt(params.get('total') || '0', 10); + const ret = params.get('return') || ''; + if (!journey || step <= 0) return; + // Dismissed for this journey/step combo? (LS sticky) + const dismissKey = 'qognio.journey-banner.dismiss.v1'; + const dismissed = JSON.parse(localStorage.getItem(dismissKey) || '{}'); + const dKey = journey + ':' + step; + if (dismissed[dKey]) return; + const bar = document.createElement('div'); + bar.id = 'qognio-journey-banner'; + bar.setAttribute('role', 'note'); + bar.style.cssText = [ + 'position:sticky', 'top:0', 'z-index:100', + 'display:flex', 'align-items:center', 'gap:.75rem', + 'padding:.55rem .9rem', + 'background:linear-gradient(90deg, rgba(124,58,237,.18), rgba(34,211,238,.10))', + 'border-bottom:1px solid rgba(124,58,237,.35)', + 'color:var(--text, #e5e7eb)', 'font-size:.875rem', + 'box-shadow:0 1px 0 rgba(0,0,0,.05)', + ].join(';'); + const human = journey.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); + bar.innerHTML = [ + '', + 'Lernreise: ' + human + '', + 'Schritt ' + step + (total > 0 ? ' von ' + total : '') + '', + '', + ret ? '↩ Zurück zur Reise' : '', + '', + ].join(''); + document.body.prepend(bar); + const btn = bar.querySelector('#qognio-journey-dismiss'); + if (btn) btn.addEventListener('click', () => { + dismissed[dKey] = Date.now(); + localStorage.setItem(dismissKey, JSON.stringify(dismissed)); + bar.remove(); + }); + } catch (e) { + console.warn('journey banner failed', e); + } + } + + async function boot() { + renderJourneyBanner(); + try { + const r = await fetch('curricula.json?v=2026-04-21'); + if (!r.ok) throw new Error('curricula.json HTTP ' + r.status); + CURRICULA = await r.json(); + } catch (e) { + toast('Curriculum konnte nicht geladen werden.', 'error', 6000); + console.error(e); + return; + } + restoreChat(); + touchActivity(); + + // Tabs + $$('.tab').forEach(tab => tab.addEventListener('click', () => switchMode(tab.dataset.mode))); + + // Welcome-card shortcuts + $$('[data-goto]').forEach(b => b.addEventListener('click', () => { + state.seenWelcome = true; saveState(); + switchMode(b.dataset.goto); + })); + + // Welcome-card prompt-fillers (special *_REQUEST modes) + $$('[data-prompt]').forEach(b => b.addEventListener('click', () => { + state.seenWelcome = true; saveState(); + switchMode('chat'); + const composer = $('#composer'); + if (composer) { + composer.value = b.dataset.prompt; + autogrow(composer); + setTimeout(() => composer.focus(), 50); + } + })); + + // Composer + const form = $('#composer-form'); + const ta = $('#composer'); + form.addEventListener('submit', (ev) => { + ev.preventDefault(); + const text = ta.value.trim(); + if (!text && pendingAttachments.length === 0) return; + ta.value = ''; + autogrow(ta); + sendChat(text); + }); + ta.addEventListener('keydown', (ev) => { + if (ev.key === 'Enter' && !ev.shiftKey) { + ev.preventDefault(); + form.requestSubmit(); + } + }); + + // Attachments + const fileInput = $('#composer-file'); + const attachBtn = $('#composer-attach'); + if (attachBtn && fileInput) { + attachBtn.addEventListener('click', () => fileInput.click()); + fileInput.addEventListener('change', async (ev) => { + await addFiles(ev.target.files); + ev.target.value = ''; + }); + } + // Drag & Drop on composer + ['dragenter', 'dragover'].forEach(evt => form.addEventListener(evt, (ev) => { + if (ev.dataTransfer && Array.from(ev.dataTransfer.types || []).includes('Files')) { + ev.preventDefault(); + form.classList.add('dragover'); + } + })); + ['dragleave', 'drop'].forEach(evt => form.addEventListener(evt, (ev) => { + if (evt === 'dragleave' && ev.target !== form) return; + form.classList.remove('dragover'); + })); + form.addEventListener('drop', async (ev) => { + if (ev.dataTransfer && ev.dataTransfer.files && ev.dataTransfer.files.length) { + ev.preventDefault(); + await addFiles(ev.dataTransfer.files); + } + }); + // Paste image / file + ta.addEventListener('paste', async (ev) => { + const items = ev.clipboardData && ev.clipboardData.files; + if (items && items.length) { + ev.preventDefault(); + await addFiles(items); + } + }); + ta.addEventListener('input', () => autogrow(ta)); + function autogrow(el) { + el.style.height = 'auto'; + el.style.height = Math.min(el.scrollHeight, 140) + 'px'; + } + + // Keyboard shortcuts Ctrl+1..5 + document.addEventListener('keydown', (ev) => { + if (!(ev.ctrlKey || ev.metaKey)) return; + const map = { '1': 'chat', '2': 'quiz', '3': 'flash', '4': 'progress', '5': 'curriculum' }; + if (map[ev.key]) { ev.preventDefault(); switchMode(map[ev.key]); } + }); + + // Default focus + ta.focus(); + // Update status once per min (visual cue) + setInterval(() => { /* placeholder for future heartbeat */ }, 60000); + + console.log('LIBRA v2026-04-25 ready. XP:', state.xp, 'Streak:', state.currentStreak); + } + + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot); + else boot(); +})(); diff --git a/www/curricula.json b/www/curricula.json new file mode 100644 index 0000000..be6ee99 --- /dev/null +++ b/www/curricula.json @@ -0,0 +1,358 @@ +{ + "version": "2026-04-25", + "updated": "2026-04-25", + "curricula": [ + { + "id": "stundensatz", + "title": "1 · Stundensatz & Lohnnebenkosten", + "short": "Vollkostendeckender SVS bilden", + "icon": "clock", + "color": "#d97706", + "description": "Vom Bruttolohn zum vollkostendeckenden Stundenverrechnungssatz: Lohnnebenkosten korrekt aufschlagen, produktive Stunden realistisch ansetzen, Gemeinkosten verteilen, Wagnis- und Gewinnzuschlag.", + "source_md": "01-stundensatz-bilden.md", + "modules": [ + { + "id": "lohnnebenkosten", + "title": "Lohnnebenkosten korrekt aufschlagen", + "objectives": [ + "Standard-Aufschlag 75-80% auf Brutto kennen", + "SOKA-Bau-Spezifikum verstehen (Faktor bis 105%)", + "Berufsgenossenschaft je Gewerk einordnen" + ], + "topics": ["Sozialabgaben", "Urlaub", "Krankheit", "BG", "SOKA-Bau"], + "difficulty": "mittel", + "source_heading": "Schritt 2 — Lohnnebenkosten aufschlagen" + }, + { + "id": "produktive-stunden", + "title": "Produktive Stunden realistisch", + "objectives": [ + "1.400-1.650 h/Jahr als Realität akzeptieren", + "Abzüge sauber rechnen (Urlaub, Krankheit, Schulung, Werkstatt)", + "Wirkung von 200 h Differenz auf SVS verstehen" + ], + "topics": ["Produktivstunden", "Auslastung", "1.436-h-Beispiel"], + "difficulty": "mittel", + "source_heading": "Schritt 3 — Produktive Stunden ermitteln" + }, + { + "id": "gemeinkosten", + "title": "Gemeinkosten verteilen", + "objectives": [ + "Werkstatt + Fuhrpark + Verwaltung + Versicherungen + Marketing erfassen", + "Pro produktiver Stunde umlegen", + "4-Mann-Beispielrechnung durchgehen (122k €/Jahr → 28,30 €/h)" + ], + "topics": ["Werkstatt", "Fuhrpark", "Verwaltung", "Umlage"], + "difficulty": "schwer", + "source_heading": "Schritt 4 — Gemeinkosten verteilen" + }, + { + "id": "wagnis-gewinn", + "title": "Wagnis- und Gewinnzuschlag", + "objectives": [ + "Wagniszuschlag 5-10% als Pflicht erkennen", + "Gewinnzuschlag 8-15% als Substanz-Erhalt verstehen", + "5%-Falle vermeiden (Substanzverzehr)" + ], + "topics": ["Wagnis", "Gewinn", "Forderungsausfall", "Substanz"], + "difficulty": "mittel", + "source_heading": "Schritt 5 — Wagniszuschlag" + }, + { + "id": "svs-formel", + "title": "SVS-Endformel + Marktvergleich", + "objectives": [ + "Vollständige SVS-Formel anwenden können", + "75-Euro-Standard kritisch hinterfragen", + "Regionale Spreizung kennen (Berlin/München vs Sachsen/Thüringen)" + ], + "topics": ["SVS-Formel", "Marktpreis", "regionale Spreizung"], + "difficulty": "schwer", + "source_heading": "Schritt 7 — Vollständige SVS-Formel" + } + ] + }, + { + "id": "material", + "title": "2 · Materialaufschlag & Skonto", + "short": "Aufschlag, Marge, Verschnitt, Skonto-Strategie", + "icon": "package", + "color": "#f59e0b", + "description": "Materialaufschlag richtig kalkulieren: Aufschlag vs. Marge, Skonto als Margen-Killer, Verschnitt-Reserve, Sondermaterial-Beschaffungspauschale, Lieferanten-Lock-in vermeiden.", + "source_md": "02-materialaufschlag.md", + "modules": [ + { + "id": "aufschlag-grundlagen", + "title": "Was deckt der Materialaufschlag", + "objectives": [ + "6 Kostenblöcke des Aufschlags benennen", + "Übliche Aufschläge nach Materialklasse kennen", + "Faustregel 25% auf EK als Standard-Mitte" + ], + "topics": ["Bestellung", "Transport", "Lager", "Verschnitt", "Vorfinanzierung", "Gewährleistung"], + "difficulty": "einfach", + "source_heading": "Was deckt der Materialaufschlag" + }, + { + "id": "aufschlag-vs-marge", + "title": "Aufschlag vs. Marge — kein Verwechslungsfehler", + "objectives": [ + "25% Aufschlag = 20% Marge sicher umrechnen", + "Bei Lieferanten-Gespräch korrekt nachfragen", + "5%-Lücke pro 100k Material berechnen können" + ], + "topics": ["Aufschlag-Formel", "Marge-Formel"], + "difficulty": "mittel", + "source_heading": "Aufschlag vs. Marge — der ewige Verwechslungsfehler" + }, + { + "id": "skonto", + "title": "Skonto-Strategie", + "objectives": [ + "Skonto immer in Kalkulation einrechnen", + "Skonto immer ziehen (Liquiditäts-Disziplin)", + "2% Skonto als 30-50% des Gewinns einordnen" + ], + "topics": ["2/14 Tage", "Kalkulationsskonto", "Liquidität"], + "difficulty": "mittel", + "source_heading": "Skonto — der unterschätzte Margenkiller" + }, + { + "id": "verschnitt", + "title": "Verschnitt-Reserve pro Material", + "objectives": [ + "Materialspezifische Verschnitt-Quoten kennen", + "Verschnitt kalkulieren, nicht abrechnen", + "Tapeten-Rapport bis 25% beachten" + ], + "topics": ["Kupferrohr", "Kabel", "Tapeten", "Fliesen", "Holz"], + "difficulty": "einfach", + "source_heading": "Verschnitt-Reserve" + } + ] + }, + { + "id": "gewerks-spezifika", + "title": "3 · Gewerke-Spezifika", + "short": "SHK / Elektro / Maler / Tischler im Detail", + "icon": "layers", + "color": "#b45309", + "description": "Pro Gewerk: typische Stundensätze, Material-Mix, Kalkulationsfallen, Rechtsrahmen (DIN VDE, DIN 1988, DIN 18363), VOB/B vs BGB, Gewährleistung 5J Bau / 2J BGB.", + "source_md": "07-rollen-spezifisch-shk-elektro-maler-tischler.md", + "modules": [ + { + "id": "shk", + "title": "SHK — Sanitär/Heizung/Klima", + "objectives": [ + "Stundensatz 75-115 EUR/h einordnen", + "DIN 1988-200 Spülung + Druckprüfung als Pflicht erkennen", + "GEG 2024 Heizungstausch-Implikationen" + ], + "topics": ["DIN 1988-200", "AVBWasserV", "GEG 2024", "Notdienst"], + "difficulty": "mittel", + "source_heading": "SHK (Sanitär-Heizung-Klima)" + }, + { + "id": "elektro", + "title": "Elektro", + "objectives": [ + "Stundensatz 68-105 EUR/h einordnen", + "DIN VDE 0100-600 Messung + Protokoll als Pflicht erkennen", + "DGUV V3 wiederkehrende Prüfung als Auftragsquelle" + ], + "topics": ["DIN VDE 0100", "DGUV V3", "TAB", "Wallbox §14a EnWG"], + "difficulty": "mittel", + "source_heading": "Elektro" + }, + { + "id": "maler", + "title": "Maler / Stuckateur", + "objectives": [ + "Stundensatz 62-95 EUR/h einordnen", + "Untergrundprüfung nach DIN 18363 als Pflicht erkennen", + "Bedenkenanzeige nach §4 Abs. 3 VOB/B sicher anwenden" + ], + "topics": ["DIN 18363", "Bedenkenanzeige", "Untergrund", "Tapeten-Rapport"], + "difficulty": "schwer", + "source_heading": "Maler / Stuckateur" + }, + { + "id": "tischler", + "title": "Tischler / Schreiner", + "objectives": [ + "Stundensatz 63-100 EUR/h einordnen", + "Werkstattzeit vs Montagezeit unterscheiden", + "Aufmaß-Toleranz vor Werkstatt-Fertigung beachten" + ], + "topics": ["DIN 18355", "Maßanfertigung", "Aufmaß", "Beschläge"], + "difficulty": "mittel", + "source_heading": "Tischler / Schreiner" + }, + { + "id": "gewerks-vergleich", + "title": "Gewerks-Vergleich Stundensatz", + "objectives": [ + "Tabelle Geselle/Meister-Sätze 2025-26 kennen", + "Regionale Spreizung beachten", + "Marktpreis ungleich kostendeckender Preis" + ], + "topics": ["Marktvergleich", "Region", "DACH 2025-26"], + "difficulty": "einfach", + "source_heading": "Aktuelle Marktsätze 2025/2026" + } + ] + }, + { + "id": "nachkalkulation", + "title": "4 · Nachkalkulation & Pareto", + "short": "Aus jedem Auftrag systematisch lernen", + "icon": "trending-up", + "color": "#22c55e", + "description": "Soll-Ist-Vergleich pro Auftrag: Stunden-Differenz, Material-Mehrverbrauch, Marge real ermitteln. Pareto-Analyse identifiziert die 3-5 wiederkehrenden Margen-Killer. Soll-Stunden-Faktor (SSF) als Profi-Kennzahl.", + "source_md": "06-nachkalkulation.md", + "modules": [ + { + "id": "soll-ist-vergleich", + "title": "Soll-Ist-Vergleich Stunden + Material", + "objectives": [ + "Stunden-Differenz pro Position erfassen", + "Material EK Soll vs Ist vergleichen (>10% = Alarm)", + "Marge in EUR und % differenziert ausweisen" + ], + "topics": ["Soll-Werte", "Ist-Werte", "Differenz-Analyse"], + "difficulty": "mittel", + "source_heading": "Die 4 Nachkalkulations-Dimensionen" + }, + { + "id": "pareto-margenanalyse", + "title": "Pareto-Analyse Margen-Killer", + "objectives": [ + "10 schlechteste Aufträge pro Quartal clustern", + "3-5 wiederkehrende Posten identifizieren", + "Systematische Kalkulationslücke schließen" + ], + "topics": ["80/20", "Cluster", "wiederkehrende Posten"], + "difficulty": "schwer", + "source_heading": "Pareto-Analyse — die 80/20-Regel der Margen-Killer" + }, + { + "id": "ssf-kennzahl", + "title": "Soll-Stunden-Faktor (SSF)", + "objectives": [ + "SSF berechnen: Ist-Stunden / Soll-Stunden", + "Zielkorridor 1,05-1,15 als guter Wert", + ">1,30 als Anlass für Stunden-Sätze-Neuerhebung" + ], + "topics": ["SSF", "Lerngeschwindigkeit", "Kalkulations-Reife"], + "difficulty": "schwer", + "source_heading": "Soll-Stunden-Faktor (SSF)" + }, + { + "id": "cluster-analyse", + "title": "Auftragstyp-Cluster analysieren", + "objectives": [ + "Aufträge nach Typ aggregieren (Bad-Sanierung, Notdienst, etc.)", + "Cluster mit chronisch niedriger Marge erkennen", + "Strategische Entscheidung Kalkulation anpassen vs Cluster abstoßen" + ], + "topics": ["Cluster", "Auftragstyp", "Marge-Trend"], + "difficulty": "schwer", + "source_heading": "Nachkalkulation pro Gewerk-/Auftragstyp clustern" + } + ] + }, + { + "id": "fallen", + "title": "5 · Typische Fallen & Verträge", + "short": "12 Kalkulationsfallen + VOB/B vs BGB", + "icon": "alert-triangle", + "color": "#ef4444", + "description": "Die 12 häufigsten Kalkulationsfallen aller Gewerke plus Vertragsformen: BGB-Werkvertrag (5J Gewährleistung Bau, 2J andere) vs VOB/B (4J Bau, neue 2J nach Mängelbeseitigung). Eventualpositionen, Stundenlohnarbeiten, Bedenkenanzeige.", + "source_md": "05-typische-kalkulationsfallen.md", + "modules": [ + { + "id": "fallen-allgemein", + "title": "Allgemeine Kalkulationsfallen", + "objectives": [ + "Lohnnebenkosten zu niedrig (Faktor 1,3 vs 1,78)", + "Wagniszuschlag fehlt komplett", + "Skonto eingerechnet aber nicht gezogen" + ], + "topics": ["Lohnnebenkosten", "Wagnis", "Skonto", "Gerüst", "Anfahrt"], + "difficulty": "mittel", + "source_heading": "Allgemein (alle Gewerke)" + }, + { + "id": "fallen-gewerk", + "title": "Gewerks-spezifische Fallen", + "objectives": [ + "SHK: Spülung/Druckprüfung im Stundenlohn versteckt", + "Elektro: Abschlussmessung nicht extra ausgewiesen", + "Maler: Untergrundprüfung im m²-Preis versteckt", + "Tischler: Aufmaß-Toleranz nicht eingerechnet" + ], + "topics": ["Spülung", "DGUV V3", "Untergrund", "Aufmaß-Toleranz"], + "difficulty": "schwer", + "source_heading": "SHK-spezifisch" + }, + { + "id": "vob-bgb", + "title": "VOB/B vs BGB-Werkvertrag", + "objectives": [ + "Geltung: VOB nur wenn vereinbart", + "Gewährleistung: 5J BGB Bau / 4J VOB Bau / 2J andere", + "Frist-Lauf: VOB neue 2J nach Mängelbeseitigung" + ], + "topics": ["BGB §631", "VOB/B §13", "Gewährleistung", "Mängelrüge"], + "difficulty": "mittel", + "source_heading": "VOB/B vs. BGB — wann was?" + }, + { + "id": "vob-c-praxis", + "title": "VOB/C — Aufmaß + Eventualpositionen", + "objectives": [ + "DIN 18299 Allgemeine Regelungen kennen", + "OZ-Nummern hierarchisch aufbauen", + "Eventualpositionen bei unklaren Mengen sinnvoll einsetzen", + "Stundenlohnarbeiten nach §15 VOB/B vorab vereinbaren" + ], + "topics": ["DIN 18299", "OZ-Nummern", "Eventualpositionen", "Stundenlohnarbeiten"], + "difficulty": "schwer", + "source_heading": "DIN 18299 — die wichtigsten 5 Abschnitte" + }, + { + "id": "preisdruecker", + "title": "Preisdrücker-Kunden + Notdienst-Tarife", + "objectives": [ + "Wert statt Rabatt anbieten", + "Roter Stoppschild-Satz formulieren", + "Notdienst mit 100-150% Zuschlag korrekt kalkulieren" + ], + "topics": ["Preisdrücker", "Skonto-Geste", "Notdienst-Zuschlag"], + "difficulty": "mittel", + "source_heading": "Wie reagiere ich auf Preisdrücker-Kunden?" + } + ] + } + ], + "badges": [ + {"id": "erste_kalkulation", "title": "Erste Kalkulation", "icon": "calculator", "description": "1. Quiz im Stundensatz-Modul erfolgreich."}, + {"id": "gewerks_meister", "title": "Gewerks-Meister", "icon": "hammer", "description": "5 Quiz im Gewerke-Spezifika-Modul korrekt."}, + {"id": "marge_optimierer", "title": "Marge-Optimierer", "icon": "trending-up", "description": "3 Quiz im Nachkalkulations-Modul korrekt."}, + {"id": "fallen_kenner", "title": "Fallen-Kenner", "icon": "alert-triangle", "description": "5 Quiz im Fallen-Modul korrekt — kennt die typischen Margen-Killer."}, + {"id": "libra_meister", "title": "LIBRA-Meister", "icon": "crown", "description": "Alle 5 Curricula mit >=80% abgeschlossen."}, + {"id": "streak_7", "title": "Wochenstreak", "icon": "flame", "description": "7 Tage in Folge aktiv gewesen."}, + {"id": "night_owl", "title": "Nachteule", "icon": "moon", "description": "Nach 22 Uhr gelernt."}, + {"id": "early_bird", "title": "Frühaufsteher", "icon": "sun", "description": "Vor 7 Uhr gelernt."} + ], + "levels": [ + {"min": 0, "title": "Lehrling"}, + {"min": 50, "title": "Geselle"}, + {"min": 200, "title": "Vorarbeiter"}, + {"min": 500, "title": "Meister"}, + {"min": 1250, "title": "Betriebsleiter"}, + {"min": 2500, "title": "Inhaber"}, + {"min": 5000, "title": "Handwerks-Unternehmer"} + ] +} diff --git a/www/index.html b/www/index.html new file mode 100644 index 0000000..3725bfb --- /dev/null +++ b/www/index.html @@ -0,0 +1,125 @@ + + + + + LIBRA · Dein Handwerks-Kalkulationscoach + + + + + + + +
    + +
    +
    + + LIBRA Kalkulationscoach +
    +
    + Online +
    + + + +
    + +
    + + +
    +
    + + +
    +
    +
    + + +
    +
    +
    + + +
    +
    +
    + + +
    +
    +
    +
    + +
    +
    +
    + + + + +
    +
    + +
    + Sovereign AI · Deutscher Bunker · Qognio  ·  DSGVO-konform · Keine externen Fonts · Keine Cookies +
    +
    + +
    + + + + diff --git a/www/styles.css b/www/styles.css new file mode 100644 index 0000000..98c881e --- /dev/null +++ b/www/styles.css @@ -0,0 +1,1038 @@ +/* LIBRA — Handwerks-Kalkulations-Coach Widget */ +:root { + --bg: #0a0a0f; + --bg-elev: #13131a; + --bg-elev-2: #1a1a24; + --border: rgba(255, 255, 255, 0.08); + --border-strong: rgba(255, 255, 255, 0.16); + --text: #f1f0f5; + --text-dim: #a9a8b6; + --text-mute: #6b6a78; + --accent: #d97706; + --accent-2: #f59e0b; + --accent-dim: rgba(217, 119, 6, 0.15); + --accent-strong: rgba(217, 119, 6, 0.45); + --success: #22c55e; + --warn: #f59e0b; + --danger: #ef4444; + --radius: 14px; + --radius-sm: 8px; + --shadow: 0 6px 24px rgba(0, 0, 0, 0.35); +} + +* { box-sizing: border-box; margin: 0; padding: 0; } +html, body { height: 100%; } +body { + font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + background: var(--bg); + color: var(--text); + -webkit-font-smoothing: antialiased; + overflow: hidden; +} + +/* Layout */ +.app { + display: grid; + grid-template-rows: auto auto 1fr auto; + height: 100vh; + max-width: 980px; + margin: 0 auto; +} + +/* Header */ +.topbar { + padding: 0.9rem 1.25rem; + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + gap: 0.75rem; + background: var(--bg); + z-index: 10; +} +.brand { + display: flex; + align-items: center; + gap: 0.55rem; + font-weight: 800; + letter-spacing: -0.02em; + font-size: 1.05rem; +} +.brand-icon { + width: 30px; height: 30px; + display: grid; place-items: center; + background: linear-gradient(140deg, #d97706 0%, #b45309 100%); + border-radius: 9px; + font-size: 1rem; +} +.brand small { font-weight: 500; color: var(--text-dim); font-size: 0.72rem; margin-left: .25rem; } +.spacer { flex: 1; } +.status { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.25rem 0.6rem; + border-radius: 999px; + background: rgba(16, 185, 129, 0.12); + color: var(--success); + font-size: 0.7rem; + font-weight: 500; + border: 1px solid rgba(16, 185, 129, 0.28); +} +.status::before { + content: ""; + width: 6px; height: 6px; + border-radius: 50%; + background: var(--success); + animation: pulse 2s infinite; +} +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.35; } +} + +/* Tab bar */ +.tabbar { + display: grid; + grid-template-columns: repeat(5, 1fr); + border-bottom: 1px solid var(--border); + background: var(--bg); + position: sticky; + top: 0; + z-index: 5; +} +.tab { + padding: 0.75rem 0.5rem; + text-align: center; + font-size: 0.85rem; + font-weight: 500; + color: var(--text-dim); + background: none; + border: none; + border-bottom: 2px solid transparent; + cursor: pointer; + font-family: inherit; + transition: all 0.18s ease; + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; +} +.tab:hover { color: var(--text); background: rgba(255, 255, 255, 0.02); } +.tab[aria-selected="true"] { + color: var(--accent); + border-bottom-color: var(--accent); + background: var(--accent-dim); +} +.tab-kbd { + font-size: 0.6rem; + color: var(--text-mute); + font-weight: 400; +} + +/* Main + views */ +.main { + overflow: hidden; + position: relative; +} +.view { + position: absolute; + inset: 0; + padding: 1rem 1.25rem; + overflow-y: auto; + opacity: 0; + transform: translateY(8px); + pointer-events: none; + transition: opacity 0.22s ease, transform 0.22s ease; +} +.view[data-active="true"] { + opacity: 1; + transform: translateY(0); + pointer-events: auto; +} + +/* Chat */ +.chat-box { + display: flex; + flex-direction: column; + gap: 0.6rem; + padding: 0.25rem 0; +} +.msg { + max-width: 88%; + padding: 0.7rem 0.95rem; + border-radius: 16px; + line-height: 1.55; + font-size: 0.94rem; + word-wrap: break-word; +} +.msg.user { + background: var(--accent-dim); + border: 1px solid var(--accent-strong); + align-self: flex-end; + border-bottom-right-radius: 4px; +} +.msg.bot { + background: var(--bg-elev); + border: 1px solid var(--border); + align-self: flex-start; + border-bottom-left-radius: 4px; +} +.msg.sys { + align-self: center; + color: var(--text-mute); + font-size: 0.78rem; + font-style: italic; + padding: 0.15rem 0; + background: none; + border: none; + max-width: 100%; + text-align: center; +} +.msg h1, .msg h2, .msg h3 { margin: 0.45rem 0 0.3rem; font-size: 1.02rem; } +.msg p { margin: 0.3rem 0; } +.msg ul, .msg ol { margin: 0.25rem 0 0.3rem 1.2rem; } +.msg li { margin: 0.1rem 0; } +.msg strong { color: #fcd34d; font-weight: 600; } +.msg em { color: #d6d3de; } +.msg code { + font-family: ui-monospace, Menlo, monospace; + background: rgba(255, 255, 255, 0.08); + padding: 0.05rem 0.35rem; + border-radius: 3px; + font-size: 0.85em; +} +.msg pre { + background: rgba(0, 0, 0, 0.4); + padding: 0.7rem 0.85rem; + border-radius: 8px; + overflow-x: auto; + margin: 0.35rem 0; + border: 1px solid var(--border); +} +.msg pre code { + background: none; + padding: 0; + font-size: 0.82em; +} +.msg a { color: var(--accent); text-decoration: underline; } + +.dots { display: inline-flex; gap: 0.28rem; padding: 0.3rem 0; } +.dots span { + width: 7px; height: 7px; + background: var(--text-dim); + border-radius: 50%; + animation: bounce 1.3s ease-in-out infinite; +} +.dots span:nth-child(2) { animation-delay: 0.15s; } +.dots span:nth-child(3) { animation-delay: 0.3s; } +@keyframes bounce { + 0%, 80%, 100% { opacity: 0.3; transform: scale(0.75); } + 40% { opacity: 1; transform: scale(1); } +} + +/* Composer */ +.composer { + padding: 0.75rem 1.25rem 1rem; + border-top: 1px solid var(--border); + background: var(--bg); + display: flex; + flex-direction: column; + gap: 0.5rem; +} +.composer.dragover { + background: color-mix(in srgb, var(--accent) 10%, var(--bg)); + outline: 2px dashed var(--accent); + outline-offset: -8px; +} +.composer-row { + display: flex; + gap: 0.5rem; + align-items: flex-end; +} +.composer textarea { + flex: 1; + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: inherit; + font: inherit; + padding: 0.7rem 0.95rem; + resize: none; + min-height: 46px; + max-height: 140px; + line-height: 1.5; +} +.composer textarea:focus { + outline: 2px solid var(--accent-strong); + outline-offset: -1px; +} +.btn-attach { + min-height: 46px; + width: 46px; + border: 1px solid var(--border); + background: var(--bg-elev); + color: var(--text); + border-radius: var(--radius-sm); + cursor: pointer; + font-size: 1.2rem; + line-height: 1; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: background .12s, border-color .12s; +} +.btn-attach:hover { background: var(--accent); color: #fff; border-color: var(--accent); } +.btn-attach:focus-visible { outline: 2px solid var(--accent-strong); outline-offset: 2px; } + +.attach-strip { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; +} +.attach-strip:empty { display: none; } +.attach-chip { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.3rem 0.55rem; + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: 999px; + font-size: 0.78rem; + max-width: 280px; +} +.attach-chip.is-error { border-color: var(--danger, #b91c1c); color: var(--danger, #b91c1c); } +.attach-chip-name { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 180px; +} +.attach-chip-size { color: var(--text-mute); font-variant-numeric: tabular-nums; } +.attach-chip-remove { + background: transparent; + border: 0; + color: inherit; + cursor: pointer; + font-size: 1rem; + line-height: 1; + padding: 0 0.1rem; + opacity: 0.6; +} +.attach-chip-remove:hover { opacity: 1; } +.msg .msg-attachments { + margin-top: 0.4rem; + display: flex; + flex-wrap: wrap; + gap: 0.3rem; +} +.msg .msg-attachments .att-name { + font-size: 0.75rem; + padding: 0.15rem 0.5rem; + background: rgba(255,255,255,0.18); + border-radius: 4px; +} +.msg.user .msg-attachments .att-name { background: rgba(255,255,255,0.3); } +.attachment-notice { + margin-top: 0.4rem; + font-size: 0.75rem; + color: var(--text-mute); + font-style: italic; +} +.btn-primary { + background: var(--accent); + color: #fff; + border: none; + padding: 0 1.25rem; + min-height: 46px; + border-radius: var(--radius-sm); + font-weight: 600; + cursor: pointer; + font-family: inherit; + font-size: 0.9rem; + transition: background 0.15s; +} +.btn-primary:hover:not(:disabled) { background: #b45309; } +.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; } +.btn-sec { + background: var(--bg-elev); + color: var(--text); + border: 1px solid var(--border-strong); + padding: 0.55rem 0.9rem; + border-radius: var(--radius-sm); + font-weight: 500; + cursor: pointer; + font-family: inherit; + font-size: 0.85rem; + transition: background 0.15s; +} +.btn-sec:hover { background: var(--bg-elev-2); } +.btn-ghost { + background: none; + color: var(--text-dim); + border: 1px solid transparent; + padding: 0.35rem 0.6rem; + border-radius: 6px; + font-family: inherit; + cursor: pointer; + font-size: 0.82rem; +} +.btn-ghost:hover { color: var(--text); background: rgba(255, 255, 255, 0.04); } + +/* Welcome screen */ +.welcome { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 80%; + padding: 1.5rem; + text-align: center; +} +.welcome h2 { + font-size: 1.5rem; + letter-spacing: -0.02em; + margin-bottom: 0.5rem; + background: linear-gradient(140deg, #d97706, #f59e0b); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} +.welcome p { + color: var(--text-dim); + margin-bottom: 1.25rem; + max-width: 38rem; + line-height: 1.55; +} +.mode-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 0.75rem; + width: 100%; + max-width: 42rem; + margin-bottom: 1rem; +} +.mode-card { + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 0.9rem; + text-align: left; + cursor: pointer; + transition: all 0.18s; + font-family: inherit; + color: inherit; +} +.mode-card:hover { + border-color: var(--accent-strong); + background: var(--bg-elev-2); + transform: translateY(-2px); +} +.mode-card strong { display: block; font-size: 0.95rem; margin-bottom: 0.2rem; } +.mode-card span { color: var(--text-dim); font-size: 0.8rem; } + +/* Quiz */ +.quiz-intro { + display: flex; + flex-direction: column; + gap: 0.75rem; + max-width: 640px; + margin: 0 auto; +} +.topic-select { + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1rem; +} +.topic-select h3 { margin-bottom: 0.5rem; font-size: 1rem; } +.topic-btn-row { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + margin-top: 0.5rem; +} +.topic-pill { + background: var(--bg-elev-2); + border: 1px solid var(--border); + color: var(--text); + padding: 0.4rem 0.8rem; + border-radius: 999px; + font-family: inherit; + font-size: 0.8rem; + cursor: pointer; + transition: all 0.15s; +} +.topic-pill:hover { + border-color: var(--accent-strong); + background: var(--accent-dim); +} +.topic-pill[aria-selected="true"] { + background: var(--accent-dim); + border-color: var(--accent); + color: var(--accent); +} +.quiz-card { + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1.25rem; + max-width: 640px; + margin: 0 auto; +} +.quiz-progress { + display: flex; + justify-content: space-between; + color: var(--text-dim); + font-size: 0.8rem; + margin-bottom: 0.75rem; +} +.quiz-q { + font-size: 1.05rem; + line-height: 1.55; + margin-bottom: 1rem; +} +.quiz-options { display: flex; flex-direction: column; gap: 0.5rem; } +.quiz-option { + background: var(--bg-elev-2); + border: 1px solid var(--border); + color: var(--text); + padding: 0.75rem 1rem; + border-radius: var(--radius-sm); + text-align: left; + font-family: inherit; + font-size: 0.92rem; + cursor: pointer; + transition: all 0.15s; + display: flex; + align-items: center; + gap: 0.75rem; +} +.quiz-option:hover:not(:disabled) { + border-color: var(--accent-strong); + background: var(--accent-dim); +} +.quiz-option .opt-letter { + width: 24px; height: 24px; + display: inline-grid; + place-items: center; + background: var(--bg); + border-radius: 6px; + font-size: 0.75rem; + font-weight: 600; + flex-shrink: 0; +} +.quiz-option.correct { + background: rgba(16, 185, 129, 0.12); + border-color: rgba(16, 185, 129, 0.5); + color: var(--success); +} +.quiz-option.correct .opt-letter { background: var(--success); color: #000; } +.quiz-option.wrong { + background: rgba(239, 68, 68, 0.1); + border-color: rgba(239, 68, 68, 0.5); + color: var(--danger); +} +.quiz-option.wrong .opt-letter { background: var(--danger); color: #fff; } +.quiz-option:disabled { cursor: default; } +.quiz-explain { + margin-top: 1rem; + padding: 0.75rem; + background: var(--bg); + border-left: 3px solid var(--accent); + border-radius: 4px; + font-size: 0.88rem; + line-height: 1.55; + color: var(--text-dim); +} +.quiz-explain strong { color: var(--text); } +.quiz-next { + margin-top: 1rem; + display: flex; + justify-content: flex-end; +} +.quiz-done { + text-align: center; + padding: 1.5rem; +} +.quiz-done h3 { font-size: 1.5rem; margin-bottom: 0.5rem; } +.quiz-done .score { + font-size: 3rem; + font-weight: 700; + background: linear-gradient(140deg, #d97706, #f59e0b); + -webkit-background-clip: text; + background-clip: text; + color: transparent; + margin: 1rem 0; +} +.quiz-done .actions { + display: flex; + gap: 0.5rem; + justify-content: center; + margin-top: 1rem; +} + +/* Flashcards */ +.flash-intro { max-width: 640px; margin: 0 auto; } +.flashcard { + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 2rem 1.5rem; + max-width: 640px; + margin: 0 auto; + min-height: 280px; + display: flex; + flex-direction: column; + justify-content: center; + text-align: center; + cursor: pointer; + transition: transform 0.3s ease, opacity 0.2s; + user-select: none; +} +.flashcard:hover { border-color: var(--accent-strong); } +.flashcard.flipping { opacity: 0.6; transform: scale(0.98); } +.flashcard-front { + font-size: 1.15rem; + font-weight: 500; + line-height: 1.5; +} +.flashcard-back { + font-size: 0.98rem; + line-height: 1.6; + color: var(--text-dim); +} +.flashcard-back strong { color: var(--text); display: block; margin-bottom: 0.5rem; } +.flashcard-hint { + margin-top: 1rem; + font-size: 0.8rem; + color: var(--text-mute); + font-style: italic; +} +.flash-controls { + max-width: 640px; + margin: 1rem auto 0; + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 0.4rem; +} +.flash-btn { + padding: 0.7rem 0.4rem; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + background: var(--bg-elev); + color: var(--text); + font-family: inherit; + font-size: 0.82rem; + cursor: pointer; + font-weight: 500; + transition: all 0.15s; +} +.flash-btn:hover { background: var(--bg-elev-2); } +.flash-btn .label { display: block; font-size: 0.72rem; color: var(--text-mute); margin-top: 2px; } +.flash-btn[data-rating="0"]:hover { border-color: var(--danger); color: var(--danger); } +.flash-btn[data-rating="1"]:hover { border-color: var(--warn); color: var(--warn); } +.flash-btn[data-rating="2"]:hover { border-color: var(--accent); color: var(--accent); } +.flash-btn[data-rating="3"]:hover { border-color: var(--success); color: var(--success); } + +.flash-meta { + max-width: 640px; + margin: 0.5rem auto 0; + display: flex; + justify-content: space-between; + color: var(--text-mute); + font-size: 0.78rem; +} + +/* Progress */ +.progress-grid { + display: grid; + gap: 1rem; + max-width: 640px; + margin: 0 auto; + padding-bottom: 1rem; +} +.stat-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: 0.75rem; +} +.stat-card { + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 0.9rem; + text-align: center; +} +.stat-card .val { + font-size: 1.8rem; + font-weight: 700; + letter-spacing: -0.02em; +} +.stat-card .lbl { + color: var(--text-dim); + font-size: 0.78rem; + margin-top: 0.2rem; +} +.stat-card.accent .val { color: var(--accent); } +.stat-card.streak .val { color: var(--warn); } +.stat-card.level .val { color: var(--success); font-size: 1.2rem; } + +.section-card { + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1rem; +} +.section-card h3 { + font-size: 0.95rem; + margin-bottom: 0.75rem; + letter-spacing: -0.01em; +} +.mastery-row { + display: flex; + flex-direction: column; + gap: 0.55rem; +} +.mastery-bar { + display: flex; + flex-direction: column; + gap: 0.25rem; +} +.mastery-head { + display: flex; + justify-content: space-between; + font-size: 0.8rem; +} +.mastery-head .pct { color: var(--accent); font-weight: 600; } +.bar-bg { + height: 6px; + background: var(--bg-elev-2); + border-radius: 3px; + overflow: hidden; +} +.bar-fg { + height: 100%; + background: linear-gradient(90deg, var(--accent) 0%, #f59e0b 100%); + border-radius: 3px; + transition: width 0.4s ease; +} + +.badge-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); + gap: 0.6rem; +} +.badge { + background: var(--bg-elev-2); + border: 1px solid var(--border); + border-radius: 10px; + padding: 0.75rem; + text-align: center; + transition: all 0.2s; +} +.badge.earned { + border-color: var(--accent-strong); + background: var(--accent-dim); +} +.badge.locked { opacity: 0.4; } +.badge .icon { + font-size: 1.6rem; + margin-bottom: 0.35rem; + display: block; +} +.badge .title { font-size: 0.78rem; font-weight: 600; } +.badge .desc { font-size: 0.7rem; color: var(--text-mute); margin-top: 0.15rem; line-height: 1.3; } + +/* Curriculum tree */ +.curr-tree { max-width: 720px; margin: 0 auto; } +.curr-root { + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: var(--radius); + margin-bottom: 0.6rem; + overflow: hidden; +} +.curr-root-head { + width: 100%; + background: none; + border: none; + color: var(--text); + padding: 0.85rem 1rem; + display: flex; + align-items: center; + gap: 0.75rem; + cursor: pointer; + font-family: inherit; + font-size: 0.95rem; + text-align: left; +} +.curr-root-head:hover { background: rgba(255, 255, 255, 0.02); } +.curr-root-head .ic { + width: 34px; height: 34px; + border-radius: 8px; + display: grid; + place-items: center; + font-size: 1.1rem; + color: #fff; + font-weight: 700; + flex-shrink: 0; +} +.curr-root-head .txt { flex: 1; } +.curr-root-head .txt strong { display: block; } +.curr-root-head .txt small { color: var(--text-dim); font-size: 0.78rem; } +.curr-root-head .chev { + transition: transform 0.2s; + color: var(--text-dim); +} +.curr-root[open] .curr-root-head .chev { transform: rotate(180deg); } +.curr-mods { + padding: 0.2rem 0 0.6rem; + border-top: 1px solid var(--border); +} +.curr-mod { + padding: 0.55rem 1rem 0.55rem 3.75rem; + border-left: 2px solid transparent; + cursor: pointer; + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.5rem; + transition: all 0.15s; +} +.curr-mod:hover { + background: rgba(5, 150, 105, 0.05); + border-left-color: var(--accent); +} +.curr-mod .m-title { + font-size: 0.88rem; + line-height: 1.3; +} +.curr-mod .m-arrow { + color: var(--text-mute); + font-size: 0.9rem; +} +.mod-detail { + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1.25rem; + max-width: 720px; + margin: 0 auto; +} +.mod-detail h3 { margin-bottom: 0.5rem; letter-spacing: -0.01em; } +.mod-detail .breadcrumb { + font-size: 0.8rem; + color: var(--text-dim); + margin-bottom: 0.75rem; +} +.mod-detail .breadcrumb button { + background: none; + border: none; + color: var(--accent); + cursor: pointer; + font-family: inherit; + padding: 0; + font-size: inherit; +} +.mod-detail h4 { margin: 1rem 0 0.35rem; font-size: 0.9rem; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.05em; } +.mod-detail ul { margin-left: 1.2rem; color: var(--text); } +.mod-detail li { margin: 0.25rem 0; line-height: 1.5; } +.mod-actions { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + margin-top: 1.25rem; +} + +/* Footer */ +.footer { + padding: 0.55rem 1.25rem; + text-align: center; + color: var(--text-mute); + font-size: 0.7rem; + border-top: 1px solid var(--border); +} +.footer a { color: var(--accent); text-decoration: none; } + +/* Toast */ +.toast-stack { + position: fixed; + top: 1rem; + right: 1rem; + display: flex; + flex-direction: column; + gap: 0.5rem; + z-index: 100; + pointer-events: none; +} +.toast { + background: var(--bg-elev); + border: 1px solid var(--border); + border-left: 3px solid var(--accent); + border-radius: var(--radius-sm); + padding: 0.7rem 0.9rem; + font-size: 0.85rem; + max-width: 300px; + box-shadow: var(--shadow); + animation: toast-in 0.25s ease; + pointer-events: auto; +} +.toast.error { border-left-color: var(--danger); } +.toast.success { border-left-color: var(--success); } +.toast.warn { border-left-color: var(--warn); } +@keyframes toast-in { + from { opacity: 0; transform: translateX(20px); } + to { opacity: 1; transform: translateX(0); } +} + +/* XP gain animation */ +.xp-gain { + position: fixed; + bottom: 5rem; + left: 50%; + transform: translateX(-50%); + background: linear-gradient(140deg, #d97706, #f59e0b); + color: #fff; + padding: 0.5rem 1rem; + border-radius: 999px; + font-weight: 600; + font-size: 0.9rem; + pointer-events: none; + animation: xp-fly 1.5s ease-out forwards; + z-index: 50; +} +@keyframes xp-fly { + 0% { opacity: 0; transform: translate(-50%, 20px) scale(0.8); } + 20% { opacity: 1; transform: translate(-50%, 0) scale(1); } + 80% { opacity: 1; transform: translate(-50%, -40px) scale(1); } + 100% { opacity: 0; transform: translate(-50%, -60px) scale(0.9); } +} + +/* Responsive */ +@media (max-width: 640px) { + .topbar { padding: 0.7rem 0.9rem; } + .brand { font-size: 0.95rem; } + .brand small { display: none; } + .tabbar { grid-template-columns: repeat(5, 1fr); } + .tab { font-size: 0.75rem; padding: 0.6rem 0.2rem; } + .tab-kbd { display: none; } + .view { padding: 0.75rem 0.9rem; } + .composer { padding: 0.6rem 0.9rem 0.75rem; } + .msg { max-width: 92%; font-size: 0.9rem; } + .quiz-card, .flashcard, .mod-detail { padding: 1rem; } + .quiz-q { font-size: 0.98rem; } + .flash-controls { grid-template-columns: repeat(2, 1fr); } + .stat-card .val { font-size: 1.45rem; } + .welcome h2 { font-size: 1.25rem; } + .welcome p { font-size: 0.88rem; } +} + +/* Focus visible */ +:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +/* Utility */ +.hidden { display: none !important; } +.flex-row { display: flex; align-items: center; gap: 0.5rem; } +.mono { font-family: ui-monospace, Menlo, monospace; } + +/* GFM table (added 2026-04-24) */ +.md-table { width:100%; border-collapse:collapse; margin:.7rem 0; font-size:.92em; background:rgba(255,255,255,0.02); border:1px solid rgba(255,255,255,0.08); border-radius:8px; overflow:hidden; } +.md-table thead { background:rgba(217,119,6,0.12); } +.md-table th, .md-table td { padding:.55rem .8rem; border-bottom:1px solid rgba(255,255,255,0.06); text-align:left; vertical-align:top; } +.md-table th { color:#d97706; font-weight:600; font-size:.85em; letter-spacing:.02em; text-transform:uppercase; } +.md-table tbody tr:last-child td { border-bottom:none; } +.md-table tbody tr:hover { background:rgba(255,255,255,0.03); } +.md-table code { font-size:.92em; padding:1px 5px; } +.msg.bot .md-table, .chat-message.bot .md-table { display:block; overflow-x:auto; max-width:100%; } + + +/* Deep-Dive bar (flashcards + quiz) — 2026-04-25 */ +.deepdive-bar { + display: flex; + flex-wrap: wrap; + gap: .4rem; + margin-top: .8rem; + padding-top: .6rem; + border-top: 1px dashed rgba(255,255,255,0.08); +} +.deepdive-btn { + font-size: .78em; + padding: .4rem .65rem; + background: rgba(255,255,255,0.04); + border: 1px solid rgba(255,255,255,0.1); + color: var(--text, #f1f0f5); + border-radius: 6px; + cursor: pointer; + transition: background 0.15s, border-color 0.15s; +} +.deepdive-btn:hover { + background: rgba(255,255,255,0.08); + border-color: rgba(255,255,255,0.2); +} +.deepdive-panel { + margin-top: .7rem; + padding: .9rem 1rem; + background: rgba(255,255,255,0.04); + border: 1px solid rgba(255,255,255,0.12); + border-radius: 8px; + font-size: .92em; + line-height: 1.55; +} +.deepdive-panel.hidden { display: none; } +.deepdive-panel .dd-loading { display: flex; align-items: center; gap: .6rem; color: var(--text-mute, #888); font-style: italic; } +.deepdive-panel .dd-body { margin-bottom: .7rem; } +.deepdive-panel .dd-body p:first-child { margin-top: 0; } +.deepdive-panel .dd-body p:last-child { margin-bottom: 0; } +.deepdive-panel .dd-body ul, .deepdive-panel .dd-body ol { margin: .3rem 0 .4rem 1.2rem; } +.deepdive-panel .dd-body li { margin-bottom: .15rem; } +.deepdive-panel .dd-body code { background: rgba(255,255,255,0.08); padding: 1px 5px; border-radius: 3px; } +.deepdive-panel .close-dd { font-size: .78em; padding: .3rem .6rem; margin-top: .4rem; } +@media (max-width: 640px) { .deepdive-bar { gap: .3rem; } .deepdive-btn { font-size: .72em; padding: .35rem .5rem; } } + + +/* Structured-reply fallback in chat (2026-04-25) */ +.structured-chat { background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.08); border-radius: 8px; padding: .9rem 1rem; font-size: .92em; } +.struct-badge { display: inline-block; font-size: .75em; padding: .2rem .55rem; background: rgba(255,255,255,0.08); border-radius: 4px; margin-bottom: .6rem; letter-spacing: .03em; } +.struct-topic { font-weight: 600; font-size: 1.02em; margin-bottom: .5rem; } +.struct-scenario { background: rgba(255,255,255,0.04); padding: .6rem .8rem; border-radius: 6px; margin-bottom: .7rem; line-height: 1.55; } +.struct-question { margin: .7rem 0; padding-left: .4rem; border-left: 2px solid rgba(255,255,255,0.15); } +.struct-options { list-style: none; padding-left: 0; margin: .35rem 0 .5rem 0; } +.struct-options li { padding: .2rem 0; color: var(--text-mute, #bbb); } +.struct-options li.correct { color: var(--success, #10b981); font-weight: 500; } +.struct-explain { font-size: .9em; margin-top: .3rem; padding: .4rem .6rem; background: rgba(255,255,255,0.03); border-radius: 4px; line-height: 1.5; } +.struct-lessons { margin-top: .6rem; font-size: .9em; } +.struct-lessons ul { margin: .25rem 0 .3rem 1.2rem; } +.struct-norms { margin-top: .4rem; font-size: .85em; color: var(--text-mute, #aaa); } +.struct-norms code { background: rgba(255,255,255,0.08); padding: 1px 6px; border-radius: 3px; margin-right: .2rem; } +.struct-flashcard { margin: .5rem 0; padding: .5rem .8rem; background: rgba(255,255,255,0.03); border-radius: 6px; } +.fc-front { font-weight: 500; } +.fc-back { margin-top: .25rem; color: var(--text, #f1f0f5); } +.fc-hint { margin-top: .25rem; font-size: .85em; color: var(--text-mute, #aaa); } +.struct-objectives { margin-bottom: .5rem; font-size: .9em; } +.struct-slide { margin: .5rem 0; padding: .5rem .8rem; border-left: 2px solid rgba(255,255,255,0.15); } +.slide-content { margin-top: .3rem; font-size: .95em; line-height: 1.55; } +.slide-content p:first-child { margin-top: 0; } +.slide-key { margin-top: .35rem; font-size: .88em; color: var(--accent, #f59e0b); } +.struct-hint { margin-top: .7rem; font-size: .78em; color: var(--text-mute, #888); font-style: italic; } + +/* Bot-spezifische Card-Erweiterungen (audit/privacy_check/mail_check/plan/validate/interview/decode/write/calc — 2026-04-25) */ +.struct-row { margin: .35rem 0; line-height: 1.5; } +.struct-row code { background: rgba(255,255,255,0.08); padding: 1px 6px; border-radius: 3px; font-size: .9em; } +.struct-section { margin-top: .65rem; padding-top: .55rem; border-top: 1px solid rgba(255,255,255,0.08); } +.struct-section ul, .struct-section ol { margin: .3rem 0 .3rem 1.2rem; line-height: 1.5; } +.struct-section li { padding: .1rem 0; } +.struct-table { width: 100%; border-collapse: collapse; margin-top: .35rem; font-size: .88em; } +.struct-table th, .struct-table td { padding: .35rem .55rem; border-bottom: 1px solid rgba(255,255,255,0.06); text-align: left; vertical-align: top; } +.struct-table th { color: var(--text-mute, #bbb); font-weight: 500; font-size: .85em; text-transform: uppercase; letter-spacing: .04em; } +.struct-table code { background: rgba(255,255,255,0.06); padding: 1px 5px; border-radius: 3px; } +.struct-step { margin: .4rem 0; padding: .4rem .65rem; background: rgba(255,255,255,0.03); border-left: 2px solid var(--accent, #7c3aed); border-radius: 4px; } +.struct-step .step-head { font-weight: 500; margin-bottom: .25rem; } +.struct-step .step-success { font-size: .85em; color: var(--success, #22c55e); margin-top: .25rem; } +.struct-step ul { margin: .25rem 0 .25rem 1.1rem; font-size: .9em; } +.struct-doc { font-family: Georgia, 'Iowan Old Style', serif; line-height: 1.6; } +.struct-doc blockquote { margin: .4rem 0; padding-left: .75rem; border-left: 3px solid var(--accent, #7c3aed); color: var(--text, #cfcedb); }