feat(doku): Anleitung, PDF und Ende-zu-Ende-Abnahme
Prüfung / pruefung (push) Canceled after 0s
Prüfung / pruefung (pull_request) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
Prüfung / e2e (pull_request) Canceled after 0s

Neun Kapitel unter docs/anleitung/ fuer Technik und Verwaltung, mit 35
Bildern aus `pnpm docs:screenshots` und einem Fehlerbehebungskapitel aus
echten Meldungen. `pnpm docs:pdf` macht daraus 72 Seiten A4 — hell
gedruckt, mit Deckblatt, Inhaltsverzeichnis und Seitenzahlen, ohne neue
Abhaengigkeit.

Die Ablaeufe pruefen ihre Voraussetzungen, bevor der erste startet:
antwortet ein gebauter Stand, steht der Seed-Bestand, und liest der
Server dieselbe Datenbank wie der Testprozess. Jeder Ablauf legt an, was
er braucht, und raeumt es weg. Vier neue kommen dazu: Hauptablauf,
Onboarding mit Vergleich der Zwischenablage, Rollen und 390 px auf
achtzehn Seiten.

Drei Fehler kamen dabei heraus. Das Projekt „handy" pruefte 412 px statt
der verlangten 390. Ein angemeldeter Benutzer ohne Recht erfuhr nie,
warum er abgewiesen wurde — die Meldung stand auf einer Seite, die ihn
sofort weiterleitet; jetzt gibt es /kein-zugriff. Und der
Verzeichnis-Ablauf schrieb die Attributzuordnung der ersten vorhandenen
Quelle um, in einer echten Installation also der produktiven.

101 Ablaeufe in INTERN und 97 in DIENSTLEISTUNG, beide ohne Fehlschlag.

