feat(frontend): v3-Umbau P2 — echtes Routing, Unterzustand in der URL

Dritte Etappe. Die Ansicht lag bisher im URL-Hash (`#models`), gespeist aus einem
useState in App.tsx. Das war fuer Reload und Lesezeichen brauchbar — aber nur auf
View-Ebene; fuer den Zustand DARUNTER war kein Platz.

## Was jetzt in der Adresse steht (Befund B-05)

  /modelle?reiter=routing     der Reiter war lokaler State, nicht teilbar
  /ideen?offen=<id>           die aufgeklappte Karte; wer waehrend eines
                              laufenden Workers neu lud, verlor seinen Platz
  /wissen?datei=<pfad>        jede Vault-Notiz verlinkbar
  <ueberall>?system=wartung|logs   die System-Schublade inkl. Reiter

Alle Suchparameter werden validiert (app/router.tsx) — ein Link mit Unsinn darin
faellt auf den Normalfall zurueck, statt die Ansicht in einen halben Zustand zu
bringen.

## Alte Lesezeichen brechen nicht

lib/hashUmleitung.ts biegt `#models` per replaceState auf `/modelle`, EINMAL vor
dem Router-Mount. Fuenf Tests decken das ab (jeder NAV-Eintrag, die Schreibweise
`#/models`, erhaltene Suchparameter, unbekannter Hash, kein Hash) — die Bruecke
sieht sonst wie toter Code aus und waere ein naheliegender Kandidat zum Wegraeumen.

Das Backend brauchte KEINE Zeile: der SPA-Catch-all in app.py:148-168 beantwortet
jede unbekannte Route schon immer mit index.html. Vorher geprueft, nicht gehofft.

## Fehlergrenze pro Route (Befund B-13)

Vorher hing eine einzige Grenze in main.tsx um alles — ein Renderfehler im
Modell-Manager nahm Cockpit, Konsole und Chronik mit. Jetzt faengt jede Route fuer
sich (components/RouteFehler.tsx: was passiert ist, was man tun kann, technische
Einzelheiten aufklappbar). AppErrorBoundary bleibt als letztes Netz fuer Fehler
ausserhalb jeder Route. Dazu eine 404-Seite, die Schiene und Kopfzeile stehen
laesst — im Browser geprueft.

## Budget: der Router kostete 29 kB, sie sind wieder drin

TanStack Router hob den Start-Chunk von 111 auf 141 kB gzip und riss damit das in
P0 gesetzte Ampel-Budget (125 kB). Statt das Budget hochzusetzen — was es als
Signal entwertet haette — den Platz zurueckgeholt: SystemDrawer und CommandPalette
sind beim Start UNSICHTBAR und liegen jetzt hinter lazy(). Die Palette laedt beim
ersten Strg+K und bleibt dann gemountet.

  Start-Chunk gzip  111 411 -> 114 978 B   (Budget 125 000, weiter scharf)
  dist gesamt                  1 565 521 B (Budget 3 145 728)

## Nebenbei

  · Befund B-09 erledigt: Der "Suchen"-Knopf baute ein GEFAELSCHTES KeyboardEvent
    (`new KeyboardEvent("keydown", { metaKey: true })`) und feuerte es aufs
    Dokument. Jetzt ein eigener Kanal; in P3 loest der Store auch den ab.
  · Der Schliessen-Knopf der System-Schublade hatte kein aria-label — ein reiner
    Icon-Knopf ohne Namen. Nachgeholt (beim Pruefen aufgefallen).
  · nav.ts fuehrt jetzt den Pfad je Eintrag + navFuerPfad() mit Laengen-Sortierung,
    damit /ideen/<id> die Sidebar bei "Ideen" markiert. Mit Tests.

## Verifiziert im Browser (lokales Backend, echtes Produktions-Buendel)

  /                        -> /cockpit, Sidebar markiert
  /#models                 -> /modelle, Sidebar markiert
  /modelle?reiter=routing  -> Reiter "Routing & Warm-Set" aktiv
  /cockpit?system=logs     -> Schublade offen, Reiter "System-Logs" aktiv;
                              Schliessen entfernt den Parameter wieder
  /gibtsnicht              -> 404-Seite, Schiene + Kopfzeile intakt
  Zurueck-Taste            -> voriger Pfad

26/26 Tests gruen · ESLint 0 Fehler · tsc sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-08-28 08:55:58 +02:00
co-authored by Claude Opus 5
parent ae69c5ce31
commit 464b0d20b8
50 changed files with 1143 additions and 607 deletions
+187
View File
@@ -0,0 +1,187 @@
import { lazy } from "react"
import {
createRootRoute,
createRoute,
createRouter,
redirect,
useNavigate,
} from "@tanstack/react-router"
import { Shell } from "@/App"
import { NAV } from "@/nav"
import { RouteFehler } from "@/components/RouteFehler"
import { CockpitView } from "@/views/cockpit/CockpitView"
// Routenbaum (v3-Umbau P2).
//
// Bewusst code-based statt dateibasiert: Es sind zwölf Routen, und sie stehen dadurch
// alle an EINER Stelle — inklusive dem, was in den Suchparametern erlaubt ist. Eine
// Datei-Konvention würde diese Übersicht auf zwölf Dateien verteilen, ohne dass jemand
// etwas davon hätte.
//
// Code-Splitting bleibt exakt wie bisher: die Views hängen an React.lazy, das Cockpit als
// Startseite lädt eifrig. Kein Chunk verschiebt sich durch den Router.
const ModelsView = lazy(() => import("@/views/ModelsView").then((m) => ({ default: m.ModelsView })))
const ConnectView = lazy(() => import("@/views/ConnectView").then((m) => ({ default: m.ConnectView })))
const AgentView = lazy(() => import("@/views/AgentView").then((m) => ({ default: m.AgentView })))
const KonsoleView = lazy(() => import("@/views/KonsoleView").then((m) => ({ default: m.KonsoleView })))
const GuideView = lazy(() => import("@/views/GuideView").then((m) => ({ default: m.GuideView })))
const AuftragsbuchView = lazy(() => import("@/views/AuftragsbuchView").then((m) => ({ default: m.AuftragsbuchView })))
const IdeenView = lazy(() => import("@/views/IdeenView").then((m) => ({ default: m.IdeenView })))
const SkillsView = lazy(() => import("@/views/SkillsView").then((m) => ({ default: m.SkillsView })))
const ChronikView = lazy(() => import("@/views/ChronikView").then((m) => ({ default: m.ChronikView })))
const WissenView = lazy(() => import("@/views/WissenView").then((m) => ({ default: m.WissenView })))
// ── Suchparameter ───────────────────────────────────────────────────────────
// Regel für das, was hier steht: Alles, was ein Kollege per Link teilen können soll und
// was einen Reload überleben muss. Nichts Flüchtiges (Hover, Entwürfe im Eingabefeld).
/** Wurzel: die System-Schublade. Gehört hierher, weil sie über JEDER Ansicht liegt. */
export interface WurzelSuche {
system?: "wartung" | "logs"
}
const wurzelSuche = (roh: Record<string, unknown>): WurzelSuche => {
const s = roh.system
return s === "wartung" || s === "logs" ? { system: s } : {}
}
export const rootRoute = createRootRoute({
component: Shell,
validateSearch: wurzelSuche,
notFoundComponent: () => (
<RouteFehler
titel="Diese Seite gibt es nicht"
text="Der Link zeigt auf eine Ansicht, die Mission Control nicht kennt. Vielleicht stammt er aus einer älteren Fassung."
/>
),
})
// Wurzelpfad → Cockpit. `redirect` statt einer Kopie der Startseite: sonst gäbe es
// zwei URLs für denselben Inhalt und die aktive Markierung in der Sidebar flackert.
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/",
beforeLoad: () => {
throw redirect({ to: "/cockpit" })
},
})
/** Eine Route je Ansicht — kurze Schreibweise, weil sich nur Pfad und Bauteil ändern.
* Generisch über den Pfad, damit TanStack Router den Literal-Typ behält: mit
* `path: string` verlöre `navigate({ to: "/cockpit" })` seine Typprüfung. */
function ansicht<P extends string>(path: P, component: () => React.ReactNode) {
return createRoute({
getParentRoute: () => rootRoute,
path,
component,
// Fehlergrenze PRO Route (Befund B-13): Vorher hing eine einzige Grenze in main.tsx
// um die ganze App — ein Renderfehler in den Modellen nahm Cockpit, Konsole und
// Chronik mit. Jetzt bleiben Schiene, Kopfzeile und Statusanzeige stehen.
errorComponent: ({ error, reset }) => (
<RouteFehler
titel="Diese Ansicht ist abgestürzt"
text="Der Rest von Mission Control läuft weiter. Erneut versuchen — oder in eine andere Ansicht wechseln."
fehler={error}
onErneut={reset}
/>
),
})
}
// Das Cockpit verlinkt in andere Bereiche und bekam den Sprung bisher als Prop von der
// App gereicht. Jetzt kommt er aus dem Router — die Ansicht selbst bleibt unverändert.
function CockpitSeite() {
const navigate = useNavigate()
return <CockpitView onNavigate={(v) => {
const ziel = NAV.find((n) => n.id === v)
if (ziel) navigate({ to: ziel.pfad })
}} />
}
const cockpitRoute = ansicht("/cockpit", () => <CockpitSeite />)
const auftraegeRoute = ansicht("/auftraege", () => <AuftragsbuchView />)
const skillsRoute = ansicht("/skills", () => <SkillsView />)
const chronikRoute = ansicht("/chronik", () => <ChronikView />)
const verbindenRoute = ansicht("/verbinden", () => <ConnectView />)
const konsoleRoute = ansicht("/konsole", () => <KonsoleView />)
const anleitungRoute = ansicht("/anleitung", () => <GuideView />)
const agentRoute = ansicht("/agent", () => <AgentView />)
/** Ideen: `?offen=<id>` hält die aufgeklappte Karte fest (Befund B-05).
* Vorher lag das in `useState` — wer während eines laufenden Workers neu lud,
* verlor seinen Platz in der Liste. */
export interface IdeenSuche {
offen?: string
}
const ideenRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/ideen",
validateSearch: (roh: Record<string, unknown>): IdeenSuche =>
typeof roh.offen === "string" && roh.offen ? { offen: roh.offen } : {},
component: () => <IdeenView />,
errorComponent: ({ error, reset }) => (
<RouteFehler titel="Die Ideen-Ansicht ist abgestürzt" text="Der Rest läuft weiter." fehler={error} onErneut={reset} />
),
})
/** Wissen: `?datei=<pfad>` macht jede Vault-Notiz verlinkbar. */
export interface WissenSuche {
datei?: string
}
const wissenRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/wissen",
validateSearch: (roh: Record<string, unknown>): WissenSuche =>
typeof roh.datei === "string" && roh.datei ? { datei: roh.datei } : {},
component: () => <WissenView />,
errorComponent: ({ error, reset }) => (
<RouteFehler titel="Die Wissens-Ansicht ist abgestürzt" text="Der Rest läuft weiter." fehler={error} onErneut={reset} />
),
})
/** Modelle: `?reiter=` — der Reiter war lokaler State und damit nicht teilbar. */
export type ModellReiter = "werkbank" | "routing" | "discover"
export interface ModelleSuche {
reiter?: ModellReiter
}
const modelleRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/modelle",
validateSearch: (roh: Record<string, unknown>): ModelleSuche => {
const r = roh.reiter
return r === "werkbank" || r === "routing" || r === "discover" ? { reiter: r } : {}
},
component: () => <ModelsView />,
errorComponent: ({ error, reset }) => (
<RouteFehler titel="Der Modell-Manager ist abgestürzt" text="Der Rest läuft weiter." fehler={error} onErneut={reset} />
),
})
export const routeTree = rootRoute.addChildren([
indexRoute,
cockpitRoute,
ideenRoute,
auftraegeRoute,
skillsRoute,
chronikRoute,
wissenRoute,
verbindenRoute,
konsoleRoute,
anleitungRoute,
modelleRoute,
agentRoute,
])
export const router = createRouter({
routeTree,
// Ein LAN-Gerät: der Verlauf muss nicht scrollen-restaurieren, aber die Position
// oben zu haben ist beim Ansichtswechsel richtig.
scrollRestoration: true,
defaultPreload: false,
})
declare module "@tanstack/react-router" {
interface Register {
router: typeof router
}
}