Beteiligte in die Projekteinstellungen, TODOs aus Protokoll, Bewegung

Beteiligten-Zuordnung verlagert: p.assign ist jetzt ein Stammdatum und
nur noch in den Projekteinstellungen editierbar (planCycleAssign()).
Wochenbericht und Projektboard zeigen sie nur noch an, mit Verweis
dorthin (springZuEinstellungen()). cycleAssign() ist damit entfallen.

Neu "TODOs aus Protokoll": ein externes Besprechungsprotokoll als PDF
wird per KI eingelesen und daraus in einem zweiten KI-Schritt Aufgaben
abgeleitet. Eigener Bereich in der Aufgaben-/GANTT-Leiste, bewusst
unabhaengig von den gespeicherten Protokollen - nichts davon wird
abgelegt. Drei Schritte in einem Dialog mit fester Fusszeile; die
Vorschlagsliste als kompakte, aufklappbare Zeilen, weil ein echter
Import leicht 20 Vorschlaege liefert.

Sichtbarkeit je Aufgabe im GANTT: neue Spalte plan_todos.show_in_gantt
(Vorgabe 1, idempotente Migration) und eine Checkbox im Aufgaben-Dialog.
Eine terminierte Aufgabe kann damit gezielt aus dem Terminplan
herausgehalten werden, bleibt sonst aber eine vollwertige Aufgabe.
plan-build-gantt.js und plan-drag.js pruefen dieselbe Bedingung, damit
Zeilenzahl und Umsortierung nicht auseinanderlaufen.

KI-Timeout dritte Stufe: ein abweichender, aber entfernter Endpunkt
(Drittanbieter-Proxy) bekommt 2 Minuten statt der 45 Sekunden der
echten Anbieter-API. Gemessen: die Punkte-Erkennung eines realen
Protokolls brauchte dort 39 Sekunden, lag also stets knapp unter dem
alten Limit und brach bei jeder Schwankung ab.

Bewegung: Hover- und Klick-Rueckmeldung als Token-Satz in :root,
angewandt auf die anklickbaren Zeilen, Schalter und Dialoge. Die
Akzentleiste ist ein inset-Schatten statt eines positionierten
Elements, damit Drag-and-Drop unberuehrt bleibt; prefers-reduced-motion
war bisher gar nicht vorhanden und kippt jetzt einfach die Tokens.

Plan Checker: optionaler Projektabgleich des Plankopfs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
erikandClaude Opus 5 committed 2026-10-17 15:14:26 +02:00
1 parent 6ba93b2a92
commit 93505e0e48
17 files changed
+1883 -334

No files matched your search

