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 <noreply@anthropic.com>
This commit is contained in:
@@ -1 +1,104 @@
|
|||||||
@import "tailwindcss";
|
@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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+18
-1
@@ -1,10 +1,27 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata, Viewport } from "next";
|
||||||
import type { ReactNode } from "react";
|
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";
|
import "./globals.css";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: { default: "SSHKeyVault", template: "%s · SSHKeyVault" },
|
title: { default: "SSHKeyVault", template: "%s · SSHKeyVault" },
|
||||||
description: "Zentrale Verwaltung von SSH-Schlüsseln",
|
description: "Zentrale Verwaltung von SSH-Schlüsseln",
|
||||||
|
applicationName: "SSHKeyVault",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const viewport: Viewport = {
|
||||||
|
themeColor: "#0D1013",
|
||||||
|
colorScheme: "dark",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||||
|
|||||||
@@ -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)));
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user