Preset b5vnF8SMi uebernommen
Some checks failed
CI / Backend (Go) (push) Failing after 30s
CI / Frontend (React/TypeScript) (push) Successful in 43s
CI / Sicherheitsprüfungen (push) Successful in 27s

Violett als Handlungsfarbe, Radius 0, schattenlos, durchgehend Geist Mono.
Kantig und ruhig — die praegenden Merkmale des Presets.

Drei begruendete Abweichungen, im Code festgehalten:

- **Die Statusfarben bleiben.** Das Preset kennt nur `destructive` und fuenf
  Diagrammfarben; PROMPT §106 verlangt fuenf Bedeutungen. Ohne sie liesse sich
  ein Teilfehler nicht von einem Erfolg unterscheiden — die eine Aussage, auf
  die es in dieser Konsole ankommt. Sie sind an die Neutraltoene des Presets
  angepasst; Rot stammt aus dessen `destructive`.
- **Das dunkle Thema haengt weiter an `[data-theme='dark']`.** Der Umschalter
  setzt dieses Attribut und ist getestet; `.dark` wird zusaetzlich unterstuetzt.
- **Die Schrift laedt nicht nach.** Geist Mono steht zuerst im Stapel und wird
  verwendet, wenn sie auf dem Geraet liegt; sonst greift die System-Monospace.
  Eine Schrift von einem fremden Host zu holen verbietet die CSP der
  Auslieferung — und ein Backup-Server, der fuer seine Oberflaeche ins Internet
  greift, waere auch ohne CSP falsch.

Eine Zuordnung war die Stolperstelle: In shadcn ist `accent` die dezente
Hover-Flaeche und `primary` die Farbe der Handlung. Sie zu verwechseln machte
jede Schaltflaeche grau. Das Preset-`primary` wird deshalb zu `--accent`, das
Preset-`accent` zu `--surface-hover`.

Die Benennung bleibt semantisch (`--surface-card`, `--text-primary`) statt
shadcn-typisch: Die Zuordnung steht an genau einer Stelle, und ein Wechsel des
Presets fasst keine einzige Komponente an.

Karten, Schaltflaechen, Dialoge, Toasts und die Anmeldemaske sind auf
schattenlos umgestellt; Dialog und Toast behalten einen kraeftigen Rand, weil
sie sonst ohne Trennung ueber der Seite schwebten — im dunklen Thema ist der
normale Rand schwarz. Die Seitenleiste bekommt die eigene Flaeche des Presets.

Nachgewiesen gegen Debian 12 mit nginx: alle 18 Seiten liefern 200, das
ausgelieferte CSS traegt Akzent, Radius 0 und Geist Mono, /retention zeigt
0 eigene Regeln und 6 anklickbare Vorlagen.

84 Tests gruen, tsc sauber, eslint ohne Warnung. CSS 28,7 KB.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Jerrit Fritzsche 2026-08-18 15:07:55 +02:00
parent 77e61dc2b3
commit fef0bd88f7
8 changed files with 162 additions and 140 deletions

View File