Closes #18

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-26 11:03:02 +02:00
co-authored by Claude Opus 5
parent 9bd198a531
commit 10e272654f
104 changed files with 4527 additions and 299 deletions
+571
View File
@@ -0,0 +1,571 @@
#!/usr/bin/env tsx
/**
* Baut die Kapitel unter docs/anleitung/ zu einem PDF zusammen:
* docs/anleitung/laufzettel-anleitung.pdf.
*
* pnpm docs:pdf
*
* Gerendert wird mit denselben Bausteinen, die auch die Hilfetexte in der App
* benutzen – `react-markdown` und `remark-gfm`, hier serverseitig ueber
* `react-dom/server` zu HTML. Gedruckt wird mit dem Chromium aus
* `@playwright/test`, der auch die Screenshots aufnimmt. Keine neue
* Abhaengigkeit, und damit auch keine zweite Markdown-Auslegung, die von der
* Oberflaeche abweichen koennte.
*
* **Hell, nicht dunkel.** Die App ist dunkel, ein Ausdruck ist es nie
* (CLAUDE.md, „Gestaltung“: Druckansichten und Etiketten immer hell). Der
* Akzent ist deshalb nicht die Signal-Lime, sondern der Akzent des hellen
* Modus aus `globals.css` – Lime auf Weiss liest niemand.
*
* **Fehlt ein verlinktes Bild, bricht der Lauf ab.** Ein PDF mit Luecken ist
* schlimmer als keines: es sieht fertig aus. Erst `pnpm docs:screenshots`,
* dann `pnpm docs:pdf`.
*/
import { readdir, readFile, writeFile } from "node:fs/promises";
import { extname, join, resolve } from "node:path";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import ReactMarkdown, { type Components } from "react-markdown";
import remarkGfm from "remark-gfm";
import { chromium } from "@playwright/test";
const WURZEL = resolve(import.meta.dirname, "..");
const QUELLE = join(WURZEL, "docs", "anleitung");
const ZIEL = join(QUELLE, "laufzettel-anleitung.pdf");
const TITEL = "Laufzettel";
const UNTERTITEL = "Anleitung für Technik und Verwaltung";
/** Ein Eintrag des Inhaltsverzeichnisses. */
type Eintrag = {
readonly ebene: 1 | 2;
readonly text: string;
readonly anker: string;
};
/** Ein eingelesenes Kapitel, schon zu HTML gerendert. */
type Kapitel = {
readonly datei: string;
/** Zweistellige Nummer aus dem Dateinamen, falls vorhanden – `README.md` hat keine. */
readonly nummer: string | undefined;
readonly anker: string;
readonly html: string;
readonly eintraege: readonly Eintrag[];
};
/** Der kleine Teil von hast, den dieses Skript anfasst. */
type HastKnoten = {
readonly value?: string;
readonly children?: readonly HastKnoten[];
};
const UMLAUTE: Record<string, string> = { ä: "ae", ö: "oe", ü: "ue", ß: "ss" };
/**
* Sprungmarke aus einer Ueberschrift. Umlaute fallen auf ASCII zusammen, damit
* ein Anker zitierbar bleibt – dieselbe Faltung wie bei Bezeichnern.
*/
function anker(text: string): string {
const gefaltet = text
.toLowerCase()
.replace(/[äöüß]/g, (zeichen) => UMLAUTE[zeichen] ?? zeichen)
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
return gefaltet === "" ? "abschnitt" : gefaltet;
}
/** Der reine Text einer Ueberschrift, ohne `*`, `` ` `` und Verweise. */
function textAus(knoten: HastKnoten | undefined): string {
if (!knoten) return "";
if (typeof knoten.value === "string") return knoten.value;
return (knoten.children ?? []).map(textAus).join("");
}
function maskiere(text: string): string {
return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
/**
* Kapitel in Lesereihenfolge: `README.md` zuerst, danach nach Dateinamen.
*
* Die Zahl der Kapitel ist offen – das Skript nimmt, was da liegt.
*/
async function kapitelDateien(): Promise<readonly string[]> {
const eintraege = await readdir(QUELLE, { withFileTypes: true }).catch(() => {
throw new Error(`Es gibt kein Verzeichnis ${QUELLE}. Erst die Kapitel schreiben.`);
});
const dateien = eintraege
.filter((eintrag) => eintrag.isFile() && eintrag.name.endsWith(".md"))
.map((eintrag) => eintrag.name)
.sort((a, b) => {
const rang = (name: string) => (name.toLowerCase() === "readme.md" ? 0 : 1);
return rang(a) - rang(b) || a.localeCompare(b, "de");
});
if (dateien.length === 0) {
throw new Error(
`In ${QUELLE} liegt kein Kapitel (*.md). Ohne Kapitel gibt es keine Anleitung – ` +
`erst die Kapitel schreiben, dann "pnpm docs:pdf".`,
);
}
return dateien;
}
const BILDTYPEN: Record<string, string> = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".svg": "image/svg+xml",
".webp": "image/webp",
".gif": "image/gif",
};
/**
* Liest jedes verlinkte Bild ein und liefert es als `data:`-URI.
*
* Eingebettet statt verlinkt, weil das PDF danach fuer sich steht und keine
* Rolle spielt, aus welchem Verzeichnis Chromium die Seite geladen hat.
* Fehlt ein Bild, bricht der Lauf ab und nennt jede fehlende Datei.
*/
async function bilderEinlesen(
kapitel: readonly { readonly datei: string; readonly text: string }[],
): Promise<Map<string, string>> {
const bilder = new Map<string, string>();
const fehlend = new Set<string>();
for (const { datei, text } of kapitel) {
for (const treffer of text.matchAll(/!\[[^\]]*\]\(\s*([^)\s]+)/g)) {
const quelle = treffer[1];
if (!quelle || /^(https?|data):/i.test(quelle)) continue;
if (bilder.has(quelle)) continue;
const pfad = resolve(QUELLE, decodeURI(quelle));
const typ = BILDTYPEN[extname(pfad).toLowerCase()];
if (!typ) {
fehlend.add(`${datei} → ${quelle} (unbekannter Bildtyp)`);
continue;
}
const bytes = await readFile(pfad).catch(() => undefined);
if (!bytes) {
fehlend.add(`${datei} → ${quelle}`);
continue;
}
bilder.set(quelle, `data:${typ};base64,${bytes.toString("base64")}`);
}
}
if (fehlend.size > 0) {
throw new Error(
`Diese Bilder fehlen, deshalb entsteht kein PDF:\n` +
[...fehlend].map((zeile) => ` - ${zeile}`).join("\n") +
`\nMit "pnpm docs:screenshots" neu aufnehmen (gebauter Stand, siehe CLAUDE.md).`,
);
}
return bilder;
}
/**
* Die Bausteine fuer ein Kapitel: Ueberschriften bekommen Sprungmarken und
* landen im Inhaltsverzeichnis, Bilder werden eingebettet, Verweise auf andere
* Kapitel werden zu Sprungmarken im selben Dokument.
*
* `eintraege` wird beim Rendern gefuellt. Das ist Absicht: so kann das
* Inhaltsverzeichnis nicht von den wirklich gesetzten Ankern abweichen – ein
* zweiter Durchlauf durch das Markdown waere eine zweite Auslegung.
*/
function bausteine(
kapitelAnker: string,
eintraege: Eintrag[],
bilder: ReadonlyMap<string, string>,
ankerJeDatei: ReadonlyMap<string, string>,
): Components {
return {
h1: ({ node, children }) => {
eintraege.push({ ebene: 1, text: textAus(node), anker: kapitelAnker });
return createElement("h1", null, children);
},
h2: ({ node, children }) => {
const marke = `${kapitelAnker}--${anker(textAus(node))}`;
eintraege.push({ ebene: 2, text: textAus(node), anker: marke });
return createElement("h2", { id: marke }, children);
},
/*
* Kein `figure`: `react-markdown` setzt ein Bild in einen Absatz, und ein
* `figure` in einem `p` beendet den Absatz – der Browser raeumt das auf,
* aber mit leeren Absaetzen im Druckbild. Ein `span` mit `display: block`
* tut dasselbe, ohne die Absatzgrenze zu brechen.
*/
img: ({ src, alt }) => {
const quelle = typeof src === "string" ? src : "";
const beschriftung = typeof alt === "string" ? alt : "";
return createElement(
"span",
{ className: "bild" },
createElement("img", { src: bilder.get(quelle) ?? quelle, alt: beschriftung }),
beschriftung === "" ? null : createElement("span", { className: "bildtext" }, beschriftung),
);
},
a: ({ href, children }) => {
const ziel = typeof href === "string" ? href : "";
const pfad = ziel.split("#")[0] ?? "";
const kapitel = ankerJeDatei.get(pfad.replace(/^\.\//, ""));
/*
* Ein Verweis auf ein Nachbarkapitel fuehrt im PDF an dessen Anfang – auch
* wenn er einen Abschnitt nennt. Die Anker im Markdown folgen der Zaehlung
* von Gitea, die Anker hier der ASCII-Faltung des Projekts; aus
* „#ueberblick“ und „#überblick“ wuerde sonst mal ein Treffer und mal ein
* toter Sprung. Der Kapitelanfang trifft immer.
*/
return createElement("a", { href: kapitel ? `#${kapitel}` : ziel }, children);
},
};
}
/**
* Die drei Schriften des Projekts, eingebettet als `data:`-URI.
*
* Nur die Latin-Schnitte: Umlaute, `ß`, Anfuehrungszeichen und `·` liegen alle
* darin, und ein zweiter Schnitt je Familie braeuchte `unicode-range`, um
* ueberhaupt etwas zu bewirken. Fehlt eine Datei, druckt Chromium mit der
* Systemschrift – das PDF entsteht trotzdem, nur sieht es fremd aus.
*/
async function schriftregeln(): Promise<string> {
const familien = [
{ familie: "Space Grotesk Variable", paket: "space-grotesk" },
{ familie: "Manrope Variable", paket: "manrope" },
{ familie: "JetBrains Mono Variable", paket: "jetbrains-mono" },
];
const regeln: string[] = [];
for (const { familie, paket } of familien) {
const pfad = join(
WURZEL,
"node_modules",
"@fontsource-variable",
paket,
"files",
`${paket}-latin-wght-normal.woff2`,
);
const bytes = await readFile(pfad).catch(() => undefined);
if (!bytes) {
console.warn(` Hinweis: ${paket} nicht gefunden, es druckt die Systemschrift.`);
continue;
}
regeln.push(
`@font-face{font-family:"${familie}";font-style:normal;font-weight:200 800;` +
`font-display:block;src:url(data:font/woff2;base64,${bytes.toString("base64")}) format("woff2")}`,
);
}
return regeln.join("\n");
}
/** Das Stilblatt des Ausdrucks: hell, eine Linie, ein Akzent. */
const STIL = `
:root {
/*
* Die Token des hellen Modus aus globals.css. Die Signal-Lime ist auf Weiss
* nicht lesbar; im hellen Modus tritt an ihre Stelle #5e8a00, und ein
* Ausdruck ist immer hell.
*/
--grund: #ffffff;
--flaeche: #f6f7f4;
--linie: #d9ddd4;
--text: #0b0d10;
--nebentext: #5b6472;
--akzent: #5e8a00;
}
* { box-sizing: border-box; }
html { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
body {
margin: 0;
background: var(--grund);
color: var(--text);
font-family: "Manrope Variable", ui-sans-serif, system-ui, sans-serif;
font-size: 10.5pt;
line-height: 1.55;
hyphens: auto;
}
h1, h2, h3, h4 {
font-family: "Space Grotesk Variable", ui-sans-serif, system-ui, sans-serif;
line-height: 1.2;
break-after: avoid;
}
h1 { font-size: 21pt; margin: 0 0 4mm; }
h2 { font-size: 14pt; margin: 8mm 0 2mm; padding-top: 2mm; border-top: 1px solid var(--linie); }
h3 { font-size: 11.5pt; margin: 5mm 0 1mm; }
h4 { font-size: 10.5pt; margin: 4mm 0 1mm; color: var(--nebentext); }
p, ul, ol, blockquote, table { margin: 0 0 2.5mm; }
ul, ol { padding-left: 6mm; }
li { margin: 0 0 1mm; }
li > p { margin: 0; }
a { color: var(--akzent); text-decoration: none; }
strong { font-weight: 700; }
code {
font-family: "JetBrains Mono Variable", ui-monospace, monospace;
font-size: 0.88em;
background: var(--flaeche);
border: 1px solid var(--linie);
border-radius: 3px;
padding: 0 0.3em;
}
pre {
font-family: "JetBrains Mono Variable", ui-monospace, monospace;
font-size: 8.5pt;
line-height: 1.45;
background: var(--flaeche);
border: 1px solid var(--linie);
border-radius: 3mm;
padding: 3mm;
margin: 0 0 3mm;
white-space: pre-wrap;
word-break: break-word;
break-inside: avoid;
}
pre code { background: none; border: 0; padding: 0; font-size: inherit; }
blockquote {
border-left: 2px solid var(--akzent);
padding: 0 0 0 3mm;
color: var(--nebentext);
}
table { border-collapse: collapse; width: 100%; font-size: 9.5pt; break-inside: avoid; }
th, td { border: 1px solid var(--linie); padding: 1.5mm 2mm; text-align: left; vertical-align: top; }
th { background: var(--flaeche); font-weight: 600; }
hr { border: 0; border-top: 1px solid var(--linie); margin: 5mm 0; }
/* Bilder: eine feine Linie, und nie ueber den Rand – auch nicht in der Hoehe. */
.bild { display: block; margin: 3mm 0 4mm; break-inside: avoid; text-align: center; }
.bild img {
display: block;
margin: 0 auto;
max-width: 100%;
max-height: 200mm;
width: auto;
height: auto;
border: 1px solid var(--linie);
border-radius: 2mm;
}
.bildtext {
display: block;
margin-top: 1.5mm;
font-size: 8.5pt;
color: var(--nebentext);
}
/* Deckblatt */
.deckblatt {
display: flex;
flex-direction: column;
justify-content: space-between;
height: 228mm;
break-after: page;
}
.deckblatt .marke {
font-family: "JetBrains Mono Variable", ui-monospace, monospace;
font-size: 9pt;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--nebentext);
}
.deckblatt .titel {
font-family: "Space Grotesk Variable", ui-sans-serif, system-ui, sans-serif;
font-size: 52pt;
font-weight: 700;
letter-spacing: -0.02em;
margin: 0;
}
.deckblatt .strich { width: 40mm; height: 3px; background: var(--akzent); margin: 4mm 0 5mm; }
.deckblatt .untertitel { font-size: 15pt; color: var(--nebentext); margin: 0; }
.deckblatt .angaben {
border-top: 1px solid var(--linie);
padding-top: 3mm;
font-size: 9.5pt;
color: var(--nebentext);
display: flex;
gap: 8mm;
}
.deckblatt .angaben span strong { color: var(--text); font-weight: 600; }
/* Inhaltsverzeichnis */
.inhalt { break-after: page; }
.inhalt ol { list-style: none; padding: 0; margin: 0; }
.inhalt a { color: var(--text); display: flex; gap: 3mm; }
.inhalt .ebene1 { margin-top: 3mm; font-weight: 600; }
.inhalt .ebene1 .nummer {
font-family: "JetBrains Mono Variable", ui-monospace, monospace;
color: var(--akzent);
min-width: 8mm;
}
.inhalt .ebene2 { padding-left: 11mm; color: var(--nebentext); font-size: 9.5pt; }
.inhalt .ebene2 a { color: var(--nebentext); }
/* Kapitel beginnen auf einer neuen Seite. */
.kapitel { break-before: page; }
.kapitel .kennung {
font-family: "JetBrains Mono Variable", ui-monospace, monospace;
font-size: 9pt;
letter-spacing: 0.16em;
color: var(--akzent);
display: block;
margin-bottom: 2mm;
}
`;
/**
* Die Fusszeile kommt aus einem eigenen Dokument, in das die eingebetteten
* Schriften nicht reichen – deshalb eine Systemschrift.
*/
const FUSSZEILE = `
<div style="width:100%;padding:0 18mm;font-family:system-ui,sans-serif;font-size:8pt;color:#5b6472;
display:flex;justify-content:space-between;align-items:center;">
<span>${TITEL} · ${UNTERTITEL}</span>
<span><span class="pageNumber"></span> / <span class="totalPages"></span></span>
</div>`;
function deckblattHtml(version: string, datum: string): string {
return `<section class="deckblatt">
<div>
<p class="marke">Handbuch</p>
</div>
<div>
<h1 class="titel">${maskiere(TITEL)}</h1>
<div class="strich"></div>
<p class="untertitel">${maskiere(UNTERTITEL)}</p>
</div>
<div class="angaben">
<span>Version <strong>${maskiere(version)}</strong></span>
<span>Stand <strong>${maskiere(datum)}</strong></span>
</div>
</section>`;
}
function inhaltHtml(kapitel: readonly Kapitel[]): string {
const zeilen = kapitel.flatMap((k) =>
k.eintraege.map((eintrag) => {
const nummer = eintrag.ebene === 1 ? (k.nummer ?? "·") : "";
return (
`<li class="ebene${eintrag.ebene}"><a href="#${maskiere(eintrag.anker)}">` +
`<span class="nummer">${maskiere(nummer)}</span>` +
`<span class="text">${maskiere(eintrag.text)}</span></a></li>`
);
}),
);
return `<section class="inhalt">
<h1>Inhalt</h1>
<ol>
${zeilen.map((zeile) => ` ${zeile}`).join("\n")}
</ol>
</section>`;
}
function seiteHtml(schriften: string, koerper: string): string {
return `<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<title>${maskiere(TITEL)} – ${maskiere(UNTERTITEL)}</title>
<style>
${schriften}
${STIL}
</style>
</head>
<body>
${koerper}
</body>
</html>`;
}
/**
* Seitenzahl aus dem erzeugten PDF, so gut es ohne Fremdpaket geht: Chromium
* schreibt den Seitenbaum unkomprimiert. Findet die Regel nichts, bleibt die
* Angabe weg, statt eine falsche Zahl zu melden.
*/
function seitenzahl(pdf: Uint8Array): number | undefined {
const text = Buffer.from(pdf).toString("latin1");
const zahlen = [...text.matchAll(/\/Type\s*\/Pages\b[^>]*?\/Count\s+(\d+)/g)].map((t) => Number(t[1]));
return zahlen.length > 0 ? Math.max(...zahlen) : undefined;
}
// ---- Ablauf ---------------------------------------------------------------
const dateien = await kapitelDateien();
const rohkapitel = await Promise.all(
dateien.map(async (datei) => ({ datei, text: await readFile(join(QUELLE, datei), "utf8") })),
);
const bilder = await bilderEinlesen(rohkapitel);
const ankerJeDatei = new Map(
rohkapitel.map(({ datei }) => [datei, `kapitel-${anker(datei.replace(/\.md$/, ""))}`]),
);
const kapitel: Kapitel[] = rohkapitel.map(({ datei, text }) => {
const kapitelAnker = ankerJeDatei.get(datei) ?? `kapitel-${anker(datei)}`;
const eintraege: Eintrag[] = [];
const html = renderToStaticMarkup(
createElement(
ReactMarkdown,
{
remarkPlugins: [remarkGfm],
components: bausteine(kapitelAnker, eintraege, bilder, ankerJeDatei),
},
text,
),
);
return {
datei,
nummer: /^(\d{2})-/.exec(datei)?.[1],
anker: kapitelAnker,
html,
eintraege,
};
});
const { version } = JSON.parse(await readFile(join(WURZEL, "package.json"), "utf8")) as {
version: string;
};
const datum = new Intl.DateTimeFormat("de-DE", {
day: "numeric",
month: "long",
year: "numeric",
timeZone: "Europe/Berlin",
}).format(new Date());
const koerper = [
deckblattHtml(version, datum),
inhaltHtml(kapitel),
...kapitel.map(
(k) =>
`<section class="kapitel" id="${maskiere(k.anker)}">` +
(k.nummer ? `<span class="kennung">Kapitel ${maskiere(k.nummer)}</span>` : "") +
`${k.html}</section>`,
),
].join("\n");
const html = seiteHtml(await schriftregeln(), koerper);
const browser = await chromium.launch();
try {
const seite = await browser.newPage();
// Hell, und zwar ausdruecklich: die App richtet sich sonst nach dem Schema
// des Systems und brachte ihre dunklen Flaechen mit in den Druck.
await seite.emulateMedia({ colorScheme: "light", media: "print" });
await seite.setContent(html, { waitUntil: "load" });
await seite.evaluate(() => document.fonts.ready);
const pdf = await seite.pdf({
format: "A4",
printBackground: true,
margin: { top: "18mm", right: "18mm", bottom: "20mm", left: "18mm" },
displayHeaderFooter: true,
headerTemplate: "<span></span>",
footerTemplate: FUSSZEILE,
});
await writeFile(ZIEL, pdf);
const seiten = seitenzahl(pdf);
console.log(` ${ZIEL.replace(`${WURZEL}/`, "")}`);
console.log(
` ${kapitel.length} Kapitel, ${bilder.size} Bilder, ` +
`${(pdf.byteLength / 1024 / 1024).toFixed(1)} MB` +
(seiten === undefined ? "" : `, ${seiten} Seiten`),
);
} finally {
await browser.close();
}
console.log("Anleitung als PDF erzeugt.");
+110 -1
View File
@@ -54,6 +54,20 @@ type Seite = {
* wie nebenan, nur groesser.
*/
readonly ausschnitt?: (tab: Page) => Locator;
/**
* Bereiche, die im Bild hervorgehoben werden – Rahmen in der Signal-Lime mit
* einer Nummer, auf die der Text der Anleitung sich beziehen kann (#018).
* Ohne Angabe entsteht genau dasselbe Bild wie vorher.
*/
readonly markierungen?: readonly Markierung[];
};
/**
* Eine Markierung im Bild. Die Nummer kommt aus der Reihenfolge – sie im Text
* der Anleitung zu nennen ist der ganze Zweck („(1) zeigt den Fortschritt“).
*/
type Markierung = {
readonly ziel: (tab: Page) => Locator;
};
/** Seiten, die abgebildet werden. Waechst mit jedem Meilenstein. */
@@ -137,7 +151,16 @@ const SEITEN: readonly Seite[] = [
},
{ name: "geraete-liste", pfad: "/geraete" },
{ name: "laeufe-liste", pfad: "/laeufe" },
{ name: "lauf-starten", pfad: "/laeufe/starten" },
{
name: "lauf-starten",
pfad: "/laeufe/starten",
// (1) Vorlage, (2) Geraet – die zwei Felder, ueber die das Kapitel „Lauf
// starten“ redet; alles darunter ist freiwillig.
markierungen: [
{ ziel: (tab) => tab.locator('select[name="vorlagenVersionId"]') },
{ ziel: (tab) => tab.locator('select[name="geraetId"]') },
],
},
{
name: "lauf-abarbeiten",
pfad: "/laeufe",
@@ -148,6 +171,24 @@ const SEITEN: readonly Seite[] = [
.click();
await tab.waitForURL(/\/laeufe\/[^/]+$/);
},
// (1) Fortschritt, (2) Zustandspillen des ersten Schritts, (3) Kommentare.
markierungen: [
{ ziel: (tab) => tab.getByRole("progressbar") },
{
ziel: (tab) =>
tab
.getByRole("listitem")
.filter({ has: tab.getByRole("button", { name: "erledigt", exact: true }) })
.first()
.getByRole("button", { name: "erledigt", exact: true })
// Nicht die Pille allein, sondern die Zeile mit allen drei Zustaenden.
.locator(".."),
},
{
ziel: (tab) =>
tab.locator("section").filter({ has: tab.getByRole("heading", { name: "Kommentare zum Lauf" }) }),
},
],
},
{
// Der Befehlsschritt, aufgeklappt und in den Blick gescrollt: am Handy
@@ -176,6 +217,15 @@ const SEITEN: readonly Seite[] = [
await tab.getByRole("link", { name: "NB-001" }).first().click();
await tab.waitForURL(/\/geraete\/[^/]+$/);
},
// (1) der Weg zum QR-Code, (2) was schon zugewiesen ist,
// (3) der Weg zu einer weiteren Vorlage.
markierungen: [
// Der Code selbst steht in einem zugeklappten `details` und waere im Bild
// nicht zu sehen – markiert wird deshalb der Griff, der ihn aufklappt.
{ ziel: (tab) => tab.locator("summary", { hasText: "QR-Code anzeigen" }) },
{ ziel: (tab) => tab.getByRole("heading", { name: "Zugewiesen" }).locator("..") },
{ ziel: (tab) => tab.getByRole("heading", { name: "Vorlage zuweisen" }).locator("..") },
],
},
{
/*
@@ -340,6 +390,64 @@ async function kachelnErzeugen(browser: Browser): Promise<void> {
}
}
/**
* Legt die Markierungen als Overlay in die Seite, bevor das Bild entsteht.
*
* Gezeichnet wird im Browser und in Dokumentkoordinaten (`getBoundingClientRect`
* plus `scrollX`/`scrollY`), damit der Rahmen auch auf einem `fullPage`-Bild dort
* sitzt, wo er hingehoert. Nachbearbeitet wird kein Bild – wer die Oberflaeche
* aendert, laesst dieses Skript erneut laufen, und die Rahmen wandern mit.
*
* Findet eine Markierung ihr Ziel nicht, bricht der Lauf ab: ein Bild mit
* fehlender „(2)“ macht den Text der Anleitung falsch.
*/
async function markierungenEinspritzen(
tab: Page,
seitenname: string,
markierungen: readonly Markierung[],
): Promise<void> {
for (const [stelle, markierung] of markierungen.entries()) {
const ziel = markierung.ziel(tab).first();
if ((await ziel.count()) === 0) {
throw new Error(`Markierung ${stelle + 1} von "${seitenname}" findet ihr Ziel nicht.`);
}
await ziel.evaluate((element, nummer) => {
const kasten = element.getBoundingClientRect();
const links = kasten.left + window.scrollX;
const oben = kasten.top + window.scrollY;
const rahmen = document.createElement("div");
Object.assign(rahmen.style, {
position: "absolute",
left: `${links - 5}px`,
top: `${oben - 5}px`,
width: `${kasten.width + 10}px`,
height: `${kasten.height + 10}px`,
border: "2px solid #C6F135",
borderRadius: "14px",
pointerEvents: "none",
zIndex: "2147483647",
});
const marke = document.createElement("div");
marke.textContent = String(nummer);
Object.assign(marke.style, {
position: "absolute",
// Am linken Rand rutscht die Nummer nach innen, sonst schnitte sie das Bild ab.
left: links < 24 ? "4px" : "-14px",
top: "-14px",
width: "26px",
height: "26px",
borderRadius: "999px",
background: "#C6F135",
color: "#0B0D10",
font: "700 15px/26px ui-sans-serif, system-ui, sans-serif",
textAlign: "center",
});
rahmen.append(marke);
document.body.append(rahmen);
}, stelle + 1);
}
}
const zielMeilenstein = argument("ziel");
const nur = argument("nur");
/** Welcher Betriebsmodus gerade eingestellt ist – steuert, welche Seiten passen. */
@@ -376,6 +484,7 @@ try {
// Schriften muessen geladen sein, sonst wackelt das Bild zwischen Laeufen.
await tab.evaluate(() => document.fonts.ready);
await tab.waitForLoadState("domcontentloaded");
if (seite.markierungen) await markierungenEinspritzen(tab, seite.name, seite.markierungen);
const datei = join(ziel, `${seite.name}-${ansicht.name}.png`);
if (seite.ausschnitt) {