+233 -38
View File
@@ -172,14 +172,20 @@ user-facing text.
│ │ ├── plan-state.js # PlanApp-Zustand, Datenladepath, savePlan, Toasts
│ │ ├── plan-sections.js # Bereichs-Navigation (showPlanSection, URL-Fragment)
│ │ │ # + Dashboard-Karten (renderPlanDashboard)
│ │ ├── plan-info.js # Status/Kommentar, Gruppe, amtierende LPH, LPH-%,
│ │ │ # Beteiligten-Matrix (verteilt auf Dashboard+Einstellungen)
│ │ ├── plan-info.js # Status/Kommentar, Gruppe, amtierende LPH, LPH-%
│ │ │ # (Dashboard-Bereich; die Beteiligten-Matrix ist
│ │ │ # entfallen, siehe plan-settings.js)
│ │ ├── plan-settings.js # Bereich "Einstellungen": Projektangaben, Honorar,
│ │ │ # beauftragte Leistungsphasen (renderPlanSettings)
│ │ │ # beauftragte Leistungsphasen, Beteiligten-Matrix
│ │ │ # (renderPlanSettings, planCycleAssign)
│ │ ├── plan-tasks.js # Bereich "Aufgaben": Aufgaben-/Meilensteinliste
│ │ │ # (renderPlanTasks; öffnet dieselben Dialoge wie das GANTT)
│ │ ├── plan-history.js # Kommentar-Verlauf über alle Sitzungen (+ Vorwochen-Helfer)
│ │ ├── plan-templates.js # Aufgaben aus LPH-Vorlagen (lph_templates)
│ │ ├── plan-protokoll-todos.js # "+ TODOs aus Protokoll": externes PDF
│ │ │ # per KI einlesen + Aufgaben daraus ableiten —
│ │ │ # unabhängig von plan-protokoll.js, nichts
│ │ │ # davon wird gespeichert
│ │ ├── plan-honorar.js # Terminplan aus Honorar + beauftragten LPH errechnen
│ │ ├── plan-build-gantt.js # buildGanttHtml-Erweiterung (TODO- und Meilensteinzeilen)
│ │ ├── plan-gantt.js # renderPlanGantt, Abhängigkeitslinien, Zoom, Ressourcen-Load
@@ -809,8 +815,8 @@ on the JSON endpoints.
The `js/plan/*` modules of the Projektbereich sit right after `gantt.js`
(`plan-state → plan-sections → plan-info → plan-history →
plan-build-gantt → plan-gantt → plan-drag → plan-modals → plan-tasks →
plan-files → plan-templates → plan-honorar → plan-settings →
plan-schedule`): they must come after `gantt.js`, whose `buildGanttHtml()`
plan-files → plan-templates → plan-protokoll-todos → plan-honorar →
plan-settings → plan-schedule`): they must come after `gantt.js`, whose `buildGanttHtml()`
`plan-build-gantt.js` wraps, and `plan-history` must stay before
`plan-build-gantt`, which uses its previous-week helpers. None of them
collides with a main-module global — every one runs in an IIFE over
@@ -1006,34 +1012,60 @@ authoritative, and two people could change the same field in different places.
The split follows what kind of data it is:
- **Laufende Erfassung → Wochenbericht only.** The session comment
(`p.status`), the progress per Leistungsphase (`p.lph`) and the assignment of
people (`p.assign`) are produced in the weekly meeting. They are editable
**only** in the Wochenbericht.
(`p.status`) and the progress per Leistungsphase (`p.lph`) are produced in
the weekly meeting. They are editable **only** in the Wochenbericht.
- **Stammdaten → Projektbereich only.** Projektnummer, Bezeichnung,
Schlagwörter, Gruppe, amtierende Leistungsphase, Auftragsstatus, Honorar, the
beauftragte Leistungsphasen and the Aufwands-Gewichtung live in the
Projektbereich (`einstellungen` section, `js/plan/plan-settings.js` plus
`js/plan/plan-info.js` for Gruppe/amtierende LPH).
beauftragte Leistungsphasen, the Aufwands-Gewichtung, and **the assignment of
people (`p.assign`)** live in the Projektbereich (`einstellungen` section,
`js/plan/plan-settings.js` plus `js/plan/plan-info.js` for Gruppe/amtierende
LPH). `p.assign` moved here on Erik's explicit request ("In den
Einstellungen der Projekte will ich auch die Projektbeteiligten einstellen
können"), fully relocated rather than duplicated — he chose that option
outright over making it editable in both places, to keep this same "Ein Ort
je Angabe" invariant for the new field too. It is edited via
`planCycleAssign(personId, role)` in `plan-settings.js`, filling
`#planAssignMatrix` — deliberately **not** reusing `cycleAssign()`
(removed, see below) or going through `currentWeek()`/`save()` at all: the
Projektbereich can be deep-linked to an older week, or be a Planung-Sandbox
plan with no `currentWeek()` entry at all (same trap `planOpenAufwand()`
already guards against), so `planCycleAssign()` mutates `app.project`
directly and calls `app.savePlan()`, exactly like every other Stammdatum in
this file.
- **Everywhere else: display plus a way there.** `renderBoardDrawer()` takes
`opts.nurAnsicht` (set by `BOARD_OPTS` in `js/board/board.js`): same card,
same values, same order — but no input fields, no clickable avatars, and a
`.drawer-verweis` underneath with the button "Im Wochenbericht bearbeiten →".
The Projektbereich does the same via `setzeBerichtVerweis()`
(`js/plan/plan-info.js`), filling `#planStatusVerweis`, `#planLphVerweis`
and `#planAssignVerweis`.
same values, same order — but no input fields and a `.drawer-verweis`
underneath with the button "Im Wochenbericht bearbeiten →" for Kommentar/
Fortschritt. The Beteiligten-Zuordnung (`roleRows`) is now **always**
rendered read-only inside `renderBoardDrawer()` — in the Projektboard AND
in the Wochenbericht, regardless of `opts.nurAnsicht` — with its own
"In den Projekteinstellungen bearbeiten →" button next to it
(`springZuEinstellungen(pid)`, `js/gantt.js`). The Projektbereich's
Dashboard section does the equivalent for Kommentar/Fortschritt via
`setzeBerichtVerweis()` (`js/plan/plan-info.js`), filling
`#planStatusVerweis`/`#planLphVerweis`; it has no such verweis for
Beteiligte any more, since that matrix is edited right there in
Einstellungen now, not somewhere else.
- **The link is a real jump**, not a hint: `springZumWochenbericht(uid)`
(`js/plan/plan-info.js`, also used by `renderBerichtVerweis()` in
`board.js`) sets `berichtAuswahl` **before** `showTab('wochenbericht')` and
therefore lands on that very project. Without it the user would have to find
it again in a list of thirty. (`berichtAuswahl` is a global `let` binding
from `js/wochenbericht/wochenbericht.js` — classic scripts, no modules, so
assigning it from another file works.)
assigning it from another file works.) `springZuEinstellungen(pid)` is the
same idea in the other direction: it sets `location.hash = 'einstellungen'`
**before** calling `openProjectPlan(pid)`, the same "set the hash first"
trick `wandSpringeZuProjekt()` already used, since `showTab('plan')`'s own
`updateRouteUrl()` preserves an already-set hash.
- **Consequence:** `planStatusBlur()`, `setPlanLph()` and `setPlanAssign()` in
`js/plan/plan-info.js` are **gone** — they had no caller left. The status
editor's formatting toolbar (`formatStatus()` buttons) went with them in the
Projektbereich; the Wochenbericht keeps the full editor. `editLph()`,
`cycleAssign()` and `saveBoardComment()` in `js/board/board.js` are
untouched — the Wochenbericht's editing hangs off them.
Projektbereich; the Wochenbericht keeps the full editor. `editLph()` and
`saveBoardComment()` in `js/board/board.js` are untouched — the
Wochenbericht's editing hangs off them. `cycleAssign()` in `js/board/board.js`
is now **also gone** (the Beteiligten relocation's own consequence,
mirroring the three functions above) — it had exactly one caller
(`roleRows`'s avatar `onclick`), which is now unconditionally read-only.
- **Honorar and beauftragte Leistungsphasen stay editable in TWO places** on
purpose (Projekteinstellungen and the "Terminplan aus Honorar" dialog, so the
dialog remains usable on its own); both write through the same functions in
@@ -1230,28 +1262,129 @@ step into a project explicit. Everything lives in `public/js/auftrag.js`:
very same `openTodoModal()`/`openMilestoneModal()` as the Gantt, and the
indices match because the list keeps `plan.todos`/`plan.milestones` order.
The create buttons (`+ Aufgabe`, `+ Meilenstein`, `+ Vorlage`,
`+ Terminplan`) exist twice — in the Gantt toolbar and above the list, the
latter with an `…BtnList` id suffix; `renderPlanPage()` hides both sets on
locked weeks.
**A todo is only a Gantt row when it has BOTH a start and an end date.**
`+ Terminplan`, `+ TODOs aus Protokoll`) exist twice — in the Gantt
toolbar and above the list, the latter with an `…BtnList` id suffix;
`renderPlanPage()` hides both sets on locked weeks.
**"+ TODOs aus Protokoll"** (`#protokollTodosBtn`/`#protokollTodosBtnList`,
`js/plan/plan-protokoll-todos.js`) is a second, genuinely independent way
to turn a meeting protocol into tasks — Erik's explicit request: "ein
Feature... das die Protokolle per KI einliest und dann aus den
Protokollpunkten Aufgaben für die Aufgabenseite im Projekt erzeugt". It
is deliberately **unrelated to `project_protokolle`/`protokoll_punkte`
and to the Protokolle section** (`js/plan/plan-protokoll.js`) — Erik's
own words: "Es sollen externe Protokolle im PDF-Format sein, die durch
die KI eingelesen werden", i.e. an ad-hoc upload of a protocol that may
never become (or already exist as) a stored Protokoll of this project,
not a second entry point into the same table. Nothing is persisted on
either end: no `project_protokolle` row, no file under
`sessions/uploads/` — the PDF and every intermediate result live only in
the request/response and the open dialog, the same "ad-hoc tool, no
archive" model as the Plan Checker (`### Protokolle` below covers the
always-persisted Protokoll-Import; this is its lightweight opposite).
Three steps in one dialog (`#protokollTodosModal`), picked by Erik from a
three-way design comparison (Taste-Skill artifact, "A — Schritt-Dialog"
vs. a wide split-panel "Werkbank" vs. an inline card in the task list):
a persistent chrome — title, a 3-segment progress bar
(`#protokollTodosSteps`), and a fixed Zurück/Abbrechen/Weiter footer —
stays on screen across all three steps, with only
`#protokollTodosBody`'s content swapped per step (same "Container-Div
wird je Phase neu befüllt" idea `applyTemplate()`'s dialog already uses).
The right-hand "Weiter" button both advances the step AND fires that
step's network call in one click (`protokollTodosWeiter()`); "Zurück"
(`protokollTodosZurueck()`) only ever re-renders already-held module
state, no re-fetch — except a re-shown file `<input>` can't be
re-populated from script, so going back past step 0 means re-picking the
file. A genuine KI failure (`zeigeFehler()`, red) is kept visually
distinct from a successful-but-degraded fallback
(`zeigeFallbackHinweis()`, the same amber `.protokoll-ki-fallback-banner`
the Protokoll-Import uses) — a fallback is an accepted outcome, not an
error.
(1) upload a PDF + pick a provider → `POST
/plan-todos-aus-protokoll/einlesen` reuses `erkennePunkteMitKI()`/
`erkennePunkteAusText()` **unchanged** (server.js) to extract points,
returned straight to the client, nowhere written to a table; (2) the
extracted points appear as a checklist (every `status:'offen'` point
pre-checked, `erledigt`/`info` pre-unchecked but still selectable — one
surface covers both "whole protocol" and "just this one point"); the
checked point objects (already in the browser, no server round-trip to
refetch) go to `POST /plan-todos-aus-protokoll/aufgaben-vorschlag`,
which runs the **second**, independent KI step, `leiteAufgabenMitKI()` →
`leiteAufgabenMitChatGPT`/`leiteAufgabenMitClaude` (server.js, same
`fetchMitTimeout()`/`LOKAL_DISPATCHER`/`reasoning_effort` plumbing as
`erkennePunkteMitChatGPT`/`Claude`, own `KI_AUFGABEN_SCHEMA`/
`KI_AUFGABEN_SYSTEM_PROMPT`) — this step *judges* the points rather than
copying them: several points about the same action can collapse into
one task, a purely informational point yields none; on any KI failure
(or no provider chosen) the route falls back to a direct 1:1 mapping
(`erkennungsart:'direkt'`), the same "always succeed, possibly degrade"
policy as the Protokoll-Import; (3) the proposed tasks appear as a
compact row list (`renderVorschlaegeHtml()`) — Erik's pick from a second,
narrower Taste-Skill comparison scoped to just this step: a real test run
had already produced 19 proposals from one protocol, and a full card per
row would have been nothing but scrolling. Each row shows title plus a
Fälligkeit/Zuständig badge; the full description, date field, and
Zuständigen-Auswahl only appear once the row is tapped
(`.protokoll-todo-vorschlag.open`, `.pt-row-detail`). A selection bar
above the list (`N von M ausgewählt`, "Alle"/"Keine" —
`protokollTodosAuswahlAlle()`) avoids unchecking rows one by one at that
volume. Zuständig is pre-matched against `projectAssignees(app.project,
app.week)` by a simple case-insensitive name match,
`matchePersonenVonName()` — an external protocol's "Zuständig" is often
an outside firm with no match at all, which is the expected common case,
not a bug. Confirming pushes one `plan_todo` per checked row
straight into `app.ensurePlanLocal().todos` **with both `start` and
`end` always set** (today / the given Fälligkeit or today) — exactly
`applyTemplate()`'s/`saveTodoAusKommentar()`'s push shape — specifically
so every created task is a Gantt row immediately, not a dateless
checklist item (see the GANTT-row rule right above); `app.savePlan()`
then `fitPlanGanttRange()` (again mirroring `applyTemplate()`) so the
visible GANTT window actually includes the new bars instead of only
showing them in the plain Aufgaben list.
**A todo is only a Gantt row when it has BOTH a start and an end date
AND `showInGantt` is not explicitly `false`.**
`plan.todos` is the one shared list either way — every todo always shows
up here in Aufgaben, with its assignees (`t.assignees`, "Zuständige"
chips in `openTodoModal()`, unrelated to whether it has a schedule) — but
`plan-build-gantt.js` (the `buildGanttHtml()` extension that inserts
TODO/milestone rows under the project row) only inserts a row for a todo
with `todo.start && todo.end`; a title-only todo (created via `+ Aufgabe`
or `+ TODO` with the date range left empty — nothing in `saveTodoEdit()`
requires dates, only the title) stays a pure checklist item that appears
solely here, never in the Gantt/Terminplan. The Aufgaben row for such a
todo shows "ohne Termin" in place of a date range
(`.plan-task-no-schedule`). Giving it a start/end later (editing the same
todo through either view) makes a Gantt row for it appear on the next
render — same object, no separate "convert to scheduled" action needed.
with `todo.start && todo.end && todo.showInGantt !== false`; a
title-only todo (created via `+ Aufgabe` or `+ TODO` with the date range
left empty — nothing in `saveTodoEdit()` requires dates, only the title)
stays a pure checklist item that appears solely here, never in the
Gantt/Terminplan. The Aufgaben row for such a todo shows "ohne Termin" in
place of a date range (`.plan-task-no-schedule`). Giving it a start/end
later (editing the same todo through either view) makes a Gantt row for
it appear on the next render — same object, no separate "convert to
scheduled" action needed.
**`showInGantt`** (`plan_todos.show_in_gantt`, `DEFAULT 1`,
`migrateAddPlanTodoShowInGantt()`) is the independent half of that
condition — Erik's explicit request, after "TODOs aus Protokoll"
(`plan-protokoll-todos.js`) showed that a single import can easily create
15-25 dated tasks at once, which — before this field existed — were all
unconditionally forced into the Gantt as bars. A checkbox "Im Terminplan
(GANTT) anzeigen" in the TODO-edit dialog (`#todoEditShowInGantt`,
`App.html`, read/written in `openTodoModal()`/`saveTodoEdit()`,
`js/plan/plan-modals.js`) lets a *dated* task still exist (tracked under
Aufgaben, counted on "Meine Aufgaben", included in dependency-based
Auto-Schedule/critical-path) without drawing a bar. Checked by default;
`!== false` rather than `=== true` everywhere it's read, so a todo from
before this column existed (or any object missing the field) keeps
showing exactly as before — no retroactive behavior change. Carried
through "Speichern & Neu" (`openTodoModal(null, {..., showInGantt})`)
like the other frame fields (Zeitraum/Zuständige), so adding several
hidden-from-Gantt tasks in a row doesn't mean re-unchecking it every
time. **Not** touched by `plan-schedule.js`'s Auto-Schedule/critical-path
(`t.start && t.end`, unchanged) or `plan-gantt.js`'s ghost-date overlay —
a hidden todo is still a real, scheduled task for every purpose except
drawing its own bar.
`reorderPlanRows()` (`js/plan/plan-drag.js`) accounts for this: its
DOM-row-count safety check and the reordering itself only ever touch
todos that have `start && end` (`scheduledIds`) — a title-only todo has
no drag handle in the Gantt to begin with and keeps its exact position in
`plan.todos` untouched by any Gantt row drag.
todos that have `start && end && showInGantt !== false`
(`scheduledIds`) — exactly the same condition as the insertion above, so
the count of draggable DOM rows and `scheduledIds.size` never drift
apart. A title-only or Gantt-hidden todo has no drag handle in the Gantt
to begin with and keeps its exact position in `plan.todos` untouched by
any Gantt row drag.
- **Leistung** — Leistungsstand & Abrechnung (`renderPlanLeistung()`,
`js/plan/plan-leistung.js`): the value actually earned, per beauftragte
Leistungsphase, as **Honorar × HOAI-Anteil der Phase × Fortschritt der
@@ -1273,9 +1406,12 @@ step into a project explicit. Everything lives in `public/js/auftrag.js`:
"### Protokolle" below for the data model and the "Fortschreiben"
mechanism.
- **Einstellungen** — project number/name (display only — the page is
addressed by them), Gruppe, amtierende Leistungsphase, Beteiligten-Matrix
(all still rendered by `plan-info.js`), plus Honorar and the **beauftragte
Leistungsphasen** (`renderPlanSettings()`, `js/plan/plan-settings.js`).
addressed by them), Gruppe, amtierende Leistungsphase (both still
rendered by `plan-info.js`), plus Honorar, the **beauftragte
Leistungsphasen** and the **Beteiligten-Matrix**
(`renderPlanSettings()`/`planCycleAssign()`, `js/plan/plan-settings.js` —
the Matrix moved here from a read-only `plan-info.js` display once it
became genuinely editable, see "Ein Ort je Angabe" above).
- `p.honorar`/`p.beauftragteLP` can now be edited in TWO places: here and in
the "Terminplan aus Honorar" dialog (which keeps them so it stays usable on
its own). Both write through `setHonorarBetrag()`/`toggleBeauftragteLp()` in
@@ -3235,6 +3371,65 @@ across Gewerke or users. Erik's own words: "macht so keinen Sinn mehr."
no special-casing needed. A redirect `GET /Test.html`-style old link isn't
needed here since nothing ever pointed at this tab before it existed.
## Bewegung (Hover/Klick-Rückmeldung)
The app's interaction feedback is **token-driven and lives in one place**:
five variables in `:root` (`styles.css`), applied by a clearly-marked
"BEWEGUNG" block at the **end** of `styles.css` plus a smaller companion
block at the end of `plan-styles.css` for the Projektbereich-only row types.
Every rule reads through `var()`; changing the feel is a change to those
five values, not to the components.
```
--dur: 150ms kurze Rückmeldung (Fläche, Farbe, Anheben, Druck)
--dur-lg: 200ms grössere Zustandswechsel (Dialog, Aufklappen)
--ease-out cubic-bezier(.2,.8,.2,1), schnell los / weich aus
--row-lift: -1px Hub einer anklickbaren Zeile beim Zeigen
--press: .97 Stauchung beim Drücken
```
Erik picked this level ("Spürbar") from a three-way live comparison
(dezent / spürbar / lebendig) built with the Taste-Skill. **Level 3's
entrance animations were deliberately NOT taken**: with ~30 projects in a
list that re-renders on every save, rows animating in on each render is
unrest, not polish. The rule that follows from that: the surface moves
**only in response to pointing or clicking**, never on its own, and never
in a loop.
- **Row lift + accent rail** on clickable rows (`.board-row-main`,
`.bericht-rail-item`, `.planung-row`, `.dash-rand-zeile`, and in
`plan-styles.css` `.todo-depends-item`, `.pt-row-main`, `.plan-task-row`).
The rail is an **`inset` box-shadow growing 0 → 3px**, deliberately NOT a
`::before` with `position:absolute`: `.board-row` is `draggable`
(`js/projects/projects.js`), and an extra positioned element or a new
stacking context on a dragged row can shift the drag image and hit
targets. An inset shadow touches neither layout nor stacking. For the
same reason the transform sits on the **inner** `.board-row-main`, never
on the draggable `.board-row` itself (verified: the draggable element
keeps `transform: none`).
- **`.protokoll-liste-zeile` is the one exception** and must stay one: it
already carries `border-left: 3px solid transparent`, which its
`.ist-aktiv` state colours. It therefore animates *that existing border*
instead of adding the inset shadow. Using both would paint 6 px of accent
instead of 3.
- **Dialogs** use `animation`, not `transition`: `.modal-bg` is toggled
`display:none` ⇄ `flex` from JS, and a `display` change is not
transitionable, whereas an animation restarts every time the element is
rendered again (`modal-bg-auf` / `modal-auf`).
- **`prefers-reduced-motion` was missing from this codebase entirely** and
is now handled at the end of `styles.css`. Because the level is
token-driven, the block simply flips the tokens (`--row-lift: 0px`,
`--press: 1`, durations to 1ms); a blanket
`transition-duration/animation-duration: 1ms !important` additionally
catches the ~60 older rules that still hard-code their own timings.
- **Keyboard focus** got explicit `:focus-visible` outlines on the same
rows/chips. The lift and press effects answer mouse and touch only;
without this the rework would have left keyboard use worse off than
before.
- **The Wandtafel (`body.wandtafel-aktiv`) is deliberately excluded** — a
kiosk touch monitor has no hover, and that screen carries its own
`--dash-*` design anyway.
## Docker
- `docker compose up --build` from the repo root. Compose maps `3003:3003`,