@ -87,7 +87,7 @@ export function AppShell({
<aside <aside
className={cn( className={cn(
'fixed inset-y-0 left-0 z-40 flex flex-col border-r border-line bg-card transition-[width,transform]', 'fixed inset-y-0 left-0 z-40 flex flex-col border-r border-line bg-sidebar transition-[width,transform]',
isSidebarCollapsed ? 'w-16' : 'w-60', isSidebarCollapsed ? 'w-16' : 'w-60',
isMobileMenuOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0', isMobileMenuOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0',
)} )}

View File

@ -24,7 +24,7 @@ const buttonVariants = cva(
{ {
variants: { variants: {
variant: { variant: {
primary: 'bg-accent text-accent-fg shadow-[var(--shadow-card)] hover:bg-accent-hover', primary: 'bg-accent text-accent-fg hover:bg-accent-hover',
secondary: 'border border-line-strong bg-card text-fg hover:bg-hover', secondary: 'border border-line-strong bg-card text-fg hover:bg-hover',
ghost: 'text-fg-muted hover:bg-hover hover:text-fg', ghost: 'text-fg-muted hover:bg-hover hover:text-fg',
destructive: destructive:

View File

@ -21,7 +21,7 @@ export function Card({
return ( return (
<RenderedComponent <RenderedComponent
className={cn('rounded-xl border border-line bg-card shadow-[var(--shadow-card)]', className)} className={cn('border border-line bg-card', className)}
{...restProps} {...restProps}
/> />
); );

View File

@ -46,7 +46,7 @@ export function DialogContent({
className={cn( className={cn(
'fixed left-1/2 top-1/2 z-50 w-[calc(100vw-2rem)] -translate-x-1/2 -translate-y-1/2', 'fixed left-1/2 top-1/2 z-50 w-[calc(100vw-2rem)] -translate-x-1/2 -translate-y-1/2',
'max-h-[calc(100vh-4rem)] overflow-y-auto', 'max-h-[calc(100vh-4rem)] overflow-y-auto',
'rounded-xl border border-line bg-card shadow-[var(--shadow-raised)]', 'border border-line-strong bg-card',
sizeClasses[size], sizeClasses[size],
className, className,
)} )}

View File

@ -177,7 +177,7 @@ export function CheckboxField({
checked={checked} checked={checked}
disabled={disabled} disabled={disabled}
onChange={(changeEvent) => onCheckedChange(changeEvent.target.checked)} onChange={(changeEvent) => onCheckedChange(changeEvent.target.checked)}
className="mt-0.5 size-4 shrink-0 rounded border-line-strong accent-[hsl(var(--accent))]" className="mt-0.5 size-4 shrink-0 rounded border-line-strong accent-[var(--accent)]"
/> />
<div className="min-w-0"> <div className="min-w-0">
<label <label

View File

@ -140,7 +140,7 @@ function ToastCard({
return ( return (
<div <div
className={cn( className={cn(
'pointer-events-auto rounded-lg border border-l-4 border-line bg-raised p-3 shadow-lg', 'pointer-events-auto border border-l-4 border-line-strong bg-raised p-3',
toneClasses[message.tone], toneClasses[message.tone],
)} )}
> >

View File

@ -106,7 +106,7 @@ export function LoginPage({ onAuthenticated }: LoginPageProps): React.JSX.Elemen
return ( return (
<div className="flex min-h-screen items-center justify-center bg-page p-4"> <div className="flex min-h-screen items-center justify-center bg-page p-4">
<main className="w-full max-w-sm rounded-lg border border-line bg-card p-6 shadow-sm"> <main className="w-full max-w-sm border border-line-strong bg-card p-6">
<h1 className="text-lg font-semibold text-fg">Syncova</h1> <h1 className="text-lg font-semibold text-fg">Syncova</h1>
{loginStep === 'credentials' ? ( {loginStep === 'credentials' ? (

View File

@ -1,168 +1,189 @@
/* /*
* Design-System der Syncova-Oberflaeche (Tailwind v4 + shadcn/ui). * Design-System der Syncova-Oberfläche.
* *
* Der Grundsatz aus PROMPT.md §106 gilt unveraendert und ist der Grund fuer den * Die Farben, Radien, Schatten und die Schrift stammen aus dem Preset
* Zuschnitt dieser Datei: Semantische Farben ausschliesslich fuer Status. Die * `b5vnF8SMi` (tweakcn). Es ist bewusst kantig (Radius 0), schattenlos und
* gesamte Oberflaeche ist neutral gehalten; wo Farbe erscheint, bedeutet sie * durchgehend in einer dicktengleichen Schrift — ein technischer, ruhiger Look.
* etwas. Eine bunte Oberflaeche verschleiert, welche Information wirklich
* dringend ist — und in einer Backup-Konsole ist genau das die einzige Frage,
* die zaehlt.
* *
* Deshalb gibt es hier keine "Markenfarbe" als Flaeche. Der Akzent ist ein * Drei Stellen weichen ab, und zwar begründet:
* gedecktes Blau und wird nur fuer Bedienelemente verwendet, nie fuer Zustaende. *
* 1. **Die Statusfarben bleiben.** Das Preset kennt nur `destructive` und fünf
* Diagrammfarben, aber PROMPT §106 verlangt fünf Bedeutungen: Grün, Gelb,
* Orange, Rot, Blau. Ohne sie ließe sich ein Teilfehler nicht von einem
* Erfolg unterscheiden — die eine Aussage, auf die es in dieser Konsole
* ankommt. Sie sind an die Neutraltöne des Presets angepasst.
* 2. **Das dunkle Thema hängt an `[data-theme='dark']`, nicht an `.dark`.** Der
* Umschalter der Konsole setzt dieses Attribut, und er ist getestet. Beide
* Schreibweisen werden unterstützt.
* 3. **Die Schrift lädt nicht nach.** Geist Mono steht zuerst im Stapel und
* wird verwendet, wenn sie auf dem Gerät liegt; sonst greift die
* System-Monospace. Eine Schrift von einem fremden Host zu holen verbietet
* die Content-Security-Policy der Auslieferung — und ein Backup-Server, der
* für seine Oberfläche ins Internet greift, wäre auch ohne CSP falsch.
*
* Die Benennung ist bewusst semantisch (`--surface-card`, `--text-primary`)
* statt shadcn-typisch (`--card`, `--foreground`): Die Zuordnung des Presets
* steht damit an genau einer Stelle, und ein Wechsel des Presets fasst keine
* einzige Komponente an.
*/ */
@import 'tailwindcss'; @import 'tailwindcss';
/* Dark Mode wird ueber ein Attribut geschaltet, nicht ueber die Medienabfrage /* Beide Schreibweisen: Der Umschalter setzt `data-theme`, `.dark` ist die
* allein: Der Betreiber soll die Wahl haben, und eine Konsole, die nachts von * shadcn-übliche Form und kostet nichts. */
* selbst umschaltet, waehrend jemand an einer Stoerung arbeitet, ist laestig. */ @custom-variant dark (&:where([data-theme='dark'], [data-theme='dark'] *, .dark, .dark *));
@custom-variant dark (&:where([data-theme='dark'], [data-theme='dark'] *));
@theme { @theme {
/* --- Schrift --- */ /* Geist Mono für alles — die Wahl des Presets. In einer Konsole voller
--font-sans: * Zahlen, Pfade und Kennungen ist das kein Stilbruch, sondern hilfreich:
system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif; * Ziffern stehen untereinander, und ein Pfad bricht dort, wo er soll. */
--font-mono: ui-monospace, 'SF Mono', Menlo, Consolas, monospace; --font-sans: 'Geist Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
--font-mono: 'Geist Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
/* --- Radien --- */ /* Radius 0 — das prägendste Merkmal des Presets. */
--radius-sm: 0.375rem; --radius-sm: 0;
--radius-md: 0.5rem; --radius-md: 0;
--radius-lg: 0.75rem; --radius-lg: 0;
--radius-xl: 1rem; --radius-xl: 0;
--radius-2xl: 0;
/* Schatten statt Rahmen, wo Tiefe gemeint ist. Ein Rahmen trennt, ein /* Das Preset ist schattenlos. Tiefe entsteht über Flächenkontrast. */
* Schatten hebt — Karten sollen heben, Tabellenzeilen trennen. */ --shadow-card: none;
--shadow-card: 0 1px 2px 0 hsl(240 20% 12% / 0.04), 0 1px 3px 0 hsl(240 20% 12% / 0.06); --shadow-raised: none;
--shadow-raised: 0 4px 12px -2px hsl(240 20% 12% / 0.10), 0 2px 4px -2px hsl(240 20% 12% / 0.06);
} }
/*
* Die Farben liegen als CSS-Variablen vor, damit dieselbe Komponente in beiden
* Themen funktioniert, ohne dass jede Klasse eine dark:-Variante braucht.
*/
:root, :root,
[data-theme='light'] { [data-theme='light'],
/* Flaechen — vier Ebenen reichen: Seite, Karte, erhoben, Rand. */ .light {
--surface-page: 240 20% 97%; /* --- Flächen --- */
--surface-card: 0 0% 100%; --surface-page: oklch(1 0 0);
--surface-raised: 0 0% 100%; --surface-card: oklch(1 0 0);
--surface-sunken: 240 20% 96%; --surface-raised: oklch(1 0 0);
--surface-hover: 240 20% 94%; --surface-sunken: oklch(0.9702 0 0);
--surface-hover: oklch(0.9702 0 0);
/* Die Seitenleiste hat im Preset eine eigene, leicht abgesetzte Fläche. */
--surface-sidebar: oklch(0.9851 0 0);
--border-default: 240 12% 90%; --border-default: oklch(0.9219 0 0);
--border-strong: 240 10% 80%; --border-strong: oklch(0.709 0 0);
--text-primary: 240 20% 12%; --text-primary: oklch(0.1448 0 0);
--text-secondary: 240 8% 44%; --text-secondary: oklch(0.5555 0 0);
--text-muted: 240 6% 58%; --text-muted: oklch(0.709 0 0);
--text-inverted: 0 0% 100%; --text-inverted: oklch(0.9691 0.0161 293.7558);
/* Akzent — Bedienelemente, niemals Zustand. */ /* Akzent = `primary` des Presets, nicht dessen `accent`.
--accent: 243 75% 58%; *
--accent-hover: 243 75% 50%; * In shadcn ist `accent` die dezente Hover-Fläche und `primary` die Farbe der
--accent-text: 0 0% 100%; * Handlung. Die beiden zu verwechseln machte jede Schaltfläche grau. */
--accent-subtle: 243 75% 96%; --accent: oklch(0.4907 0.2699 292.5633);
--accent-hover: oklch(0.4316 0.232 292.7118);
--accent-text: oklch(0.9691 0.0161 293.7558);
--accent-subtle: oklch(0.9674 0.0013 286.3752);
/* --- Statusfarben (PROMPT.md §106) --- /* --- Statusfarben (PROMPT §106) ---
* Gruen = Healthy, Gelb = Warning, Orange = High, Rot = Critical, * Grün = Healthy, Gelb = Warning, Orange = High, Rot = Critical,
* Blau/Neutral = Information. Sonst nichts. */ * Blau/Neutral = Information. Rot stammt aus `destructive` des Presets. */
--status-healthy: 142 72% 29%; --status-healthy: oklch(0.52 0.14 152);
--status-healthy-bg: 142 60% 95%; --status-healthy-bg: oklch(0.96 0.03 152);
--status-warning: 38 92% 33%; --status-warning: oklch(0.55 0.12 85);
--status-warning-bg: 45 90% 94%; --status-warning-bg: oklch(0.96 0.04 85);
--status-high: 21 90% 40%; --status-high: oklch(0.56 0.17 45);
--status-high-bg: 24 90% 95%; --status-high-bg: oklch(0.96 0.04 45);
--status-critical: 0 74% 42%; --status-critical: oklch(0.583 0.2387 28.4765);
--status-critical-bg: 0 85% 96%; --status-critical-bg: oklch(0.96 0.03 28);
--status-info: 221 83% 45%; --status-info: oklch(0.5405 0.2811 292.8523);
--status-info-bg: 214 95% 96%; --status-info-bg: oklch(0.96 0.02 293);
--status-neutral: 240 4% 42%; --status-neutral: oklch(0.5555 0 0);
--status-neutral-bg: 240 5% 95%; --status-neutral-bg: oklch(0.9702 0 0);
--focus-ring: 221 83% 53%; --focus-ring: oklch(0.4907 0.2699 292.5633);
} }
[data-theme='dark'] { [data-theme='dark'],
--surface-page: 240 15% 7%; .dark {
--surface-card: 240 13% 10%; --surface-page: oklch(0.1448 0 0);
--surface-raised: 240 12% 13%; --surface-card: oklch(0.2046 0 0);
--surface-sunken: 240 16% 5%; --surface-raised: oklch(0.2046 0 0);
--surface-hover: 240 12% 16%; --surface-sunken: oklch(0.2686 0 0);
--surface-hover: oklch(0.2686 0 0);
--surface-sidebar: oklch(0.2046 0 0);
--border-default: 240 8% 20%; /* Das Preset setzt im dunklen Thema reines Schwarz als Rand: Flächen trennen
--border-strong: 240 8% 30%; * sich über ihre Helligkeit, nicht über Linien. Übernommen. */
--border-default: oklch(0 0 0);
--border-strong: oklch(0.3 0 0);
--text-primary: 240 20% 97%; --text-primary: oklch(0.9851 0 0);
--text-secondary: 240 8% 70%; --text-secondary: oklch(0.709 0 0);
--text-muted: 240 6% 54%; --text-muted: oklch(0.5555 0 0);
--text-inverted: 240 15% 7%; --text-inverted: oklch(0.9691 0.0161 293.7558);
--accent: 243 80% 70%; --accent: oklch(0.6006 0.2425 293.8885);
--accent-hover: 243 80% 78%; --accent-hover: oklch(0.8101 0.1055 293.6966);
--accent-text: 240 15% 7%; --accent-text: oklch(0.1448 0 0);
--accent-subtle: 243 45% 20%; --accent-subtle: oklch(0.2739 0.0055 286.0326);
/* Im dunklen Thema werden die Statusfarben aufgehellt. Die dunklen Toene des /* Aufgehellt, sonst wären die Töne auf dunklem Grund nicht unterscheidbar —
* hellen Themas waeren auf dunklem Grund nicht mehr unterscheidbar — und ein * und ein nicht unterscheidbarer Statuston ist schlimmer als gar keiner. */
* nicht unterscheidbarer Statuston ist schlimmer als gar keiner. */ --status-healthy: oklch(0.75 0.16 152);
--status-healthy: 142 65% 55%; --status-healthy-bg: oklch(0.28 0.05 152);
--status-healthy-bg: 142 45% 15%; --status-warning: oklch(0.82 0.15 85);
--status-warning: 45 90% 60%; --status-warning-bg: oklch(0.29 0.05 85);
--status-warning-bg: 40 50% 15%; --status-high: oklch(0.75 0.16 45);
--status-high: 25 92% 60%; --status-high-bg: oklch(0.29 0.06 45);
--status-high-bg: 22 55% 15%; --status-critical: oklch(0.7022 0.1892 22.2279);
--status-critical: 0 85% 65%; --status-critical-bg: oklch(0.29 0.07 22);
--status-critical-bg: 0 50% 16%; --status-info: oklch(0.8101 0.1055 293.6966);
--status-info: 217 90% 65%; --status-info-bg: oklch(0.29 0.06 293);
--status-info-bg: 220 50% 17%; --status-neutral: oklch(0.709 0 0);
--status-neutral: 240 5% 65%; --status-neutral-bg: oklch(0.2686 0 0);
--status-neutral-bg: 240 5% 18%;
--focus-ring: 217 90% 62%; --focus-ring: oklch(0.6006 0.2425 293.8885);
} }
/* Die Variablen werden Tailwind als Farben bekannt gemacht, damit /* Die Variablen werden Tailwind als Farben bekannt gemacht, damit `bg-card`,
* bg-surface-card, text-status-critical usw. unmittelbar funktionieren. */ * `text-fg` und `text-critical` unmittelbar funktionieren. */
@theme inline { @theme inline {
--color-page: hsl(var(--surface-page)); --color-page: var(--surface-page);
--color-card: hsl(var(--surface-card)); --color-card: var(--surface-card);
--color-raised: hsl(var(--surface-raised)); --color-raised: var(--surface-raised);
--color-sunken: hsl(var(--surface-sunken)); --color-sunken: var(--surface-sunken);
--color-hover: hsl(var(--surface-hover)); --color-hover: var(--surface-hover);
--color-sidebar: var(--surface-sidebar);
--color-line: hsl(var(--border-default)); --color-line: var(--border-default);
--color-line-strong: hsl(var(--border-strong)); --color-line-strong: var(--border-strong);
--color-fg: hsl(var(--text-primary)); --color-fg: var(--text-primary);
--color-fg-muted: hsl(var(--text-secondary)); --color-fg-muted: var(--text-secondary);
--color-fg-subtle: hsl(var(--text-muted)); --color-fg-subtle: var(--text-muted);
--color-fg-inverted: hsl(var(--text-inverted)); --color-fg-inverted: var(--text-inverted);
--color-accent: hsl(var(--accent)); --color-accent: var(--accent);
--color-accent-hover: hsl(var(--accent-hover)); --color-accent-hover: var(--accent-hover);
--color-accent-fg: hsl(var(--accent-text)); --color-accent-fg: var(--accent-text);
--color-accent-subtle: hsl(var(--accent-subtle)); --color-accent-subtle: var(--accent-subtle);
--color-healthy: hsl(var(--status-healthy)); --color-healthy: var(--status-healthy);
--color-healthy-bg: hsl(var(--status-healthy-bg)); --color-healthy-bg: var(--status-healthy-bg);
--color-warning: hsl(var(--status-warning)); --color-warning: var(--status-warning);
--color-warning-bg: hsl(var(--status-warning-bg)); --color-warning-bg: var(--status-warning-bg);
--color-high: hsl(var(--status-high)); --color-high: var(--status-high);
--color-high-bg: hsl(var(--status-high-bg)); --color-high-bg: var(--status-high-bg);
--color-critical: hsl(var(--status-critical)); --color-critical: var(--status-critical);
--color-critical-bg: hsl(var(--status-critical-bg)); --color-critical-bg: var(--status-critical-bg);
--color-info: hsl(var(--status-info)); --color-info: var(--status-info);
--color-info-bg: hsl(var(--status-info-bg)); --color-info-bg: var(--status-info-bg);
--color-neutral: hsl(var(--status-neutral)); --color-neutral: var(--status-neutral);
--color-neutral-bg: hsl(var(--status-neutral-bg)); --color-neutral-bg: var(--status-neutral-bg);
--color-ring: hsl(var(--focus-ring)); --color-ring: var(--focus-ring);
} }
@layer base { @layer base {
* { * {
border-color: hsl(var(--border-default)); border-color: var(--border-default);
} }
html { html {
@ -172,21 +193,22 @@
} }
body { body {
background-color: hsl(var(--surface-page)); background-color: var(--surface-page);
color: hsl(var(--text-primary)); color: var(--text-primary);
font-family: var(--font-sans); font-family: var(--font-sans);
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
} }
/* Der Fokusring bleibt sichtbar. Ihn zu entfernen macht die Konsole fuer /* Der Fokusring bleibt sichtbar. Ihn zu entfernen macht die Konsole für
* Tastaturbedienung unbrauchbar — und an einer Stoerung arbeitet niemand mit * Tastaturbedienung unbrauchbar — und an einer Störung arbeitet niemand mit
* der Maus. */ * der Maus. */
:focus-visible { :focus-visible {
outline: 2px solid hsl(var(--focus-ring)); outline: 2px solid var(--focus-ring);
outline-offset: 2px; outline-offset: 2px;
} }
/* Zahlen in Tabellen sollen untereinander stehen. */ /* Bei einer dicktengleichen Schrift stehen Ziffern ohnehin untereinander;
* die Klasse bleibt, damit die Aufrufstellen unverändert gelten. */
.tabular { .tabular {
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }