+ );
+}
diff --git a/src/components/huelle/Platzhalterseite.tsx b/src/components/huelle/Platzhalterseite.tsx
new file mode 100644
index 0000000..ec143a8
--- /dev/null
+++ b/src/components/huelle/Platzhalterseite.tsx
@@ -0,0 +1,42 @@
+import { Construction } from "lucide-react";
+import { Leerzustand } from "@/components/ui/Leerzustand";
+import { Seitenkopf } from "@/components/ui/Seitenkopf";
+
+/**
+ * Für Navigationsziele, die erst in späteren Phasen gebaut werden (#002) – damit die
+ * Navigation nie auf eine 404 führt.
+ */
+export function Platzhalterseite({
+ titel,
+ untertitel,
+ phase,
+ issue,
+}: {
+ titel: string;
+ untertitel?: string;
+ phase: number;
+ /** Issue-Nummer, z. B. "#008". */
+ issue?: string;
+}) {
+ return (
+
+
+ }
+ titel={`Folgt in Phase ${phase}`}
+ text={
+ <>
+ Diese Ansicht ist noch nicht gebaut
+ {issue && (
+ <>
+ {" "}
+ – geplant in {issue}
+ >
+ )}
+ .
+ >
+ }
+ />
+
+ );
+}
diff --git a/src/components/huelle/SeitenleistenRahmen.tsx b/src/components/huelle/SeitenleistenRahmen.tsx
new file mode 100644
index 0000000..de7c0a6
--- /dev/null
+++ b/src/components/huelle/SeitenleistenRahmen.tsx
@@ -0,0 +1,91 @@
+"use client";
+
+import { clsx } from "clsx";
+import { Menu, X } from "lucide-react";
+import { useEffect, useRef, useState, type MouseEvent, type ReactNode } from "react";
+
+/**
+ * Schaltet die Seitenleiste unter 1024 px ein und aus (#002). Der Inhalt der Leiste kommt
+ * als Server-gerenderte children; nur das Auf/Zu lebt im Browser.
+ *
+ * Geschlossen ist die Leiste mobil `invisible` und damit auch aus der Tab-Reihenfolge –
+ * nur verschieben würde unsichtbare Links fokussierbar lassen.
+ */
+export function SeitenleistenRahmen({ kopf, children }: { kopf: ReactNode; children: ReactNode }) {
+ const [offen, setOffen] = useState(false);
+ const knopf = useRef(null);
+
+ useEffect(() => {
+ if (!offen) return;
+ function taste(e: KeyboardEvent) {
+ if (e.key === "Escape") {
+ setOffen(false);
+ knopf.current?.focus();
+ }
+ }
+ window.addEventListener("keydown", taste);
+ return () => window.removeEventListener("keydown", taste);
+ }, [offen]);
+
+ // Ein Klick auf einen Link in der Leiste navigiert – danach soll das Menü zu sein.
+ function klickInLeiste(e: MouseEvent) {
+ if ((e.target as HTMLElement).closest("a")) setOffen(false);
+ }
+
+ return (
+ <>
+ {/* Kopfleiste nur auf schmalen Bildschirmen */}
+
+
+ {kopf}
+
+
+ {offen && (
+
setOffen(false)}
+ />
+ )}
+
+ {/* Ab 1024 px eine eigene Spalte über die volle Seitenhöhe; die Leiste darin klebt
+ oben (sticky), damit Hintergrund und Rahmen auch bei langen Seiten durchlaufen. */}
+
+
+
+ >
+ );
+}
diff --git a/src/components/huelle/index.ts b/src/components/huelle/index.ts
new file mode 100644
index 0000000..b7c5ba5
--- /dev/null
+++ b/src/components/huelle/index.ts
@@ -0,0 +1,3 @@
+export { Huelle } from "./Huelle";
+export { Platzhalterseite } from "./Platzhalterseite";
+export { ROLLEN_TEXT, type HuellenBenutzer, type Rolle } from "./typen";
diff --git a/src/components/huelle/nav-punkte.ts b/src/components/huelle/nav-punkte.ts
new file mode 100644
index 0000000..0537ba3
--- /dev/null
+++ b/src/components/huelle/nav-punkte.ts
@@ -0,0 +1,37 @@
+import {
+ Boxes,
+ KeyRound,
+ LayoutDashboard,
+ LayoutTemplate,
+ Network,
+ ScrollText,
+ Server,
+ Settings,
+ Users,
+ type LucideIcon,
+} from "lucide-react";
+
+export type NavPunkt = { href: string; titel: string; icon: LucideIcon };
+
+/** Hauptnavigation (#002). Reihenfolge folgt dem Arbeitsablauf: Hosts → Schlüssel → Zuweisen. */
+export const HAUPTNAVIGATION: NavPunkt[] = [
+ { href: "/", titel: "Übersicht", icon: LayoutDashboard },
+ { href: "/hosts", titel: "Hosts", icon: Server },
+ { href: "/hostgruppen", titel: "Hostgruppen", icon: Boxes },
+ { href: "/schluessel", titel: "Schlüssel", icon: KeyRound },
+ { href: "/personen", titel: "Personen", icon: Users },
+ { href: "/zuweisungen", titel: "Zuweisungen", icon: Network },
+ { href: "/vorlagen", titel: "Vorlagen", icon: LayoutTemplate },
+];
+
+/** Unter dem Trenner: Nachvollziehen und Verwalten statt Tagesgeschäft. */
+export const NEBENNAVIGATION: NavPunkt[] = [
+ { href: "/protokoll", titel: "Audit-Log", icon: ScrollText },
+ { href: "/einstellungen", titel: "Einstellungen", icon: Settings },
+];
+
+/** „/“ nur exakt, sonst zählt auch jede Unterseite (/schluessel/abc → Schlüssel). */
+export function istAktiv(href: string, pfad: string): boolean {
+ if (href === "/") return pfad === "/";
+ return pfad === href || pfad.startsWith(`${href}/`);
+}
diff --git a/src/components/huelle/typen.ts b/src/components/huelle/typen.ts
new file mode 100644
index 0000000..18fef4d
--- /dev/null
+++ b/src/components/huelle/typen.ts
@@ -0,0 +1,13 @@
+export type Rolle = "ADMIN" | "OPERATOR" | "LESER";
+
+/**
+ * Was die Hülle vom angemeldeten Benutzer wissen muss – bewusst nur Anzeigedaten,
+ * damit die Hülle unabhängig von Sitzung und Datenbank bleibt (#002, #004).
+ */
+export type HuellenBenutzer = { name: string; rolle: Rolle; herkunft: string };
+
+export const ROLLEN_TEXT: Record = {
+ ADMIN: "Admin",
+ OPERATOR: "Operator",
+ LESER: "Leser",
+};
diff --git a/src/components/marke/Bildmarke.tsx b/src/components/marke/Bildmarke.tsx
new file mode 100644
index 0000000..e553e18
--- /dev/null
+++ b/src/components/marke/Bildmarke.tsx
@@ -0,0 +1,49 @@
+/**
+ * Bildmarke (Tresortür mit Schlüsselloch) als Inline-SVG (#002).
+ *
+ * Inline statt , damit die Marke ohne zusätzliche Anfrage
+ * sofort steht und die Farben aus den CSS-Tokens kommen. Geometrie identisch mit
+ * public/brand/logo.svg – Änderungen bitte an beiden Stellen und `pnpm icons` laufen lassen.
+ */
+export function Bildmarke({
+ groesse = 34,
+ titel,
+ className,
+}: {
+ /** Kantenlänge in px. */
+ groesse?: number;
+ /** Zugänglicher Name; ohne Titel gilt die Marke als Schmuck (neben der Wortmarke). */
+ titel?: string;
+ className?: string;
+}) {
+ const linie = "var(--text)";
+ const messing = "var(--accent)";
+ return (
+
+ );
+}
diff --git a/src/components/marke/Wortmarke.tsx b/src/components/marke/Wortmarke.tsx
new file mode 100644
index 0000000..ab247eb
--- /dev/null
+++ b/src/components/marke/Wortmarke.tsx
@@ -0,0 +1,38 @@
+import { clsx } from "clsx";
+import { Bildmarke } from "./Bildmarke";
+
+/** „SSHKey“ in Textfarbe, „Vault“ in Messing – Space Grotesk 700 (#002). */
+export function Wortmarke({ groesse = 22, className }: { groesse?: number; className?: string }) {
+ return (
+
+ SSHKey
+ Vault
+
+ );
+}
+
+/** Kurzname immer kleingeschrieben in JetBrains Mono. */
+export function Kurzname({ className }: { className?: string }) {
+ return sshkv;
+}
+
+/** Bildmarke + Wortmarke nebeneinander, Standardgrößen wie in der Seitenleiste (34/22 px). */
+export function Marke({
+ bildGroesse = 34,
+ textGroesse = 22,
+ className,
+}: {
+ bildGroesse?: number;
+ textGroesse?: number;
+ className?: string;
+}) {
+ return (
+
+
+
+
+ );
+}
diff --git a/src/components/marke/index.ts b/src/components/marke/index.ts
new file mode 100644
index 0000000..8036dda
--- /dev/null
+++ b/src/components/marke/index.ts
@@ -0,0 +1,2 @@
+export { Bildmarke } from "./Bildmarke";
+export { Kurzname, Marke, Wortmarke } from "./Wortmarke";
diff --git a/src/components/ui/Badge.tsx b/src/components/ui/Badge.tsx
new file mode 100644
index 0000000..0d5b078
--- /dev/null
+++ b/src/components/ui/Badge.tsx
@@ -0,0 +1,38 @@
+import { clsx } from "clsx";
+import type { ReactNode } from "react";
+
+export type BadgeVariante = "neutral" | "messing" | "ok" | "gefahr";
+
+const VARIANTEN: Record = {
+ neutral: "bg-raised text-text border-border-strong",
+ // Messing auf --accent-soft: Ablaufhinweise und Vorlagen-Kennzeichnung.
+ messing: "bg-accent-soft text-accent border-[#5a4519]",
+ ok: "bg-[#12261f] text-ok border-[#1f4a3d]",
+ gefahr: "bg-danger-soft text-danger border-danger-border",
+};
+
+/** Pillenförmiges Etikett; `mono` für technische Werte wie Schlüsseltypen (#002). */
+export function Badge({
+ variante = "neutral",
+ mono = false,
+ children,
+ className,
+}: {
+ variante?: BadgeVariante;
+ mono?: boolean;
+ children: ReactNode;
+ className?: string;
+}) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/components/ui/Breadcrumb.tsx b/src/components/ui/Breadcrumb.tsx
new file mode 100644
index 0000000..05dba65
--- /dev/null
+++ b/src/components/ui/Breadcrumb.tsx
@@ -0,0 +1,38 @@
+import { ChevronRight } from "lucide-react";
+import Link from "next/link";
+import { Fragment } from "react";
+
+export type BreadcrumbEintrag = { titel: string; href?: string };
+
+/** Brotkrumen-Navigation; der letzte Eintrag ist die aktuelle Seite (#002). */
+export function Breadcrumb({ pfad }: { pfad: BreadcrumbEintrag[] }) {
+ return (
+
+ );
+}
diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx
new file mode 100644
index 0000000..9d76fff
--- /dev/null
+++ b/src/components/ui/Button.tsx
@@ -0,0 +1,64 @@
+import { clsx } from "clsx";
+import Link from "next/link";
+import type { ComponentProps, ReactNode } from "react";
+
+export type ButtonVariante = "primaer" | "sekundaer" | "gefahr";
+
+const VARIANTEN: Record = {
+ primaer: "bg-accent text-bg font-semibold border-accent hover:bg-[#f5c25c]",
+ sekundaer: "bg-surface text-text font-medium border-border-strong hover:bg-raised",
+ gefahr: "bg-transparent text-danger font-medium border-danger-border hover:bg-danger-soft",
+};
+
+/** Gemeinsame Klassen – auch für Elemente, die wie ein Button aussehen sollen. */
+export function buttonKlassen(variante: ButtonVariante = "sekundaer", klein = false, className?: string) {
+ return clsx(
+ // 44 px Mindesthöhe als Klickfläche (WCAG 2.5.5), auch in der kleinen Variante.
+ "inline-flex min-h-11 items-center justify-center gap-2 rounded-box border px-4 text-[14px] leading-tight whitespace-nowrap transition-colors",
+ "disabled:cursor-not-allowed disabled:opacity-50",
+ klein && "px-3 text-[13px]",
+ VARIANTEN[variante],
+ className,
+ );
+}
+
+type GemeinsameProps = {
+ variante?: ButtonVariante;
+ /** Lucide-Icon o. Ä. vor dem Text. */
+ icon?: ReactNode;
+ klein?: boolean;
+ className?: string;
+ children: ReactNode;
+};
+
+type AlsButton = GemeinsameProps & Omit, keyof GemeinsameProps> & { href?: never };
+type AlsLink = GemeinsameProps & Omit, keyof GemeinsameProps> & { href: string };
+
+/** Button in den Varianten primär/sekundär/Gefahr; mit `href` wird daraus ein Link. */
+export function Button(props: AlsButton | AlsLink) {
+ const { variante = "sekundaer", icon, klein = false, className, children, ...rest } = props;
+ const klassen = buttonKlassen(variante, klein, className);
+ const inhalt = (
+ <>
+ {icon && (
+
+ {icon}
+
+ )}
+ {children}
+ >
+ );
+ if ("href" in rest && rest.href !== undefined) {
+ return (
+ )} className={klassen}>
+ {inhalt}
+
+ );
+ }
+ const buttonRest = rest as Omit;
+ return (
+
+ );
+}
diff --git a/src/components/ui/DiffBox.tsx b/src/components/ui/DiffBox.tsx
new file mode 100644
index 0000000..9d966c8
--- /dev/null
+++ b/src/components/ui/DiffBox.tsx
@@ -0,0 +1,55 @@
+import { clsx } from "clsx";
+import type { ReactNode } from "react";
+
+export type DiffZeile = { art: "hinzu" | "entfernt" | "kontext"; text: string };
+
+const ZEICHEN = { hinzu: "+", entfernt: "−", kontext: " " } as const;
+const LESBAR = { hinzu: "hinzugefügt", entfernt: "entfernt", kontext: "unverändert" } as const;
+
+/**
+ * Diff-Vorschau (Dry-Run) für authorized_keys, ~/.ssh/config & Co. (#002).
+ * Entfernte Zeilen Koralle auf --danger-soft, hinzugefügte in --ok. Lange Schlüsselzeilen
+ * brechen um statt die Seite zu verbreitern – ein Public Key ist sonst 400+ Zeichen breit.
+ */
+export function DiffBox({
+ zeilen,
+ datei,
+ fuss,
+ className,
+}: {
+ zeilen: DiffZeile[];
+ /** Pfad der Datei als Kopfzeile, z. B. „backup@srv-pve01:~/.ssh/authorized_keys“. */
+ datei?: string;
+ fuss?: ReactNode;
+ className?: string;
+}) {
+ return (
+
+ {datei && (
+
+ {datei}
+
+ )}
+
+ {zeilen.map((z, i) => (
+