Web-Anwendung: Design-Tokens, Texte, Gesundheitsendpunkt

Design-Tokens aus docs/10-design-system.md als CSS-Variablen: Grundpalette,
die sechs Klassifizierungsfarben, Eckenradius durchgehend 2 px, keine Schatten
ausser dem Fokusring. Der Schwärzungsbalken mit dem einmaligen Durchwischen
respektiert prefers-reduced-motion. Vier Tests halten die Tokens gegen das
Dokument.

IBM Plex selbst gehostet als woff2, latin und latin-ext getrennt über
unicode-range, font-display: swap, immutable-Cache.

Alle Oberflächentexte in lib/i18n/de.ts. Ein Test durchsucht app/ und
components/ nach Umlauten im JSX und schlägt fehl, wenn dort deutscher Text
steht. Er hat beim ersten Lauf gleich einen Verstoss gefunden.

/api/health prüft Datenbank, Discord-Gateway und ClamAV. Die Gesamtbewertung
fällt nur auf "down", wenn die Datenbank weg ist: die anderen Dienste haben
eigene Healthchecks im Stack, und ein ausgefallener Virenscanner soll nicht
den Web-Container als tot markieren.

Strikte CSP mit Nonce, kein unsafe-inline für Skripte. Die erste Route liegt
bei 100 kB JavaScript nach Kompression, Budget sind 120 kB.
This commit is contained in:
Harlekin
2026-09-01 00:01:41 +02:00
parent 8f0644e053
commit ade977538d
38 changed files with 1343 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
import { collectHealth } from '@/lib/health/check'
export const dynamic = 'force-dynamic'
export const revalidate = 0
/**
* Wird vom Healthcheck des Dienstes `web` benutzt, siehe docs/11-betrieb.md.
* 200 solange die Datenbank erreichbar ist, 503 sonst.
*/
export async function GET(): Promise<Response> {
const report = await collectHealth()
return Response.json(report, {
status: report.status === 'down' ? 503 : 200,
headers: { 'Cache-Control': 'no-store' },
})
}
+19
View File
@@ -0,0 +1,19 @@
'use client'
import { de } from '@/lib/i18n/de'
export default function Error({ reset }: { error: Error; reset: () => void }) {
return (
<main id="inhalt" className="mx-auto max-w-(--container-measure) px-6 py-12">
<h1 className="font-condensed">{de.errors.unexpected.title}</h1>
<p className="mt-2 text-text-dim">{de.errors.unexpected.body}</p>
<button
type="button"
onClick={reset}
className="mt-4 border border-line bg-raised px-3 py-1.5 font-condensed text-xs"
>
{de.common.retry}
</button>
</main>
)
}
+89
View File
@@ -0,0 +1,89 @@
/* IBM Plex, selbst gehostet, als woff2 untergesetzt (latin und latin-ext).
Die Wahl ist in docs/10-design-system.md begruendet: Plex wurde als Hausschrift
einer Institution entworfen, hat einen schmalen Schnitt fuer Tabellenkoepfe und
eine passende Mono fuer Kennzeichen.
font-display: swap nach docs/09-performance-und-ux.md. */
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/ibm-plex-sans-latin-400-normal.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304,
U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/ibm-plex-sans-latin-ext-400-normal.woff2') format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308,
U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113,
U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('/fonts/ibm-plex-sans-latin-500-normal.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304,
U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('/fonts/ibm-plex-sans-latin-ext-500-normal.woff2') format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308,
U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113,
U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
font-family: 'IBM Plex Sans Condensed';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('/fonts/ibm-plex-sans-condensed-latin-500-normal.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304,
U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'IBM Plex Sans Condensed';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('/fonts/ibm-plex-sans-condensed-latin-ext-500-normal.woff2') format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308,
U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113,
U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
font-family: 'IBM Plex Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/ibm-plex-mono-latin-400-normal.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304,
U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'IBM Plex Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/ibm-plex-mono-latin-ext-400-normal.woff2') format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308,
U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113,
U+2C60-2C7F, U+A720-A7FF;
}
+230
View File
@@ -0,0 +1,230 @@
@import 'tailwindcss';
@import './fonts.css';
/* ---------------------------------------------------------------------------
Design-Tokens. Fachliche Grundlage: docs/10-design-system.md.
Bezugspunkt ist eine Registratur im Nachtdienst: lackierter Stahl,
Aktenpapier, Stempelfarbe. Die einzige Farbe kommt von der Klassifizierung.
--------------------------------------------------------------------------- */
@theme {
/* Grundpalette, dunkel ist der Auslieferungszustand */
--color-ink-900: #15181a; /* Grundflaeche, kuehles Anthrazit, leicht gruenstichig */
--color-ink-800: #1d2124; /* Panel */
--color-ink-700: #262b2f; /* erhoehte Flaeche, Eingabefelder */
--color-ink-500: #3a4147; /* Linien und Rahmen */
--color-paper: #e4e7e4; /* Primaertext, papierfarbenes Off-White */
--color-paper-dim: #939c99; /* Sekundaertext */
--color-steel: #7a97a6; /* interaktiv: Links, Fokus, aktive Zustaende */
/* Klassifizierungsfarben. Das einzige Buntsystem, nirgends sonst benutzt. */
--color-stamp-0: #8a918d; /* Offen, Grau */
--color-stamp-1: #6d8a9c; /* Intern, Stahlblau */
--color-stamp-2: #4e8f72; /* Vertraulich, Gruenspan */
--color-stamp-3: #b08637; /* Geheim, Messing */
--color-stamp-4: #b4543c; /* Streng geheim, Oxidrot */
--color-stamp-5: #7e5e96; /* Leitungssache, Tiefviolett */
/* Semantische Ebene. Das helle Thema tauscht nur diese Werte. */
--color-surface: var(--color-ink-900);
--color-panel: var(--color-ink-800);
--color-raised: var(--color-ink-700);
--color-line: var(--color-ink-500);
--color-text: var(--color-paper);
--color-text-dim: var(--color-paper-dim);
--color-accent: var(--color-steel);
/* Schrift. IBM Plex, selbst gehostet, drei Rollen aus einer Familie. */
--font-sans: 'IBM Plex Sans', ui-sans-serif, system-ui, sans-serif;
--font-condensed: 'IBM Plex Sans Condensed', 'IBM Plex Sans', ui-sans-serif, sans-serif;
--font-mono: 'IBM Plex Mono', ui-monospace, monospace;
/* Skala 12 / 13 / 15 / 18 / 24 px */
--text-2xs: 0.75rem; /* 12 */
--text-xs: 0.8125rem; /* 13 */
--text-sm: 0.9375rem; /* 15 */
--text-base: 0.9375rem; /* 15, Fliesstext */
--text-lg: 1.125rem; /* 18 */
--text-xl: 1.5rem; /* 24 */
--text-2xs--line-height: 1.4;
--text-xs--line-height: 1.45;
--text-sm--line-height: 1.6;
--text-base--line-height: 1.6;
--text-lg--line-height: 1.45;
--text-xl--line-height: 1.3;
/* Struktur: Eckenradius 2 px, ueberall gleich. Akten sind nicht abgerundet. */
--radius-none: 0px;
--radius-sm: 2px;
--radius-DEFAULT: 2px;
--radius-md: 2px;
--radius-lg: 2px;
--radius-xl: 2px;
/* Keine Schatten ausser dem Fokusring. */
--shadow-sm: none;
--shadow-DEFAULT: none;
--shadow-md: none;
--shadow-lg: none;
/* Zeilenlaenge hoechstens 72 Zeichen */
--container-measure: 72ch;
/* Die 3 px breite Klassifizierungskante links am Objekt */
--spacing-edge: 3px;
}
/* Helles Thema. Verpflichtend mitzuliefern, ausgebaut in Phase 9. */
[data-theme='light'] {
--color-surface: #eceeea;
--color-panel: #e2e5e1;
--color-raised: #f4f5f2;
--color-line: #b9beb8;
--color-text: #1b1f21;
--color-text-dim: #545c58;
--color-accent: #3d5f70;
}
@layer base {
*,
*::before,
*::after {
border-color: var(--color-line);
}
html {
color-scheme: dark;
background-color: var(--color-surface);
}
[data-theme='light'] {
color-scheme: light;
}
body {
background-color: var(--color-surface);
color: var(--color-text);
font-family: var(--font-sans);
font-size: var(--text-base);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
/* Spaltenkoepfe, Stempel und Reiter tragen den schmalen Schnitt.
Kein Versalsatz: der Stempelcharakter kommt aus Schnitt, Rahmen und Farbe. */
th,
legend {
font-family: var(--font-condensed);
font-weight: 500;
text-transform: none;
}
/* Aktenzeichen, Zeitstempel, Hashes und Dienstnummern in Mono. */
code,
kbd,
samp,
time {
font-family: var(--font-mono);
}
h1,
h2,
h3 {
font-weight: 500;
text-wrap: balance;
}
h1 {
font-size: var(--text-xl);
}
h2 {
font-size: var(--text-lg);
}
h3 {
font-size: var(--text-base);
}
a {
color: var(--color-accent);
text-underline-offset: 2px;
}
/* Sichtbarer Fokusring in --steel, der einzige Schatten im System. */
:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
border-radius: 2px;
}
::selection {
background-color: color-mix(in srgb, var(--color-accent) 40%, transparent);
}
}
@layer components {
/* Der eine mutige Moment: die Schwaerzung. Ein massiver Balken mit feiner
Rahmenlinie, der beim ersten Erscheinen einmal von links nach rechts
durchwischt. Sonst bewegt sich in der Oberflaeche nichts von selbst. */
.redaction {
display: inline-block;
position: relative;
overflow: hidden;
vertical-align: baseline;
background-color: var(--color-ink-900);
border: 1px solid var(--color-line);
border-radius: 2px;
color: transparent;
user-select: none;
}
.redaction::after {
content: '';
position: absolute;
inset: 0;
background-color: var(--color-ink-700);
transform-origin: left center;
animation: redaction-wipe 150ms ease-out 1 both;
}
@keyframes redaction-wipe {
from {
transform: scaleX(1);
}
to {
transform: scaleX(0);
}
}
/* Zeitachse des Verlaufs: durchgehende senkrechte Linie. Der Chat hat keine.
Der Unterschied muss ohne Beschriftung erkennbar sein. */
.timeline {
position: relative;
padding-left: 1.5rem;
}
.timeline::before {
content: '';
position: absolute;
left: 0.4375rem;
top: 0;
bottom: 0;
width: 1px;
background-color: var(--color-line);
}
}
@media (prefers-reduced-motion: reduce) {
.redaction::after {
animation: none;
transform: scaleX(0);
}
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
+48
View File
@@ -0,0 +1,48 @@
import type { Metadata, Viewport } from 'next'
import { de } from '@/lib/i18n/de'
import './globals.css'
export const metadata: Metadata = {
title: { default: de.app.name, template: `%s · ${de.app.shortName}` },
description: de.app.description,
robots: { index: false, follow: false },
}
export const viewport: Viewport = {
themeColor: '#15181A',
width: 'device-width',
initialScale: 1,
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="de">
<head>
{/* Nur die beiden Schnitte vorladen, die auf jeder Seite vorkommen. */}
<link
rel="preload"
href="/fonts/ibm-plex-sans-latin-400-normal.woff2"
as="font"
type="font/woff2"
crossOrigin="anonymous"
/>
<link
rel="preload"
href="/fonts/ibm-plex-mono-latin-400-normal.woff2"
as="font"
type="font/woff2"
crossOrigin="anonymous"
/>
</head>
<body>
<a
href="#inhalt"
className="sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-50 focus:border focus:border-line focus:bg-panel focus:px-3 focus:py-2"
>
{de.nav.skipToContent}
</a>
{children}
</body>
</html>
)
}
+24
View File
@@ -0,0 +1,24 @@
import { SkeletonLine, Skeleton } from '@/components/Skeleton'
/**
* Jede Route hat ein Skelett, das die spaetere Struktur nachbildet.
* Nie eine leere Seite mit Spinner, siehe docs/09-performance-und-ux.md.
*/
export default function Loading() {
return (
<main className="mx-auto max-w-(--container-measure) px-6 py-12">
<Skeleton className="h-[1.5rem]" width="18rem" />
<div className="mt-3">
<SkeletonLine />
<SkeletonLine width="70%" />
</div>
<hr className="my-8 border-line" />
<Skeleton className="h-[1.125rem]" width="10rem" />
<div className="mt-4">
<SkeletonLine />
<SkeletonLine />
<SkeletonLine />
</div>
</main>
)
}
+10
View File
@@ -0,0 +1,10 @@
import { de } from '@/lib/i18n/de'
export default function NotFound() {
return (
<main id="inhalt" className="mx-auto max-w-(--container-measure) px-6 py-12">
<h1 className="font-condensed">{de.errors.notFound.title}</h1>
<p className="mt-2 text-text-dim">{de.errors.notFound.body}</p>
</main>
)
}
+72
View File
@@ -0,0 +1,72 @@
import { Suspense } from 'react'
import { collectHealth, type ComponentState } from '@/lib/health/check'
import { de } from '@/lib/i18n/de'
import { SkeletonLine } from '@/components/Skeleton'
export const dynamic = 'force-dynamic'
const STATE_LABEL: Record<ComponentState, string> = {
up: de.health.ok,
degraded: de.health.degraded,
down: de.health.down,
}
async function SystemState() {
const report = await collectHealth()
return (
<table className="w-full max-w-(--container-measure) border-collapse text-sm">
<caption className="pb-2 text-left font-condensed text-xs text-text-dim">
{de.health.checkedAt(new Date(report.checkedAt).toLocaleString('de-DE'))}
</caption>
<thead>
<tr className="border-b border-line text-left text-xs text-text-dim">
<th scope="col" className="py-1.5 pr-4 font-normal">
{de.health.service}
</th>
<th scope="col" className="py-1.5 font-normal">
{de.health.state}
</th>
</tr>
</thead>
<tbody>
{Object.entries(report.components).map(([key, value]) => (
<tr key={key} className="border-b border-line/60">
<td className="py-2 pr-4">{de.health.components[key] ?? key}</td>
<td className="py-2">
{/* Zustand als Text, nicht nur als Farbe. */}
<span className="font-condensed">{STATE_LABEL[value.status]}</span>
{value.detail ? (
<span className="ml-2 font-mono text-2xs text-text-dim">{value.detail}</span>
) : null}
</td>
</tr>
))}
</tbody>
</table>
)
}
export default function Home() {
return (
<main id="inhalt" className="mx-auto max-w-(--container-measure) px-6 py-12">
<h1 className="font-condensed">{de.app.name}</h1>
<p className="mt-2 max-w-(--container-measure) text-text-dim">{de.app.description}</p>
<hr className="my-8 border-line" />
<h2 className="mb-4 font-condensed">{de.health.heading}</h2>
<Suspense
fallback={
<div className="max-w-(--container-measure)">
<SkeletonLine width="14rem" />
<SkeletonLine />
<SkeletonLine />
<SkeletonLine />
</div>
}
>
<SystemState />
</Suspense>
</main>
)
}