diff --git a/package.json b/package.json
index 3fa1a48..cdbe017 100644
--- a/package.json
+++ b/package.json
@@ -19,7 +19,8 @@
"konto:anlegen": "tsx scripts/konto-anlegen.ts",
"backup": "bash scripts/im-container/sicherung.sh",
"restore": "bash scripts/im-container/wiederherstellen.sh",
- "screenshots": "tsx scripts/screenshots.ts"
+ "screenshots": "tsx scripts/screenshots.ts",
+ "symbole": "tsx scripts/symbole.ts"
},
"dependencies": {
"@node-rs/argon2": "^2.0.2",
diff --git a/public/sw.js b/public/sw.js
new file mode 100644
index 0000000..7cdea14
--- /dev/null
+++ b/public/sw.js
@@ -0,0 +1,161 @@
+/* eslint-disable */
+/**
+ * Der Dienstarbeiter der Lern-Bar.
+ *
+ * Er hat genau eine Aufgabe: Der Azubi soll im Serverraum, im Keller oder im
+ * Zug weiterlernen können, wo kein Netz ist. Alles andere wäre Ballast.
+ *
+ * ─── Was offline liegen darf ──────────────────────────────────────────────
+ *
+ * Nur das Lernmaterial: Handouts, Merkkarten, Glossar und die eigene Woche.
+ *
+ * **Nicht** zwischengespeichert werden Lernzielkontrollen, Berichtsheft,
+ * Bewertungen, Feedbackgespräche, Anhänge und jedes PDF. Der Grund ist keine
+ * Sparsamkeit, sondern eine Zusage aus dem Pflichtenheft: Musterlösungen und
+ * Bewertungsdaten stehen hinter einer eigenen Prüfung. Was im Browsercache
+ * liegt, ist an dieser Prüfung vorbei – also liegt es dort nicht.
+ *
+ * ─── Wie zwischengespeichert wird ─────────────────────────────────────────
+ *
+ * Seiten: erst das Netz, dann der Speicher. So sieht man nach einer
+ * Aktualisierung sofort die neue Fassung und nur bei fehlendem Netz die alte.
+ *
+ * Dateien unter /_next/static/: erst der Speicher. Die Namen tragen eine
+ * Prüfsumme, ihr Inhalt ändert sich nie.
+ *
+ * Alles andere – Serveraktionen, Anmeldung, POST – geht immer ans Netz.
+ */
+
+const FASSUNG = 'v1';
+const SCHALE = `lernbar-schale-${FASSUNG}`;
+const SEITEN = `lernbar-seiten-${FASSUNG}`;
+const DATEIEN = `lernbar-dateien-${FASSUNG}`;
+
+/** Was schon beim Einrichten vorliegen muss. */
+const GRUNDVORRAT = ['/offline', '/symbol-192.png', '/lernbar.svg', '/manifest.webmanifest'];
+
+/** Höchstens so viele Seiten liegen im Speicher – ein Handy ist kein Archiv. */
+const HOECHSTZAHL_SEITEN = 60;
+
+/** Nur diese Pfade dürfen offline vorliegen. Alles andere niemals. */
+function darfOfflineLiegen(pfad) {
+ if (pfad === '/' || pfad === '/offline' || pfad === '/glossar') return true;
+ if (pfad === '/handouts' || pfad.startsWith('/handouts/')) return true;
+ if (pfad === '/karten' || pfad.startsWith('/karten/')) return true;
+ if (/^\/woche\/\d+$/.test(pfad)) return true;
+ return false;
+}
+
+self.addEventListener('install', (ereignis) => {
+ ereignis.waitUntil(
+ caches
+ .open(SCHALE)
+ // Einzeln, damit ein fehlendes Stück nicht die ganze Einrichtung kippt.
+ .then((speicher) => Promise.all(GRUNDVORRAT.map((pfad) => speicher.add(pfad).catch(() => undefined))))
+ .then(() => self.skipWaiting()),
+ );
+});
+
+self.addEventListener('activate', (ereignis) => {
+ ereignis.waitUntil(
+ caches
+ .keys()
+ .then((namen) =>
+ Promise.all(
+ namen
+ .filter((name) => name.startsWith('lernbar-') && ![SCHALE, SEITEN, DATEIEN].includes(name))
+ .map((name) => caches.delete(name)),
+ ),
+ )
+ .then(() => self.clients.claim()),
+ );
+});
+
+/**
+ * Beim Abmelden wird alles Persönliche weggeräumt.
+ *
+ * Ein geteiltes Werkstattgerät soll nach dem Abmelden nicht noch die Woche des
+ * vorigen Azubis im Speicher haben.
+ */
+self.addEventListener('message', (ereignis) => {
+ if (ereignis.data && ereignis.data.typ === 'aufraeumen') {
+ ereignis.waitUntil(Promise.all([caches.delete(SEITEN), caches.delete(DATEIEN)]));
+ }
+});
+
+async function speicherKuerzen(name, hoechstzahl) {
+ const speicher = await caches.open(name);
+ const schluessel = await speicher.keys();
+ // Die ältesten zuerst – `keys()` liefert sie in der Reihenfolge des Ablegens.
+ for (const alt of schluessel.slice(0, Math.max(0, schluessel.length - hoechstzahl))) {
+ await speicher.delete(alt);
+ }
+}
+
+/** Seiten: erst das Netz, bei Ausfall der Speicher, sonst die Offlineseite. */
+async function seiteHolen(anfrage) {
+ const pfad = new URL(anfrage.url).pathname;
+
+ try {
+ const antwort = await fetch(anfrage);
+
+ if (antwort.ok && darfOfflineLiegen(pfad)) {
+ const speicher = await caches.open(SEITEN);
+ await speicher.put(anfrage, antwort.clone());
+ await speicherKuerzen(SEITEN, HOECHSTZAHL_SEITEN);
+ }
+
+ return antwort;
+ } catch (fehler) {
+ const gespeichert = await caches.match(anfrage, { ignoreSearch: false });
+ if (gespeichert) return gespeichert;
+
+ const offline = await caches.match('/offline');
+ if (offline) return offline;
+
+ return new Response('Kein Netz und nichts im Speicher.', {
+ status: 503,
+ headers: { 'Content-Type': 'text/plain; charset=utf-8' },
+ });
+ }
+}
+
+/** Unveränderliche Dateien: erst der Speicher. */
+async function dateiHolen(anfrage) {
+ const gespeichert = await caches.match(anfrage);
+ if (gespeichert) return gespeichert;
+
+ const antwort = await fetch(anfrage);
+ if (antwort.ok) {
+ const speicher = await caches.open(DATEIEN);
+ await speicher.put(anfrage, antwort.clone());
+ }
+ return antwort;
+}
+
+self.addEventListener('fetch', (ereignis) => {
+ const anfrage = ereignis.request;
+
+ // Nur eigene Adressen, nur Lesezugriffe.
+ if (anfrage.method !== 'GET') return;
+
+ const adresse = new URL(anfrage.url);
+ if (adresse.origin !== self.location.origin) return;
+
+ // Serveraktionen und Datenschnittstellen gehen immer ans Netz – eine
+ // zwischengespeicherte Antwort wäre hier schlicht falsch.
+ if (adresse.pathname.startsWith('/api/') || anfrage.headers.has('Next-Action')) return;
+
+ // Auch die Teilnachladungen von Next (RSC) nicht: Sie gehören zu einer
+ // Sitzung und sollen nicht als Seite verwechselt werden.
+ if (adresse.searchParams.has('_rsc')) return;
+
+ if (anfrage.mode === 'navigate') {
+ ereignis.respondWith(seiteHolen(anfrage));
+ return;
+ }
+
+ if (adresse.pathname.startsWith('/_next/static/') || adresse.pathname.endsWith('.svg') || adresse.pathname.endsWith('.png')) {
+ ereignis.respondWith(dateiHolen(anfrage));
+ }
+});
diff --git a/public/symbol-192.png b/public/symbol-192.png
new file mode 100644
index 0000000..8175d58
Binary files /dev/null and b/public/symbol-192.png differ
diff --git a/public/symbol-512.png b/public/symbol-512.png
new file mode 100644
index 0000000..90c8364
Binary files /dev/null and b/public/symbol-512.png differ
diff --git a/public/symbol-maskabel-512.png b/public/symbol-maskabel-512.png
new file mode 100644
index 0000000..93f575f
Binary files /dev/null and b/public/symbol-maskabel-512.png differ
diff --git a/scripts/pruefungen/baustein-10.mjs b/scripts/pruefungen/baustein-10.mjs
new file mode 100644
index 0000000..6316a1a
--- /dev/null
+++ b/scripts/pruefungen/baustein-10.mjs
@@ -0,0 +1,292 @@
+import { chromium } from '@playwright/test';
+
+/**
+ * Durchstich für Baustein 10 – PWA, Offlinebetrieb, Druck.
+ *
+ * Die Zusage dieses Bausteins ist schmal und genau: Der Azubi soll ohne Netz
+ * weiterlernen können, und was er dabei bewertet, darf nicht verloren gehen.
+ * Gleichzeitig darf **nichts** offline liegen, was hinter einer eigenen
+ * Rechteprüfung steht – Musterlösungen und Bewertungen gehören nicht in den
+ * Browserspeicher.
+ *
+ * Geprüft wird gegen den laufenden Server, mit echtem Abschalten des Netzes.
+ */
+
+const BASIS = process.env.LERNBAR_BASIS ?? 'http://localhost:3000';
+const AZUBI = { email: 'jonas.berger@betrieb.de', pw: 'azubi-start-2026' };
+
+let fehler = 0;
+function pruefe(bedingung, text, zusatz = '') {
+ console.log(` ${bedingung ? '✓' : '✗'} ${text}${zusatz ? ` – ${zusatz}` : ''}`);
+ if (!bedingung) fehler += 1;
+}
+
+const browser = await chromium.launch();
+const kontext = await browser.newContext({ locale: 'de-DE' });
+const seite = await kontext.newPage();
+
+async function anmelden() {
+ await seite.goto(`${BASIS}/anmelden`);
+ await seite.fill('#email', AZUBI.email);
+ await seite.fill('#passwort', AZUBI.pw);
+ await seite.click('button[type=submit]');
+ await seite.waitForURL(`${BASIS}/`);
+}
+
+/** Wartet, bis der Dienstarbeiter das Ruder übernommen hat. */
+async function dienstarbeiterBereit() {
+ return seite.evaluate(async () => {
+ if (!('serviceWorker' in navigator)) return false;
+ const registrierung = await navigator.serviceWorker.ready;
+ // `ready` sagt nur „installiert". Erst ein Controller fängt Anfragen ab.
+ if (navigator.serviceWorker.controller) return true;
+ await new Promise((fertig) => {
+ navigator.serviceWorker.addEventListener('controllerchange', fertig, { once: true });
+ window.setTimeout(fertig, 5000);
+ });
+ return Boolean(navigator.serviceWorker.controller || registrierung.active);
+ });
+}
+
+// --- 1. Die Anwendung lässt sich installieren ------------------------------
+
+console.log('\n1. Installierbar');
+
+const manifest = await kontext.request.get(`${BASIS}/manifest.webmanifest`);
+pruefe(manifest.status() === 200, 'Das Manifest wird ausgeliefert', `HTTP ${manifest.status()}`);
+
+const manifestInhalt = await manifest.json().catch(() => null);
+pruefe(manifestInhalt?.name?.includes('Lern-Bar'), 'Es trägt den Namen der Anwendung');
+pruefe(manifestInhalt?.display === 'standalone', 'Es startet als eigenständige Anwendung');
+pruefe(
+ Array.isArray(manifestInhalt?.icons) && manifestInhalt.icons.some((s) => s.purpose === 'maskable'),
+ 'Es enthält ein Symbol für den Beschnitt unter Android',
+);
+
+for (const datei of ['/symbol-192.png', '/symbol-512.png', '/symbol-maskabel-512.png', '/sw.js']) {
+ const antwort = await kontext.request.get(`${BASIS}${datei}`);
+ pruefe(antwort.status() === 200, `${datei} liegt bereit`, `HTTP ${antwort.status()}`);
+}
+
+// Nichts davon darf von außen kommen – die Anwendung läuft in einem Netz
+// ohne Internet.
+await anmelden();
+const fremdeQuellen = await seite.evaluate(() =>
+ [...document.querySelectorAll('script[src], link[href], img[src]')]
+ .map((element) => element.getAttribute('src') ?? element.getAttribute('href') ?? '')
+ .filter((adresse) => /^https?:\/\//.test(adresse) && !adresse.startsWith(location.origin)),
+);
+pruefe(fremdeQuellen.length === 0, 'Keine Quelle von außerhalb', fremdeQuellen.join(', ') || 'keine');
+
+// --- 2. Der Dienstarbeiter übernimmt ---------------------------------------
+
+console.log('\n2. Dienstarbeiter');
+
+pruefe(await dienstarbeiterBereit(), 'Der Dienstarbeiter ist eingerichtet und steuert die Seite');
+
+// Lernmaterial einmal ansehen, damit es im Speicher landet.
+await seite.goto(`${BASIS}/handouts/N3`);
+await seite.waitForSelector('h1');
+await seite.goto(`${BASIS}/karten/lernen`);
+await seite.waitForSelector('h1, .lernkarte');
+
+// Und etwas, das **nicht** in den Speicher darf.
+await seite.goto(`${BASIS}/kontrollen`);
+await seite.waitForSelector('h1');
+
+const imSpeicher = await seite.evaluate(async () => {
+ const namen = await caches.keys();
+ const pfade = [];
+ for (const name of namen) {
+ const speicher = await caches.open(name);
+ for (const anfrage of await speicher.keys()) pfade.push(new URL(anfrage.url).pathname);
+ }
+ return pfade;
+});
+
+pruefe(imSpeicher.includes('/handouts/N3'), 'Das gelesene Handout liegt im Speicher');
+pruefe(imSpeicher.includes('/karten/lernen'), 'Der Lernmodus liegt im Speicher');
+
+// Das ist die eigentliche Zusage – hier wird eine Regel geprüft, keine Laune.
+const verboten = imSpeicher.filter((pfad) =>
+ /^\/(kontrollen|berichtsheft|feedback|auswertung|aufgaben|checklisten|verwaltung|anhaenge)/.test(pfad),
+);
+pruefe(
+ verboten.length === 0,
+ 'Nichts Bewertungsrelevantes liegt im Browserspeicher',
+ verboten.join(', ') || 'nichts',
+);
+pruefe(
+ !imSpeicher.some((pfad) => pfad.endsWith('/pdf')),
+ 'Kein PDF liegt im Browserspeicher',
+);
+
+// --- 3. Ohne Netz --------------------------------------------------------
+
+console.log('\n3. Ohne Netz');
+
+await kontext.setOffline(true);
+
+const handoutOffline = await seite.goto(`${BASIS}/handouts/N3`).catch(() => null);
+pruefe(handoutOffline !== null && handoutOffline.ok(), 'Das gelesene Handout öffnet sich ohne Netz');
+pruefe(
+ (await seite.locator('h1').innerText().catch(() => '')).length > 0,
+ 'Es ist wirklich der Inhalt und keine Fehlerseite',
+ await seite.locator('h1').innerText().catch(() => '—'),
+);
+
+const lernenOffline = await seite.goto(`${BASIS}/karten/lernen`).catch(() => null);
+pruefe(lernenOffline !== null && lernenOffline.ok(), 'Der Lernmodus öffnet sich ohne Netz');
+
+// Die Netzleiste sagt Bescheid.
+await seite.waitForTimeout(500);
+const leiste = await seite.locator('.netzleiste').count();
+pruefe(leiste > 0, 'Die Leiste „Ohne Netz" erscheint');
+
+// Eine Seite, die nie im Speicher war, landet auf der Offlineseite.
+const unbekannt = await seite.goto(`${BASIS}/kontrollen`).catch(() => null);
+const unbekannterText = await seite.locator('body').innerText().catch(() => '');
+pruefe(
+ unbekannterText.includes('Kein Netz'),
+ 'Eine nicht gespeicherte Seite zeigt die Offlineseite',
+ `HTTP ${unbekannt?.status() ?? '—'}`,
+);
+
+// --- 4. Bewertungen gehen nicht verloren -----------------------------------
+
+console.log('\n4. Bewertungen ohne Netz');
+
+await seite.goto(`${BASIS}/karten/lernen`);
+const hatKarte = (await seite.locator('.lernkarte').count()) > 0;
+
+if (!hatKarte) {
+ console.log(' – heute ist keine Karte fällig, Warteschlange übersprungen');
+} else {
+ await seite.click('.lernkarte');
+ await seite.waitForSelector('.bewerten-knopf');
+ await seite.click('.bewerten-sitzt');
+ await seite.waitForTimeout(800);
+
+ const schlange = await seite.evaluate(() =>
+ Object.keys(localStorage)
+ .filter((s) => s.startsWith('lernbar-warteschlange-'))
+ .flatMap((s) => JSON.parse(localStorage.getItem(s) ?? '[]')),
+ );
+ pruefe(schlange.length >= 1, 'Die Bewertung liegt in der Warteschlange', `${schlange.length} Eintrag/Einträge`);
+ pruefe(
+ schlange.every((e) => e.karteId && e.bewertung && /^\d{4}-\d{2}-\d{2}$/.test(e.erfasstAm)),
+ 'Jeder Eintrag trägt Karte, Bewertung und den Tag der Bewertung',
+ );
+
+ const gemeldet = await seite.locator('.lernmodus-rueckmeldung').innerText();
+ pruefe(/gemerkt/.test(gemeldet), 'Der Azubi sieht, dass die Bewertung gemerkt wurde', gemeldet.trim());
+
+ // --- Und wieder im Netz ---
+ console.log('\n5. Wieder im Netz');
+
+ // Wichtig: auf **derselben** Seite bleiben. Die Verbindung kehrt zurück,
+ // während der Azubi noch im Lernmodus steht – genau dort muss die
+ // Bestätigung erscheinen. Wer vorher die Seite wechselt, sieht sie nie,
+ // weil die Schlange dann schon leer ist.
+ await kontext.setOffline(false);
+
+ const bestaetigung = await seite
+ .locator('.netzleiste')
+ .filter({ hasText: 'nachgereicht' })
+ .first()
+ .innerText({ timeout: 15000 })
+ .catch(() => '');
+ pruefe(/nachgereicht/.test(bestaetigung), 'Der Azubi bekommt die Bestätigung zu sehen', bestaetigung.trim() || '—');
+
+ const nachher = await seite.evaluate(() =>
+ Object.keys(localStorage)
+ .filter((s) => s.startsWith('lernbar-warteschlange-'))
+ .flatMap((s) => JSON.parse(localStorage.getItem(s) ?? '[]')),
+ );
+ pruefe(nachher.length === 0, 'Die Warteschlange ist abgearbeitet', `${nachher.length} übrig`);
+
+ // Und die Bewertung ist wirklich angekommen, nicht nur verschwunden.
+ await seite.goto(`${BASIS}/karten`);
+ await seite.waitForSelector('h1');
+ const uebersicht = await seite.locator('body').innerText();
+ pruefe(
+ !/0 von \d+ Karten/.test(uebersicht) || /sitzt/i.test(uebersicht),
+ 'Die Übersicht kennt den neuen Lernstand',
+ );
+}
+
+await kontext.setOffline(false);
+
+// --- 6. Die Schnittstelle prüft die Rechte ---------------------------------
+
+console.log('\n6. Rechte an der Schnittstelle');
+
+const ohneAnmeldung = await browser.newContext();
+const abgewiesen = await ohneAnmeldung.request.post(`${BASIS}/api/lernstand`, {
+ data: { eintraege: [{ karteId: 'N3-1', bewertung: 'sitzt', erfasstAm: '2026-09-11' }] },
+});
+pruefe(abgewiesen.status() === 401, 'Ohne Anmeldung: abgewiesen', `HTTP ${abgewiesen.status()}`);
+await ohneAnmeldung.close();
+
+const alsAusbilder = await browser.newContext({ locale: 'de-DE' });
+const ausbilderSeite = await alsAusbilder.newPage();
+await ausbilderSeite.goto(`${BASIS}/anmelden`);
+await ausbilderSeite.fill('#email', 'fw@wildeflorian.de');
+await ausbilderSeite.fill('#passwort', 'ausbilder-start');
+await ausbilderSeite.click('button[type=submit]');
+await ausbilderSeite.waitForURL(`${BASIS}/`);
+
+const alsAusbilderAbgewiesen = await alsAusbilder.request.post(`${BASIS}/api/lernstand`, {
+ data: { eintraege: [{ karteId: 'N3-1', bewertung: 'sitzt', erfasstAm: '2026-09-11' }] },
+});
+pruefe(
+ alsAusbilderAbgewiesen.status() === 403,
+ 'Der Ausbilder darf den Lernstand nicht führen',
+ `HTTP ${alsAusbilderAbgewiesen.status()}`,
+);
+await alsAusbilder.close();
+
+// --- 7. Druck --------------------------------------------------------------
+
+console.log('\n7. Druck');
+
+await seite.goto(`${BASIS}/handouts/N3`);
+await seite.waitForSelector('h1');
+
+// Die dunkle Fassung einstellen – und dann drucken. Das ist der Fall, in dem
+// früher schwarze Flächen aufs Papier gekommen wären.
+await seite.evaluate(() => {
+ document.documentElement.setAttribute('data-thema', 'dunkel');
+ localStorage.setItem('lernbar-thema', 'dunkel');
+});
+await seite.emulateMedia({ media: 'print' });
+await seite.waitForTimeout(200);
+
+const druckfarben = await seite.evaluate(() => {
+ const stil = getComputedStyle(document.body);
+ const wurzel = getComputedStyle(document.documentElement);
+ return { grund: stil.backgroundColor, text: stil.color, flaeche: wurzel.getPropertyValue('--flaeche').trim() };
+});
+pruefe(
+ druckfarben.grund === 'rgb(255, 255, 255)',
+ 'Im Druck ist der Grund weiß, auch bei eingestellter dunkler Fassung',
+ druckfarben.grund,
+);
+pruefe(druckfarben.text === 'rgb(0, 0, 0)', 'Der Text ist schwarz', druckfarben.text);
+pruefe(
+ druckfarben.flaeche.toLowerCase() === '#f2f2f2',
+ 'Auch die Flächen sind hell',
+ druckfarben.flaeche,
+);
+
+const versteckt = await seite.evaluate(() => {
+ const kopf = document.querySelector('.kopf');
+ return kopf ? getComputedStyle(kopf).display : 'keine';
+});
+pruefe(versteckt === 'none', 'Die Navigation wird nicht mitgedruckt', versteckt);
+
+await seite.emulateMedia({ media: null });
+await browser.close();
+
+console.log(`\n${fehler === 0 ? '✓ Baustein 10 steht.' : `✗ ${fehler} Prüfung(en) fehlgeschlagen.`}`);
+process.exit(fehler === 0 ? 0 : 1);
diff --git a/scripts/symbole.ts b/scripts/symbole.ts
new file mode 100644
index 0000000..5bf9894
--- /dev/null
+++ b/scripts/symbole.ts
@@ -0,0 +1,77 @@
+/**
+ * Erzeugt die Symbole für die installierte Anwendung aus dem Zeichen selbst –
+ * ohne Bilddienst, ohne Netz.
+ *
+ * npm run symbole
+ *
+ * Zwei Fassungen, weil Android sie unterschiedlich behandelt:
+ *
+ * symbol-192.png / symbol-512.png das Zeichen, wie es ist
+ * symbol-maskabel-512.png derselbe Inhalt, aber randvoll auf
+ * Petrol und nach innen gerückt. Android
+ * schneidet das Symbol in eine eigene
+ * Form – ein Kreis, ein Tropfen, was das
+ * Gerät gerade mag. Was zu weit außen
+ * liegt, ist dann weg.
+ */
+import fs from 'node:fs';
+import path from 'node:path';
+import { chromium } from '@playwright/test';
+
+const ZIEL = path.join(process.cwd(), 'public');
+
+/** Das Zeichen. Gleicher Inhalt wie `public/lernbar.svg`. */
+function zeichen(randAnteil: number, eckenRadius: number): string {
+ // Die drei Balken liegen im Original in einem Feld von 64 × 64 Punkten.
+ // `randAnteil` rückt sie nach innen, ohne den Grund zu verkleinern.
+ const innen = 64 * (1 - 2 * randAnteil);
+ const versatz = 64 * randAnteil;
+
+ return ``;
+}
+
+type Auftrag = { datei: string; groesse: number; svg: string };
+
+const auftraege: Auftrag[] = [
+ { datei: 'symbol-192.png', groesse: 192, svg: zeichen(0, 12) },
+ { datei: 'symbol-512.png', groesse: 512, svg: zeichen(0, 12) },
+ // Randvoll und nach innen gerückt: der Beschnitt darf nichts abschneiden.
+ { datei: 'symbol-maskabel-512.png', groesse: 512, svg: zeichen(0.14, 0) },
+];
+
+async function main() {
+ const browser = await chromium.launch();
+
+ for (const auftrag of auftraege) {
+ const seite = await browser.newPage({
+ viewport: { width: auftrag.groesse, height: auftrag.groesse },
+ deviceScaleFactor: 1,
+ });
+
+ await seite.setContent(
+ `${auftrag.svg}`,
+ );
+
+ const datei = path.join(ZIEL, auftrag.datei);
+ await seite.screenshot({ path: datei, omitBackground: false });
+ await seite.close();
+
+ console.log(` public/${auftrag.datei} (${auftrag.groesse}×${auftrag.groesse}, ${fs.statSync(datei).size} Bytes)`);
+ }
+
+ await browser.close();
+ console.log('Symbole erzeugt.');
+}
+
+main().catch((fehler) => {
+ console.error('Die Symbole konnten nicht erzeugt werden.');
+ console.error(fehler instanceof Error ? fehler.message : fehler);
+ process.exit(1);
+});
diff --git a/src/app/(angemeldet)/auswertung/page.tsx b/src/app/(angemeldet)/auswertung/page.tsx
index 9ce1ea4..0393427 100644
--- a/src/app/(angemeldet)/auswertung/page.tsx
+++ b/src/app/(angemeldet)/auswertung/page.tsx
@@ -6,6 +6,7 @@ import { benutzerNachId } from '@/lib/repos/benutzer';
import { eigenerStand, rahmenplanabdeckung } from '@/lib/auswertung/sammeln';
import { bilanz, nachGruppen, stufenText, type Abdeckungsstufe } from '@/lib/auswertung/abdeckung';
import { verlauf } from '@/lib/kontrollen/bewertung';
+import { Druckknopf } from '@/komponenten/Druckknopf';
export const metadata: Metadata = { title: 'Auswertung' };
export const dynamic = 'force-dynamic';
@@ -72,6 +73,12 @@ export default async function AuswertungsSeite({ searchParams }: { searchParams:
+
+ {/* Die Abdeckung ist die Ansicht, die man zum Gespräch mitnimmt – und zwar
+ auf Papier, weil dort niemand scrollt. */}
+
+
+
);
}
diff --git a/src/app/(angemeldet)/glossar/page.tsx b/src/app/(angemeldet)/glossar/page.tsx
index 84fbcec..776cc06 100644
--- a/src/app/(angemeldet)/glossar/page.tsx
+++ b/src/app/(angemeldet)/glossar/page.tsx
@@ -5,6 +5,7 @@ import { inhalte } from '@/lib/inhalte';
import { glossarAnker } from '@/lib/inhalte/glossarverweise';
import { normalisiere } from '@/lib/inhalte/laden';
import { GlossarSuche } from './GlossarSuche';
+import { Druckknopf } from '@/komponenten/Druckknopf';
export const metadata: Metadata = { title: 'Glossar' };
export const dynamic = 'force-dynamic';
@@ -67,6 +68,10 @@ export default async function GlossarSeite({ searchParams }: { searchParams: Pro
))}
)}
+
+
+
+
);
}
diff --git a/src/app/(angemeldet)/handouts/[id]/page.tsx b/src/app/(angemeldet)/handouts/[id]/page.tsx
index a2f088f..ed5b372 100644
--- a/src/app/(angemeldet)/handouts/[id]/page.tsx
+++ b/src/app/(angemeldet)/handouts/[id]/page.tsx
@@ -7,6 +7,7 @@ import { verlinkeGlossar } from '@/lib/inhalte/glossarverweise';
import { gelesenAm } from '@/lib/repos/fortschritt';
import { alsZeitpunktText } from '@/lib/zeit';
import { gelesenUmschalten } from './aktionen';
+import { Druckknopf } from '@/komponenten/Druckknopf';
export const dynamic = 'force-dynamic';
@@ -159,6 +160,10 @@ export default async function HandoutSeite({ params }: { params: Promise<{ id: s
) : null}
+
+
+
+