feat(ui): Marke und Grundkomponenten (#002)

Bildmarke als Inline-SVG und Wortmarke, dazu Karte, Button, Badge,
Statuspunkt, Tabelle, Kennzahl, Diff-Box, Breadcrumb, Seitenkopf,
Leerzustand und MonoKopieren. Die Bausteine kapseln die Designregeln
(44 px Klickflaeche, Status nie nur ueber Farbe, Tabellen scrollen im
eigenen Container), damit spaetere Seiten sie nicht neu erfinden.
Nur MonoKopieren ist Client-Komponente, weil sie die Zwischenablage
braucht.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 09:20:26 +02:00
co-authored by Claude Opus 5.5
parent 88b5697cc9
commit da0607522e
15 changed files with 674 additions and 0 deletions
+49
View File
@@ -0,0 +1,49 @@
/**
* Bildmarke (Tresortür mit Schlüsselloch) als Inline-SVG (#002).
*
* Inline statt <img src="/brand/logo.svg">, 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 (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 64 64"
width={groesse}
height={groesse}
className={className}
{...(titel ? { role: "img", "aria-label": titel } : { "aria-hidden": true, focusable: false })}
>
<rect x="4" y="4" width="56" height="56" rx="14" fill="none" stroke={linie} strokeWidth="3.5" />
<circle
cx="32"
cy="32"
r="19"
fill="none"
stroke={linie}
strokeOpacity="0.28"
strokeWidth="2"
strokeDasharray="3 3.2"
/>
<circle cx="13.5" cy="13.5" r="2.2" fill={linie} />
<circle cx="50.5" cy="13.5" r="2.2" fill={linie} />
<circle cx="13.5" cy="50.5" r="2.2" fill={linie} />
<circle cx="50.5" cy="50.5" r="2.2" fill={linie} />
<circle cx="32" cy="27" r="7.5" fill={messing} />
<path d="M28.6 30.5 L25.5 45 H38.5 L35.4 30.5 Z" fill={messing} />
</svg>
);
}
+38
View File
@@ -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 (
<span
className={clsx("font-display leading-none font-bold whitespace-nowrap", className)}
style={{ fontSize: groesse, letterSpacing: "-0.02em" }}
>
<span className="text-text">SSHKey</span>
<span className="text-accent">Vault</span>
</span>
);
}
/** Kurzname immer kleingeschrieben in JetBrains Mono. */
export function Kurzname({ className }: { className?: string }) {
return <span className={clsx("font-mono lowercase", className)}>sshkv</span>;
}
/** 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 (
<span className={clsx("inline-flex items-center gap-2.5", className)}>
<Bildmarke groesse={bildGroesse} />
<Wortmarke groesse={textGroesse} />
</span>
);
}
+2
View File
@@ -0,0 +1,2 @@
export { Bildmarke } from "./Bildmarke";
export { Kurzname, Marke, Wortmarke } from "./Wortmarke";
+38
View File
@@ -0,0 +1,38 @@
import { clsx } from "clsx";
import type { ReactNode } from "react";
export type BadgeVariante = "neutral" | "messing" | "ok" | "gefahr";
const VARIANTEN: Record<BadgeVariante, string> = {
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 (
<span
className={clsx(
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-[12.5px] leading-5 whitespace-nowrap",
mono ? "font-mono font-medium" : "font-medium",
VARIANTEN[variante],
className,
)}
>
{children}
</span>
);
}
+38
View File
@@ -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 (
<nav aria-label="Brotkrumen" className="text-muted text-[13.5px]">
<ol className="flex flex-wrap items-center gap-x-1 gap-y-1">
{pfad.map((e, i) => {
const letzter = i === pfad.length - 1;
return (
<Fragment key={i}>
<li className="min-w-0">
{e.href && !letzter ? (
<Link href={e.href} className="hover:text-text rounded transition-colors">
{e.titel}
</Link>
) : (
<span aria-current={letzter ? "page" : undefined} className={letzter ? "text-text" : ""}>
{e.titel}
</span>
)}
</li>
{!letzter && (
<li aria-hidden className="flex">
<ChevronRight className="size-3.5" />
</li>
)}
</Fragment>
);
})}
</ol>
</nav>
);
}
+64
View File
@@ -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<ButtonVariante, string> = {
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<ComponentProps<"button">, keyof GemeinsameProps> & { href?: never };
type AlsLink = GemeinsameProps & Omit<ComponentProps<typeof Link>, 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 && (
<span aria-hidden className="-ml-0.5 inline-flex shrink-0">
{icon}
</span>
)}
{children}
</>
);
if ("href" in rest && rest.href !== undefined) {
return (
<Link {...(rest as Omit<AlsLink, keyof GemeinsameProps>)} className={klassen}>
{inhalt}
</Link>
);
}
const buttonRest = rest as Omit<AlsButton, keyof GemeinsameProps>;
return (
<button type="button" {...buttonRest} className={klassen}>
{inhalt}
</button>
);
}
+55
View File
@@ -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 (
<figure className={clsx("border-border bg-bg rounded-box min-w-0 overflow-hidden border", className)}>
{datei && (
<figcaption className="border-border text-muted border-b px-4 py-2.5 font-mono text-[12.5px] break-all">
{datei}
</figcaption>
)}
<ol className="py-2 font-mono text-[12.5px] leading-[1.6]">
{zeilen.map((z, i) => (
<li
key={i}
className={clsx(
"grid grid-cols-[1.75rem_1fr] px-2",
z.art === "entfernt" && "bg-danger-soft text-danger",
z.art === "hinzu" && "bg-[#0f1f1b] text-ok",
z.art === "kontext" && "text-muted",
)}
>
<span aria-hidden className="text-center select-none">
{ZEICHEN[z.art]}
</span>
<span className="sr-only">{LESBAR[z.art]}: </span>
<span className="min-w-0 pr-2 break-all whitespace-pre-wrap">{z.text || " "}</span>
</li>
))}
</ol>
{fuss && <div className="border-border border-t px-4 py-3">{fuss}</div>}
</figure>
);
}
+76
View File
@@ -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 (
<Element
className={clsx(
"border-border bg-surface min-w-0 rounded-karte border",
innen === "normal" && "p-5 sm:p-6",
className,
)}
>
{(titel || aktionen) && (
<KartenKopf titel={titel} untertitel={untertitel} aktionen={aktionen} rand={innen === "ohne"} />
)}
{children}
</Element>
);
}
function KartenKopf({
titel,
untertitel,
aktionen,
rand,
}: {
titel?: ReactNode;
untertitel?: ReactNode;
aktionen?: ReactNode;
rand: boolean;
}) {
return (
<header
className={clsx(
"mb-4 flex flex-wrap items-center justify-between gap-x-4 gap-y-2",
rand && "px-5 pt-5 sm:px-6 sm:pt-6",
)}
>
<div className="min-w-0">
{titel && <h2 className="text-text text-[17px] leading-snug font-semibold">{titel}</h2>}
{untertitel && <p className="text-muted mt-0.5 text-sm">{untertitel}</p>}
</div>
{aktionen && <div className="flex shrink-0 flex-wrap items-center gap-2">{aktionen}</div>}
</header>
);
}
/** Innere Box (--sidebar, 12 px Radius) – für Einträge innerhalb einer Karte. */
export function InnereBox({ children, className }: { children: ReactNode; className?: string }) {
return (
<div className={clsx("border-border bg-sidebar rounded-box min-w-0 border p-4", className)}>
{children}
</div>
);
}
+51
View File
@@ -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<KennzahlVariante, string> = {
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 = (
<>
<p className="text-muted text-[14px] font-medium">{titel}</p>
<p
className={clsx(
"font-display mt-2 text-[34px] leading-none font-semibold tracking-tight tabular-nums",
WERTFARBE[variante],
)}
>
{wert}
</p>
{hinweis && <p className="text-muted mt-2.5 text-[13px]">{hinweis}</p>}
</>
);
const klassen = "border-border bg-surface block min-w-0 rounded-karte border p-5 sm:p-6";
if (href) {
return (
<Link href={href} className={clsx(klassen, "hover:border-border-strong transition-colors")}>
{inhalt}
</Link>
);
}
return <div className={klassen}>{inhalt}</div>;
}
+30
View File
@@ -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 (
<div className="border-border-strong rounded-karte flex flex-col items-center border border-dashed px-6 py-12 text-center">
{icon && (
<div
aria-hidden
className="bg-raised text-muted rounded-box mb-4 flex size-12 items-center justify-center"
>
{icon}
</div>
)}
<p className="font-display text-text text-[18px] font-semibold">{titel}</p>
{text && <div className="text-muted mt-1.5 max-w-md text-[14.5px]">{text}</div>}
{aktion && <div className="mt-5">{aktion}</div>}
</div>
);
}
+64
View File
@@ -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<ReturnType<typeof setTimeout> | 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 (
<span className={clsx("inline-flex max-w-full min-w-0 items-start gap-1", className)}>
<code
className={clsx(
"text-text min-w-0 py-[11px] font-mono text-[13px] leading-[1.45]",
umbrechen ? "break-all" : "truncate",
)}
title={umbrechen ? undefined : wert}
>
{wert}
</code>
<button
type="button"
onClick={kopieren}
className="text-muted hover:bg-raised hover:text-text rounded-feld inline-flex size-11 shrink-0 items-center justify-center transition-colors"
aria-label={`${beschriftung} kopieren`}
>
{kopiert ? <Check className="text-ok size-4" aria-hidden /> : <Copy className="size-4" aria-hidden />}
</button>
<span role="status" className="sr-only">
{kopiert ? `${beschriftung} kopiert` : ""}
</span>
</span>
);
}
+32
View File
@@ -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 (
<header className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
<div className="min-w-0">
{oben && <div className="mb-3">{oben}</div>}
<h1 className="text-text text-[26px] leading-tight font-semibold tracking-tight break-words sm:text-[30px]">
{titel}
</h1>
{badges && <div className="mt-3 flex flex-wrap items-center gap-2">{badges}</div>}
{untertitel && <div className="text-muted mt-1.5 text-[14.5px]">{untertitel}</div>}
</div>
{aktionen && <div className="flex shrink-0 flex-wrap gap-2.5">{aktionen}</div>}
</header>
);
}
+41
View File
@@ -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<HostStatus, { text: string; punkt: string; farbe: string }> = {
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 (
<span className={clsx("inline-flex items-center gap-2 text-[14px]", d.farbe, className)}>
<span aria-hidden className={clsx("size-2 shrink-0 rounded-full", d.punkt)} />
<span>{text ?? d.text}</span>
</span>
);
}
+85
View File
@@ -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.
<div className={clsx("relative -mx-1 overflow-x-auto px-1", className)}>
<table className="w-full border-collapse text-left text-[14px]">
{beschriftung && <caption className="sr-only">{beschriftung}</caption>}
<thead>
<tr className="border-border-strong border-b">
{spalten.map((s, i) => (
<th
key={i}
scope="col"
className={clsx(
"text-muted px-3 pb-2.5 text-[13px] font-medium whitespace-nowrap first:pl-0 last:pr-0",
s.rechts && "text-right",
s.className,
)}
>
{s.versteckt ? <span className="sr-only">{s.titel}</span> : s.titel}
</th>
))}
</tr>
</thead>
<tbody>{children}</tbody>
</table>
</div>
);
}
export function TabellenZeile({ children, className }: { children: ReactNode; className?: string }) {
return <tr className={clsx("border-border border-b last:border-b-0", className)}>{children}</tr>;
}
export function TabellenZelle({
children,
rechts,
mono,
className,
}: {
children: ReactNode;
rechts?: boolean;
mono?: boolean;
className?: string;
}) {
return (
<td
className={clsx(
"px-3 py-3 align-middle first:pl-0 last:pr-0",
rechts && "text-right",
mono && "font-mono text-[13px]",
className,
)}
>
{children}
</td>
);
}
+11
View File
@@ -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";