From 88b5697cc91d693f247352157ff1e4e9158f6ba7 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Mon, 12 Oct 2026 09:20:22 +0200 Subject: [PATCH] feat(ui): Design-Tokens, Schriften und Datumsformate (#002) Die Farben des Tresor-Designs stehen als CSS-Variablen in :root und werden per @theme inline an Tailwind gereicht, damit Inline-SVGs und Utility-Klassen dieselbe Quelle nutzen. Schriften kommen selbst gehostet aus @fontsource, nur in den benutzten Gewichten und im Latin-Ausschnitt. src/lib/format.ts rechnet fest in Europe/Berlin: der Server laeuft im Container auf UTC, die Anzeige soll aber ueberall dieselbe Ortszeit zeigen. relativ() nimmt den Bezugspunkt als Parameter, damit statische Seiten und Tests deterministisch bleiben. Co-Authored-By: Claude Opus 5.5 --- src/app/globals.css | 103 +++++++++++++++++++++++++++++++++++++++++++ src/app/layout.tsx | 19 +++++++- src/lib/format.ts | 89 +++++++++++++++++++++++++++++++++++++ tests/format.test.ts | 91 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 301 insertions(+), 1 deletion(-) create mode 100644 src/lib/format.ts create mode 100644 tests/format.test.ts diff --git a/src/app/globals.css b/src/app/globals.css index f1d8c73..49f3cd8 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1 +1,104 @@ @import "tailwindcss"; + +/* + * Design „Tresor“ (#002): Graphit-Flächen, eine Markenfarbe (Messing), Monospace für alles + * Technische. Die Werte stehen als CSS-Variablen in :root, damit auch Inline-SVGs und + * Stile ausserhalb von Tailwind dieselben Tokens nutzen; @theme reicht sie an Tailwind + * weiter (bg-surface, text-muted, border-strong …). + * + * Es gibt bewusst nur den dunklen Modus. Ein heller folgt in einer eigenen Phase (PLAN §5). + */ +:root { + --bg: #0d1013; + --sidebar: #11151a; + --surface: #151a1f; + --raised: #1d232a; + --border: #222930; + --border-strong: #2a323b; + --text: #e8ecef; + --text-muted: #8b96a3; + --accent: #f0b43c; + --accent-soft: #3a2e14; + --ok: #3cc9a7; + --danger: #f2714b; + --danger-soft: #2a1814; + --danger-border: #6b3324; + --info: #6aa8ff; + + --schrift-display: "Space Grotesk", ui-sans-serif, system-ui, sans-serif; + --schrift-text: "IBM Plex Sans", ui-sans-serif, system-ui, sans-serif; + --schrift-mono: "JetBrains Mono", ui-monospace, "SFMono-Regular", Menlo, monospace; + + --seitenleiste-breite: 248px; + + color-scheme: dark; +} + +/* inline: Tailwind-Klassen verweisen auf die Variablen statt die Werte zu kopieren. */ +@theme inline { + --color-bg: var(--bg); + --color-sidebar: var(--sidebar); + --color-surface: var(--surface); + --color-raised: var(--raised); + --color-border: var(--border); + --color-border-strong: var(--border-strong); + --color-text: var(--text); + --color-muted: var(--text-muted); + --color-accent: var(--accent); + --color-accent-soft: var(--accent-soft); + --color-ok: var(--ok); + --color-danger: var(--danger); + --color-danger-soft: var(--danger-soft); + --color-danger-border: var(--danger-border); + --color-info: var(--info); + + --font-display: var(--schrift-display); + --font-sans: var(--schrift-text); + --font-mono: var(--schrift-mono); + + --radius-karte: 16px; + --radius-box: 12px; + --radius-feld: 10px; +} + +@layer base { + html { + background: var(--bg); + color: var(--text); + font-family: var(--schrift-text); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; + } + + body { + min-height: 100dvh; + background: var(--bg); + font-size: 15px; + line-height: 1.5; + } + + h1, + h2, + h3 { + font-family: var(--schrift-display); + letter-spacing: -0.01em; + } + + /* Fokus immer sichtbar und in Messing – auch auf Elementen ohne eigenen Fokusstil. */ + :focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + border-radius: 6px; + } + + ::selection { + background: var(--accent-soft); + color: var(--text); + } + + a { + color: inherit; + text-decoration: none; + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index fb9fcde..7dae5f8 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,10 +1,27 @@ -import type { Metadata } from "next"; +import type { Metadata, Viewport } from "next"; import type { ReactNode } from "react"; + +// Schriften selbst gehostet (#002): keine CDNs, nur die Gewichte, die das Design nutzt, +// und nur der Latin-Ausschnitt (enthält die deutschen Umlaute und ß). +import "@fontsource/space-grotesk/latin-500.css"; +import "@fontsource/space-grotesk/latin-600.css"; +import "@fontsource/space-grotesk/latin-700.css"; +import "@fontsource/ibm-plex-sans/latin-400.css"; +import "@fontsource/ibm-plex-sans/latin-500.css"; +import "@fontsource/ibm-plex-sans/latin-600.css"; +import "@fontsource/jetbrains-mono/latin-400.css"; +import "@fontsource/jetbrains-mono/latin-500.css"; import "./globals.css"; export const metadata: Metadata = { title: { default: "SSHKeyVault", template: "%s · SSHKeyVault" }, description: "Zentrale Verwaltung von SSH-Schlüsseln", + applicationName: "SSHKeyVault", +}; + +export const viewport: Viewport = { + themeColor: "#0D1013", + colorScheme: "dark", }; export default function RootLayout({ children }: { children: ReactNode }) { diff --git a/src/lib/format.ts b/src/lib/format.ts new file mode 100644 index 0000000..d946fc0 --- /dev/null +++ b/src/lib/format.ts @@ -0,0 +1,89 @@ +/** + * Datums- und Zeitformate der Oberfläche (#002): TT.MM.JJJJ, 24-h-Zeit, deutsch. + * + * Die Zeitzone ist fest Europe/Berlin und nicht die des Prozesses: Server (UTC im + * Container) und Browser sollen dieselbe Zeit anzeigen, sonst springen Zeitstempel + * zwischen Server- und Client-Rendering um Stunden. + */ + +export const ZEITZONE = "Europe/Berlin"; + +type Eingabe = Date | string | number; + +const DATUM = new Intl.DateTimeFormat("de-DE", { + timeZone: ZEITZONE, + day: "2-digit", + month: "2-digit", + year: "numeric", +}); + +const UHRZEIT = new Intl.DateTimeFormat("de-DE", { + timeZone: ZEITZONE, + hour: "2-digit", + minute: "2-digit", + hourCycle: "h23", +}); + +function alsDatum(wert: Eingabe): Date { + const d = wert instanceof Date ? wert : new Date(wert); + if (Number.isNaN(d.getTime())) throw new RangeError(`Ungültiges Datum: ${String(wert)}`); + return d; +} + +/** „12.10.2026“ */ +export function datum(wert: Eingabe): string { + return DATUM.format(alsDatum(wert)); +} + +/** „08:42“ */ +export function uhrzeit(wert: Eingabe): string { + return UHRZEIT.format(alsDatum(wert)); +} + +/** „12.10.2026, 08:42“ */ +export function datumZeit(wert: Eingabe): string { + const d = alsDatum(wert); + return `${DATUM.format(d)}, ${UHRZEIT.format(d)}`; +} + +const MINUTE = 60_000; +const STUNDE = 60 * MINUTE; +const TAG = 24 * STUNDE; + +/** + * Grobe Relativangabe für Listen: „gerade eben“, „vor 5 Min.“, „vor 3 Std.“, + * „vor 2 Tagen“, „in 12 Tagen“. Ab 30 Tagen Abstand wird das Datum gezeigt, weil + * „vor 47 Tagen“ schlechter einzuordnen ist als ein Datum. + * + * `jetzt` ist ein Parameter, damit Tests und statisch gerenderte Seiten einen + * festen Bezugspunkt haben. + */ +export function relativ(wert: Eingabe, jetzt: Eingabe = new Date()): string { + const abstand = alsDatum(wert).getTime() - alsDatum(jetzt).getTime(); + const betrag = Math.abs(abstand); + const zukunft = abstand > 0; + + if (betrag < MINUTE) return "gerade eben"; + if (betrag >= 30 * TAG) return `am ${datum(wert)}`; + + let text: string; + if (betrag < STUNDE) { + text = `${Math.floor(betrag / MINUTE)} Min.`; + } else if (betrag < TAG) { + text = `${Math.floor(betrag / STUNDE)} Std.`; + } else { + const tage = Math.floor(betrag / TAG); + // „vor“ und „in“ verlangen beide den Dativ: „vor 1 Tag“, „in 2 Tagen“. + text = tage === 1 ? "1 Tag" : `${tage} Tagen`; + } + return zukunft ? `in ${text}` : `vor ${text}`; +} + +/** Ganze Kalendertage bis `wert` (negativ, wenn vorbei) – Grundlage für Ablaufhinweise. */ +export function tageBis(wert: Eingabe, jetzt: Eingabe = new Date()): number { + const tagesnummer = (d: Date) => { + const [t, m, j] = DATUM.format(d).split(".").map(Number); + return Date.UTC(j ?? 0, (m ?? 1) - 1, t ?? 1) / TAG; + }; + return Math.round(tagesnummer(alsDatum(wert)) - tagesnummer(alsDatum(jetzt))); +} diff --git a/tests/format.test.ts b/tests/format.test.ts new file mode 100644 index 0000000..88a90d5 --- /dev/null +++ b/tests/format.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; +import { datum, datumZeit, relativ, tageBis, uhrzeit, ZEITZONE } from "@/lib/format"; + +// Alle Erwartungen gelten für Europe/Berlin, unabhängig von der Zeitzone des Testprozesses. +describe("Formate (#002)", () => { + it("nutzt fest Europe/Berlin", () => { + expect(ZEITZONE).toBe("Europe/Berlin"); + }); + + describe("datum()", () => { + it("schreibt TT.MM.JJJJ mit führenden Nullen", () => { + expect(datum("2026-03-05T10:00:00Z")).toBe("05.03.2026"); + }); + it("rechnet in Berliner Zeit – 23:30 UTC ist dort schon der nächste Tag", () => { + expect(datum("2026-10-12T23:30:00Z")).toBe("13.10.2026"); + }); + it("nimmt Date, Zeitstempel und ISO-Text", () => { + const d = new Date("2026-01-01T12:00:00Z"); + expect(datum(d)).toBe("01.01.2026"); + expect(datum(d.getTime())).toBe("01.01.2026"); + }); + it("lehnt ungültige Eingaben ab", () => { + expect(() => datum("kein Datum")).toThrow(RangeError); + }); + }); + + describe("uhrzeit() und datumZeit()", () => { + it("zeigt 24-h-Zeit in Sommerzeit (UTC+2)", () => { + expect(uhrzeit("2026-07-01T13:05:00Z")).toBe("15:05"); + }); + it("zeigt 24-h-Zeit in Winterzeit (UTC+1)", () => { + expect(uhrzeit("2026-12-01T22:59:00Z")).toBe("23:59"); + }); + it("Mitternacht ist 00:00, nicht 24:00", () => { + expect(uhrzeit("2026-12-01T23:00:00Z")).toBe("00:00"); + }); + it("verbindet Datum und Zeit", () => { + expect(datumZeit("2026-10-12T06:42:00Z")).toBe("12.10.2026, 08:42"); + }); + it("berücksichtigt die Zeitumstellung am 25.10.2026", () => { + expect(datumZeit("2026-10-25T00:30:00Z")).toBe("25.10.2026, 02:30"); + expect(datumZeit("2026-10-25T01:30:00Z")).toBe("25.10.2026, 02:30"); + }); + }); + + describe("relativ()", () => { + const jetzt = new Date("2026-10-12T10:00:00Z"); + const vor = (ms: number) => new Date(jetzt.getTime() - ms); + const MIN = 60_000; + const STD = 60 * MIN; + const TAG = 24 * STD; + + it("unter einer Minute: gerade eben", () => { + expect(relativ(vor(30_000), jetzt)).toBe("gerade eben"); + expect(relativ(jetzt, jetzt)).toBe("gerade eben"); + }); + it("Minuten", () => { + expect(relativ(vor(5 * MIN), jetzt)).toBe("vor 5 Min."); + expect(relativ(vor(59 * MIN), jetzt)).toBe("vor 59 Min."); + }); + it("Stunden", () => { + expect(relativ(vor(3 * STD), jetzt)).toBe("vor 3 Std."); + expect(relativ(vor(23 * STD + 59 * MIN), jetzt)).toBe("vor 23 Std."); + }); + it("Tage mit Einzahl und Mehrzahl", () => { + expect(relativ(vor(TAG), jetzt)).toBe("vor 1 Tag"); + expect(relativ(vor(4 * TAG), jetzt)).toBe("vor 4 Tagen"); + }); + it("Zukunft", () => { + expect(relativ(vor(-12 * TAG), jetzt)).toBe("in 12 Tagen"); + expect(relativ(vor(-2 * STD), jetzt)).toBe("in 2 Std."); + }); + it("ab 30 Tagen das Datum", () => { + expect(relativ(vor(40 * TAG), jetzt)).toBe("am 02.09.2026"); + }); + it("nimmt ohne Bezugspunkt die aktuelle Zeit", () => { + expect(relativ(new Date())).toBe("gerade eben"); + }); + }); + + describe("tageBis()", () => { + const jetzt = "2026-10-12T21:00:00Z"; // in Berlin schon 23:00 am 12.10. + it("zählt Kalendertage in Berliner Zeit", () => { + expect(tageBis("2026-10-12T22:30:00Z", jetzt)).toBe(1); // 00:30 am 13.10. + expect(tageBis("2026-11-11T12:00:00Z", jetzt)).toBe(30); + }); + it("ist negativ für Vergangenes", () => { + expect(tageBis("2026-10-10T12:00:00Z", jetzt)).toBe(-2); + }); + }); +});