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) => (
+ -
+
+ {ZEICHEN[z.art]}
+
+ {LESBAR[z.art]}:
+ {z.text || " "}
+
+ ))}
+
+ {fuss && {fuss}
}
+
+ );
+}
diff --git a/src/components/ui/Karte.tsx b/src/components/ui/Karte.tsx
new file mode 100644
index 0000000..c1b6bfd
--- /dev/null
+++ b/src/components/ui/Karte.tsx
@@ -0,0 +1,76 @@
+import { clsx } from "clsx";
+import type { ReactNode } from "react";
+
+/**
+ * Karte: --surface, 1 px Rahmen, 16 px Radius, 22–24 px Innenabstand (#002).
+ * Mit `titel` entsteht ein Kopf mit optionalen Aktionen rechts.
+ */
+export function Karte({
+ titel,
+ untertitel,
+ aktionen,
+ children,
+ className,
+ innen = "normal",
+ as: Element = "section",
+}: {
+ titel?: ReactNode;
+ untertitel?: ReactNode;
+ aktionen?: ReactNode;
+ children?: ReactNode;
+ className?: string;
+ /** „ohne“ für Inhalte, die bis an den Rand reichen (z. B. Tabellen mit eigenem Abstand). */
+ innen?: "normal" | "ohne";
+ as?: "section" | "div" | "article";
+}) {
+ return (
+
+ {(titel || aktionen) && (
+
+ )}
+ {children}
+
+ );
+}
+
+function KartenKopf({
+ titel,
+ untertitel,
+ aktionen,
+ rand,
+}: {
+ titel?: ReactNode;
+ untertitel?: ReactNode;
+ aktionen?: ReactNode;
+ rand: boolean;
+}) {
+ return (
+
+
+ {titel &&
{titel}
}
+ {untertitel &&
{untertitel}
}
+
+ {aktionen && {aktionen}
}
+
+ );
+}
+
+/** Innere Box (--sidebar, 12 px Radius) – für Einträge innerhalb einer Karte. */
+export function InnereBox({ children, className }: { children: ReactNode; className?: string }) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/components/ui/Kennzahl.tsx b/src/components/ui/Kennzahl.tsx
new file mode 100644
index 0000000..3be6295
--- /dev/null
+++ b/src/components/ui/Kennzahl.tsx
@@ -0,0 +1,51 @@
+import { clsx } from "clsx";
+import Link from "next/link";
+import type { ReactNode } from "react";
+
+export type KennzahlVariante = "neutral" | "gefahr" | "messing";
+
+const WERTFARBE: Record = {
+ neutral: "text-text",
+ gefahr: "text-danger",
+ messing: "text-accent",
+};
+
+/** Kennzahl-Kachel: große Zahl in Space Grotesk, Bezeichnung darüber, Hinweis darunter (#002). */
+export function Kennzahl({
+ titel,
+ wert,
+ hinweis,
+ variante = "neutral",
+ href,
+}: {
+ titel: string;
+ wert: ReactNode;
+ hinweis?: ReactNode;
+ variante?: KennzahlVariante;
+ /** Macht die ganze Kachel zum Link (z. B. auf die gefilterte Liste). */
+ href?: string;
+}) {
+ const inhalt = (
+ <>
+ {titel}
+
+ {wert}
+
+ {hinweis && {hinweis}
}
+ >
+ );
+ const klassen = "border-border bg-surface block min-w-0 rounded-karte border p-5 sm:p-6";
+ if (href) {
+ return (
+
+ {inhalt}
+
+ );
+ }
+ return {inhalt}
;
+}
diff --git a/src/components/ui/Leerzustand.tsx b/src/components/ui/Leerzustand.tsx
new file mode 100644
index 0000000..19bd242
--- /dev/null
+++ b/src/components/ui/Leerzustand.tsx
@@ -0,0 +1,30 @@
+import type { ReactNode } from "react";
+
+/** Hinweis für leere Listen und noch nicht gebaute Seiten (#002). */
+export function Leerzustand({
+ titel,
+ text,
+ icon,
+ aktion,
+}: {
+ titel: string;
+ text?: ReactNode;
+ icon?: ReactNode;
+ aktion?: ReactNode;
+}) {
+ return (
+
+ {icon && (
+
+ {icon}
+
+ )}
+
{titel}
+ {text &&
{text}
}
+ {aktion &&
{aktion}
}
+
+ );
+}
diff --git a/src/components/ui/MonoKopieren.tsx b/src/components/ui/MonoKopieren.tsx
new file mode 100644
index 0000000..5f00777
--- /dev/null
+++ b/src/components/ui/MonoKopieren.tsx
@@ -0,0 +1,64 @@
+"use client";
+
+import { clsx } from "clsx";
+import { Check, Copy } from "lucide-react";
+import { useRef, useState } from "react";
+
+/**
+ * Technischer Wert in Monospace mit Kopier-Button (#002). Client-Komponente, weil die
+ * Zwischenablage nur im Browser erreichbar ist. Nur für öffentliche Werte gedacht
+ * (Fingerprints, Public Keys, Befehle) – Private Keys gehören nie in Client-Props.
+ */
+export function MonoKopieren({
+ wert,
+ beschriftung = "Wert",
+ umbrechen = false,
+ className,
+}: {
+ wert: string;
+ /** Wofür der Button kopiert, für Screenreader: „Fingerprint kopieren“. */
+ beschriftung?: string;
+ /** Lange Werte umbrechen statt abschneiden. */
+ umbrechen?: boolean;
+ className?: string;
+}) {
+ const [kopiert, setKopiert] = useState(false);
+ const zeitgeber = useRef | null>(null);
+
+ async function kopieren() {
+ try {
+ await navigator.clipboard.writeText(wert);
+ setKopiert(true);
+ if (zeitgeber.current) clearTimeout(zeitgeber.current);
+ zeitgeber.current = setTimeout(() => setKopiert(false), 1800);
+ } catch {
+ // Ohne sichere Verbindung (http im LAN) gibt es keine Clipboard-API; der Wert
+ // bleibt markierbar, daher kein Fehlerdialog.
+ }
+ }
+
+ return (
+
+
+ {wert}
+
+
+
+ {kopiert ? `${beschriftung} kopiert` : ""}
+
+
+ );
+}
diff --git a/src/components/ui/Seitenkopf.tsx b/src/components/ui/Seitenkopf.tsx
new file mode 100644
index 0000000..374d536
--- /dev/null
+++ b/src/components/ui/Seitenkopf.tsx
@@ -0,0 +1,32 @@
+import type { ReactNode } from "react";
+
+/** Kopf jeder Seite: optional Brotkrumen, Titel, Badges, Untertitel und Aktionen rechts (#002). */
+export function Seitenkopf({
+ titel,
+ untertitel,
+ aktionen,
+ oben,
+ badges,
+}: {
+ titel: ReactNode;
+ untertitel?: ReactNode;
+ aktionen?: ReactNode;
+ /** Über dem Titel, typischerweise eine Breadcrumb. */
+ oben?: ReactNode;
+ /** Unter dem Titel, z. B. Typ/Kategorie/Ablauf. */
+ badges?: ReactNode;
+}) {
+ return (
+
+
+ {oben &&
{oben}
}
+
+ {titel}
+
+ {badges &&
{badges}
}
+ {untertitel &&
{untertitel}
}
+
+ {aktionen && {aktionen}
}
+
+ );
+}
diff --git a/src/components/ui/Statuspunkt.tsx b/src/components/ui/Statuspunkt.tsx
new file mode 100644
index 0000000..b55ea74
--- /dev/null
+++ b/src/components/ui/Statuspunkt.tsx
@@ -0,0 +1,41 @@
+import { clsx } from "clsx";
+
+export type HostStatus = "synchron" | "drift" | "unerreichbar" | "unbekannt";
+
+/*
+ * Status nie nur über Farbe (#002): Punktform und Text tragen die Information mit,
+ * damit sie auch bei Farbfehlsichtigkeit und in Graustufen-Ausdrucken lesbar bleibt.
+ */
+const DARSTELLUNG: Record = {
+ synchron: { text: "synchron", punkt: "bg-ok", farbe: "text-ok" },
+ drift: { text: "Drift", punkt: "bg-danger", farbe: "text-danger" },
+ unerreichbar: {
+ text: "nicht erreichbar",
+ punkt: "border-[1.5px] border-muted bg-transparent",
+ farbe: "text-muted",
+ },
+ unbekannt: {
+ text: "unbekannt",
+ punkt: "border-[1.5px] border-dashed border-muted bg-transparent",
+ farbe: "text-muted",
+ },
+};
+
+export function Statuspunkt({
+ status,
+ text,
+ className,
+}: {
+ status: HostStatus;
+ /** Abweichender Text (z. B. „2 fremde Schlüssel“); ohne ihn der Standardtext des Status. */
+ text?: string;
+ className?: string;
+}) {
+ const d = DARSTELLUNG[status];
+ return (
+
+
+ {text ?? d.text}
+
+ );
+}
diff --git a/src/components/ui/Tabelle.tsx b/src/components/ui/Tabelle.tsx
new file mode 100644
index 0000000..306b25f
--- /dev/null
+++ b/src/components/ui/Tabelle.tsx
@@ -0,0 +1,85 @@
+import { clsx } from "clsx";
+import type { ReactNode } from "react";
+
+export type Spalte = {
+ titel: ReactNode;
+ /** Rechtsbündig für Zahlen und Aktionen. */
+ rechts?: boolean;
+ /** Nur für Screenreader (z. B. Aktionsspalte ohne sichtbare Überschrift). */
+ versteckt?: boolean;
+ className?: string;
+};
+
+/**
+ * Tabelle mit eigenem horizontalem Scrollbereich (#002): Breite Inhalte schieben nie die
+ * ganze Seite, auf 390 px scrollt nur die Tabelle. Kopf mit --border-strong-Linie.
+ */
+export function Tabelle({
+ spalten,
+ children,
+ beschriftung,
+ className,
+}: {
+ spalten: Spalte[];
+ children: ReactNode;
+ /** Zugänglicher Name der Tabelle (caption, visuell versteckt). */
+ beschriftung?: string;
+ className?: string;
+}) {
+ return (
+ // relative: sonst entkommen absolut positionierte Kinder (sr-only-Texte) dem
+ // Scrollbereich und verbreitern die ganze Seite.
+
+
+ {beschriftung && {beschriftung}}
+
+
+ {spalten.map((s, i) => (
+ |
+ {s.versteckt ? {s.titel} : s.titel}
+ |
+ ))}
+
+
+ {children}
+
+
+ );
+}
+
+export function TabellenZeile({ children, className }: { children: ReactNode; className?: string }) {
+ return {children}
;
+}
+
+export function TabellenZelle({
+ children,
+ rechts,
+ mono,
+ className,
+}: {
+ children: ReactNode;
+ rechts?: boolean;
+ mono?: boolean;
+ className?: string;
+}) {
+ return (
+
+ {children}
+ |
+ );
+}
diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts
new file mode 100644
index 0000000..d6c20a9
--- /dev/null
+++ b/src/components/ui/index.ts
@@ -0,0 +1,11 @@
+export { Badge, type BadgeVariante } from "./Badge";
+export { Breadcrumb, type BreadcrumbEintrag } from "./Breadcrumb";
+export { Button, buttonKlassen, type ButtonVariante } from "./Button";
+export { DiffBox, type DiffZeile } from "./DiffBox";
+export { InnereBox, Karte } from "./Karte";
+export { Kennzahl, type KennzahlVariante } from "./Kennzahl";
+export { Leerzustand } from "./Leerzustand";
+export { MonoKopieren } from "./MonoKopieren";
+export { Seitenkopf } from "./Seitenkopf";
+export { Statuspunkt, type HostStatus } from "./Statuspunkt";
+export { Tabelle, TabellenZeile, TabellenZelle, type Spalte } from "./Tabelle";