From 3c62a1a29c7cc7bf7cc99ec969d386fa3dfa47e5 Mon Sep 17 00:00:00 2001 From: Schalli Date: Fri, 25 Sep 2026 14:32:37 +0200 Subject: [PATCH 01/12] style(web): Design-Tokens Mosaik (Fluent 2 / GitHub) Kuehlgraue Arbeitsflaeche, weisse Karten mit Fluent-Schatten statt Rahmen, dunkle App-Leiste, Link-Blau, neutraler Fokusring, 6/8/12 px Radien, Segoe-UI-Schriftstapel, Knopf-/Link-Klassen, Fluent-Eingabefeld und reduzierte Bewegung. Die persoenliche Akzentfarbe setzt nur noch --primary. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/web/src/app/globals.css | 298 ++++++++++++++++++++++---- apps/web/src/lib/stores/auth-store.ts | 22 +- 2 files changed, 264 insertions(+), 56 deletions(-) diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index ac5a1b5..1e60bc2 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -57,45 +57,85 @@ --color-status-idle-fg: var(--status-idle-fg); --color-status-orphan-fg: var(--status-orphan-fg); --color-well: var(--well); + --color-link: var(--link); + --color-primary-strong: var(--primary-strong); + --color-input-strong: var(--input-strong); + --color-appbar: var(--appbar); + --color-appbar-foreground: var(--appbar-foreground); + --color-appbar-muted: var(--appbar-muted); + --color-appbar-hover: var(--appbar-hover); + --color-sidebar-hover: var(--sidebar-hover); --radius-sm: calc(var(--radius) - 2px); --radius-md: var(--radius); --radius-lg: calc(var(--radius) + 2px); } +/* Fluent-2-Hoehenstufen: shadow-sm = shadow2 (ruhende Karte), shadow-md = + shadow4 (angehobene/Hover-Karte), shadow-lg = shadow16 (Menues, Dialoge). + Im dunklen Erscheinungsbild tragen Haarlinien statt Schatten (--border). */ +@theme { + --shadow-sm: 0 0 2px oklch(0 0 0 / 0.12), 0 1px 2px oklch(0 0 0 / 0.14); + --shadow-md: 0 0 2px oklch(0 0 0 / 0.12), 0 4px 8px oklch(0 0 0 / 0.14); + --shadow-lg: 0 0 2px oklch(0 0 0 / 0.12), 0 8px 16px oklch(0 0 0 / 0.14); + --shadow-xl: 0 0 8px oklch(0 0 0 / 0.12), 0 14px 28px oklch(0 0 0 / 0.14); +} + :root { - --radius: 0.5rem; - --font-sans: "Inter", system-ui, -apple-system, sans-serif; + /* Design „Mosaik“ (Fluent 2 / GitHub als Vorbild): 6 px fuer Bedien- + elemente (rounded-md), 8 px fuer Karten (rounded-lg), 12 px fuer grosse + Flaechen (rounded-xl). */ + --radius: 0.375rem; + /* Segoe UI Variable zuerst: unter Windows (Desktop-Client) wirkt die + Oberflaeche damit wie eine native Anwendung; sonst Inter bzw. Systemschrift. */ + --font-sans: "Segoe UI Variable Text", "Segoe UI", Inter, system-ui, -apple-system, sans-serif; /* Layout dimensions */ --sidebar-width: 240px; --sidebar-width-collapsed: 64px; - --header-height: 60px; + --header-height: 48px; - /* Light mode colors (OKLCH) */ - --background: oklch(0.99 0 0); - --foreground: oklch(0.15 0 0); + /* Light mode colors (OKLCH) — Arbeitsflaeche leicht kuehlgrau, Karten weiss */ + --background: oklch(0.972 0.003 260); + --foreground: oklch(0.2 0.006 260); --card: oklch(1 0 0); - --card-foreground: oklch(0.15 0 0); + --card-foreground: oklch(0.2 0.006 260); --primary: oklch(0.91 0.19 102); --primary-foreground: oklch(0.20 0.02 90); - --secondary: oklch(0.96 0 0); - --secondary-foreground: oklch(0.15 0 0); - --muted: oklch(0.96 0 0); - --muted-foreground: oklch(0.55 0 0); - --accent: oklch(0.96 0 0); - --accent-foreground: oklch(0.15 0 0); + --secondary: oklch(0.955 0.003 260); + --secondary-foreground: oklch(0.2 0.006 260); + --muted: oklch(0.955 0.003 260); + --muted-foreground: oklch(0.49 0.01 260); + --accent: oklch(0.955 0.003 260); + --accent-foreground: oklch(0.2 0.006 260); --destructive: oklch(0.55 0.2 27); --destructive-foreground: oklch(0.98 0 0); - --border: oklch(0.90 0 0); - --input: oklch(0.90 0 0); - --ring: oklch(0.91 0.19 102); + --border: oklch(0.915 0.004 260); + --input: oklch(0.87 0 0); + /* Untere Kante von Eingabefeldern (Fluent-Eingabefeld). */ + --input-strong: oklch(0.58 0.005 260); + /* Tastaturfokus: dunkler Ring statt Gelb — Gelb auf Weiss erreicht nur + 1,3:1 und waere als Fokusanzeige kaum sichtbar. */ + --ring: oklch(0.3 0.01 260); + /* Verweise: nie gelbe Schrift auf Weiss. 5,9:1 auf --card. */ + --link: oklch(0.50 0.15 255); + /* Akzent fuer duenne Markierungen (Auswahlpille, Reiter-Unterstrich) auf + hellem Grund: die Akzentfarbe um knapp ein Drittel abgedunkelt, damit + auch Gelb neben Hellgrau noch erkennbar bleibt. */ + --primary-strong: color-mix(in oklab, var(--primary) 70%, oklch(0.3 0.03 90)); + + /* Kopfzeile (dunkle App-Leiste wie GitHub / Microsoft 365) */ + --appbar: oklch(0.24 0.012 260); + --appbar-foreground: oklch(0.985 0 0); + --appbar-muted: oklch(0.985 0 0 / 0.72); + --appbar-hover: oklch(1 0 0 / 0.08); /* Sidebar tokens */ - --sidebar: oklch(0.97 0 0); - --sidebar-foreground: oklch(0.15 0 0); - --sidebar-accent: oklch(0.93 0 0); - --sidebar-accent-foreground: oklch(0.25 0 0); - --sidebar-border: oklch(0.90 0 0); + --sidebar: oklch(0.975 0.003 260); + --sidebar-foreground: oklch(0.2 0.006 260); + --sidebar-accent: oklch(0.925 0.005 260); + --sidebar-accent-foreground: oklch(0.16 0.006 260); + --sidebar-hover: oklch(0.945 0.004 260); + --sidebar-border: oklch(0.915 0.004 260); /* Statusfarben (quick-260924-h7x). Die Flaechentoene stammen aus dem Design-Plan; als Schrift auf Weiss erreichen sie aber nur 2,6:1 bis @@ -113,34 +153,44 @@ --status-idle-fg: oklch(0.50 0.02 260); --status-orphan-fg: oklch(0.50 0.01 260); /* Eingelassene Messfelder. */ - --well: oklch(0.975 0.003 260); + --well: oklch(0.972 0.003 260); } .dark { - --background: oklch(0.23 0.01 260); - --foreground: oklch(0.95 0 0); - --card: oklch(0.27 0.01 260); - --card-foreground: oklch(0.95 0 0); + --background: oklch(0.2 0.01 260); + --foreground: oklch(0.95 0.003 260); + --card: oklch(0.245 0.01 260); + --card-foreground: oklch(0.95 0.003 260); --primary: oklch(0.91 0.19 102); --primary-foreground: oklch(0.20 0.02 90); - --secondary: oklch(0.30 0.01 260); - --secondary-foreground: oklch(0.95 0 0); - --muted: oklch(0.30 0.01 260); - --muted-foreground: oklch(0.65 0 0); - --accent: oklch(0.30 0.01 260); - --accent-foreground: oklch(0.95 0 0); - --destructive: oklch(0.55 0.2 27); + --secondary: oklch(0.28 0.01 260); + --secondary-foreground: oklch(0.95 0.003 260); + --muted: oklch(0.28 0.01 260); + --muted-foreground: oklch(0.72 0.01 260); + --accent: oklch(0.28 0.01 260); + --accent-foreground: oklch(0.95 0.003 260); + --destructive: oklch(0.6 0.2 27); --destructive-foreground: oklch(0.98 0 0); - --border: oklch(0.30 0.01 260); - --input: oklch(0.30 0.01 260); - --ring: oklch(0.91 0.19 102); + /* Dunkel: feine helle Haarlinie statt Schatten. */ + --border: oklch(1 0 0 / 0.09); + --input: oklch(1 0 0 / 0.16); + --input-strong: oklch(1 0 0 / 0.42); + --ring: oklch(0.92 0.005 260); + --link: oklch(0.78 0.11 250); + --primary-strong: var(--primary); + + --appbar: oklch(0.18 0.01 260); + --appbar-foreground: oklch(0.985 0 0); + --appbar-muted: oklch(0.985 0 0 / 0.7); + --appbar-hover: oklch(1 0 0 / 0.08); /* Sidebar tokens (dark) */ - --sidebar: oklch(0.20 0.01 260); - --sidebar-foreground: oklch(0.95 0 0); - --sidebar-accent: oklch(0.91 0.19 102 / 0.15); - --sidebar-accent-foreground: oklch(0.91 0.19 102); - --sidebar-border: oklch(0.30 0.01 260); + --sidebar: oklch(0.21 0.01 260); + --sidebar-foreground: oklch(0.93 0.003 260); + --sidebar-accent: oklch(1 0 0 / 0.08); + --sidebar-accent-foreground: oklch(0.98 0 0); + --sidebar-hover: oklch(1 0 0 / 0.05); + --sidebar-border: oklch(1 0 0 / 0.08); /* Statusfarben (dark). Textvariante auf --card gemessen: ok 7,4:1, warn 8,5:1, down 6,1:1, idle 6,1:1, orphan 5,6:1. */ @@ -154,11 +204,13 @@ --status-down-fg: oklch(0.74 0.16 27); --status-idle-fg: oklch(0.72 0.02 260); --status-orphan-fg: oklch(0.70 0.01 260); - --well: oklch(0.235 0.01 260); + --well: oklch(0.215 0.01 260); } body { font-family: var(--font-sans); + font-size: 0.875rem; + line-height: 1.25rem; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } @@ -181,3 +233,165 @@ body { .wmde-markdown ul ul { list-style: circle; } .wmde-markdown ol { list-style: decimal; } .wmde-markdown ul.contains-task-list, .wmde-markdown li.task-list-item { list-style: none; } + +/* Tastaturfokus sichtbar ueberall, auch wo ein Bauteil keinen eigenen + Fokusring setzt (im Basis-Layer, damit `outline-none` + eigener Ring + eines Bauteils Vorrang behalten). Mausklicks loesen :focus-visible nicht aus. */ +@layer base { + :where(a, button, [role="tab"], [role="button"], summary, [tabindex]):focus-visible { + outline: 2px solid var(--ring); + outline-offset: 2px; + } +} + +/* Fluent-Eingabefeld: untere Kante eine Stufe dunkler; Fokus = dunklerer + Rahmen plus 2 px Akzentlinie unten. Gilt fuer Textfelder und Auswahllisten + mit dem ueblichen `border-input`/`border-border`-Rahmen; Fehlerzustaende + (aria-invalid, border-destructive) behalten ihre Farbe. Bewusst ausserhalb + eines @layer, damit die Regel die Tailwind-Utilities schlaegt. */ +:is(input:not([type="checkbox"], [type="radio"], [type="range"], [type="color"], [type="file"]), select, textarea):is(.border-input, .border-border):not([aria-invalid="true"], .border-destructive) { + border-bottom-color: var(--input-strong); +} +:is(input:not([type="checkbox"], [type="radio"], [type="range"], [type="color"], [type="file"]), select, textarea):is(.border-input, .border-border):not([aria-invalid="true"], .border-destructive):focus { + outline: none; + border-color: var(--input-strong); + border-bottom-color: var(--primary); + box-shadow: inset 0 -1px 0 var(--primary); +} + +/* Karteneffekt in einer Klasse fuer neue Flaechen: hell Schatten, dunkel Haarlinie. */ +.surface { + background: var(--card); + border-radius: 0.5rem; + box-shadow: var(--shadow-sm); +} +.dark .surface { + box-shadow: none; + outline: 1px solid var(--border); + outline-offset: -1px; +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} + +/* Knoepfe (Design „Mosaik“, Fluent 2): 32 px hoch, 6 px Radius, 14 px + halbfett. Primaer = Akzentflaeche mit dunkler Schrift, Sekundaer = weisse + Flaeche mit Rahmen, Subtil = ohne Flaeche. Im Komponenten-Layer, damit + einzelne Utilities (etwa `w-full`) weiter ueberschreiben koennen. */ +@layer components { + .btn { + display: inline-flex; + height: 2rem; + align-items: center; + justify-content: center; + gap: 0.5rem; + border-radius: 0.375rem; + border: 1px solid transparent; + padding-inline: 0.75rem; + font-size: 0.875rem; + line-height: 1.25rem; + font-weight: 600; + white-space: nowrap; + transition: background-color 120ms, border-color 120ms, color 120ms; + } + .btn:focus-visible { + outline: 2px solid var(--ring); + outline-offset: 2px; + } + .btn:disabled { + cursor: not-allowed; + opacity: 0.55; + } + .btn-primary { + background-color: var(--primary); + color: var(--primary-foreground); + } + .btn-primary:hover:not(:disabled) { + background-color: color-mix(in oklab, var(--primary) 88%, black); + } + .btn-secondary { + background-color: var(--card); + color: var(--foreground); + border-color: oklch(0.87 0 0); + box-shadow: 0 1px 2px oklch(0 0 0 / 0.06); + } + .btn-secondary:hover:not(:disabled) { + background-color: oklch(0.965 0 0); + } + .dark .btn-secondary { + border-color: var(--input); + box-shadow: none; + } + .dark .btn-secondary:hover:not(:disabled) { + background-color: var(--muted); + } + .btn-subtle { + background-color: transparent; + color: var(--foreground); + } + .btn-subtle:hover:not(:disabled) { + background-color: var(--muted); + } + .btn-icon { + width: 2rem; + padding-inline: 0; + } + + /* Verweis: dunkle bzw. blaue Schrift, nie gelb auf Weiss. */ + .link { + color: var(--link); + text-decoration-line: underline; + text-decoration-thickness: 1px; + text-underline-offset: 3px; + text-decoration-color: color-mix(in oklab, var(--link) 40%, transparent); + } + .link:hover { + text-decoration-color: currentColor; + } +} + +/* Markdown (Notiz-Kachel, „Was ist neu“): Fluent-Schriftgroessen statt der + GitHub-Vorgaben (16 px Grundschrift, 2em-Ueberschriften), Hintergrund + transparent, Farben aus den Tokens. */ +:root .wmde-markdown { + font-family: var(--font-sans); + font-size: 0.875rem; + line-height: 1.5; + background-color: transparent !important; + color: var(--foreground) !important; + --color-border-default: var(--border); + --color-border-muted: var(--border); + --color-accent-fg: var(--link); + --color-fg-default: var(--foreground); + --color-fg-muted: var(--muted-foreground); +} +:root .wmde-markdown h1 { font-size: 1.25rem; line-height: 1.75rem; font-weight: 600; } +:root .wmde-markdown h2 { font-size: 1rem; line-height: 1.375rem; font-weight: 600; } +:root .wmde-markdown h3, :root .wmde-markdown h4 { font-size: 0.875rem; line-height: 1.25rem; font-weight: 600; } +:root .w-md-editor { + background-color: transparent !important; + box-shadow: none !important; +} + +/* Navigationseintrag mit Fluent-Auswahlpille, gesteuert ueber aria-current + (Einstellungs- und Verwaltungsnavigation). */ +.nav-item { + position: relative; +} +.nav-item[aria-current="page"]::before { + content: ""; + position: absolute; + left: 0; + top: 50%; + width: 3px; + height: 1rem; + margin-top: -0.5rem; + border-radius: 9999px; + background-color: var(--primary-strong); +} diff --git a/apps/web/src/lib/stores/auth-store.ts b/apps/web/src/lib/stores/auth-store.ts index 026b004..67301e8 100644 --- a/apps/web/src/lib/stores/auth-store.ts +++ b/apps/web/src/lib/stores/auth-store.ts @@ -21,24 +21,18 @@ interface AuthState { function applyAccentColor(color: string | null | undefined) { if (typeof document === 'undefined') return; const root = document.documentElement; + // Design „Mosaik“: die persoenliche Akzentfarbe setzt nur noch `--primary` + // (Hauptknoepfe, Auswahlpille der Seitenleiste, Reiter-Unterstrich, Logo). + // Fokusring und Auswahlflaeche der Seitenleiste bleiben neutral — eine + // helle Akzentfarbe (Standard Gelb) waere als Fokusring auf Weiss kaum + // sichtbar (1,3:1). Alte Werte frueherer Fassungen werden entfernt. + root.style.removeProperty('--ring'); + root.style.removeProperty('--sidebar-accent'); + root.style.removeProperty('--sidebar-accent-foreground'); if (color) { root.style.setProperty('--primary', color); - root.style.setProperty('--ring', color); - // Sidebar active state: accent color with 15% opacity (#rrggbbaa, 0x26 ≈ 15%) - root.style.setProperty('--sidebar-accent', `${color}26`); - // Full-saturation accent text only reads well against the dark-mode sidebar's - // tinted-dark background; in light mode it's near-white behind it, so keep - // the theme's default (dark, high-contrast) foreground there instead. - if (root.classList.contains('dark')) { - root.style.setProperty('--sidebar-accent-foreground', color); - } else { - root.style.removeProperty('--sidebar-accent-foreground'); - } } else { root.style.removeProperty('--primary'); - root.style.removeProperty('--ring'); - root.style.removeProperty('--sidebar-accent'); - root.style.removeProperty('--sidebar-accent-foreground'); } } From 7192be223ababe6a8fbbad1dd713420aa58d0d9c Mon Sep 17 00:00:00 2001 From: Schalli Date: Fri, 25 Sep 2026 14:32:37 +0200 Subject: [PATCH 02/12] feat(web): Modul-Identitaet mit App-Kacheln und deutschen Kategorienamen Jedes Modul bekommt Farbe und Symbol (tender-radar Ocker, dkv-fleet Petrol, cert-manager Violett, domaincheck Blau, proxmox Rotorange, unbekannte stabil per Hash). Kategorien in Alltagssprache; dazu die Texte fuer die neue Anmeldeseite. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/components/modules/module-tile.tsx | 97 +++++++++++++++++++ apps/web/src/lib/module-identity.ts | 53 ++++++++++ apps/web/src/lib/use-category-label.ts | 17 ++++ apps/web/src/messages/de.json | 13 ++- apps/web/src/messages/en.json | 13 ++- 5 files changed, 189 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/components/modules/module-tile.tsx create mode 100644 apps/web/src/lib/module-identity.ts create mode 100644 apps/web/src/lib/use-category-label.ts diff --git a/apps/web/src/components/modules/module-tile.tsx b/apps/web/src/components/modules/module-tile.tsx new file mode 100644 index 0000000..68dff7c --- /dev/null +++ b/apps/web/src/components/modules/module-tile.tsx @@ -0,0 +1,97 @@ +import type { ReactNode } from 'react'; +import { getModuleIdentity, type ModuleIconId } from '@/lib/module-identity'; + +const GLYPHS: Record = { + radar: ( + <> + + + + + + + + + + ), + fuel: ( + <> + + + + + + ), + certificate: ( + <> + + + + ), + globe: ( + <> + + + + + ), + server: ( + <> + + + + + + ), + tile: ( + <> + + + + + ), +}; + +interface ModuleTileProps { + slug: string; + /** Kantenlaenge in Pixeln (Seitenleiste 20, Karten 40, Seitenkopf 36). */ + size?: number; + className?: string; +} + +/** + * App-Kachel eines Moduls: abgerundetes Quadrat in der Modulfarbe mit weissem + * Symbol (siehe lib/module-identity.ts). Rein dekorativ — der Modulname steht + * immer als Text daneben. + */ +export function ModuleTile({ slug, size = 20, className = '' }: ModuleTileProps) { + const { color, icon } = getModuleIdentity(slug); + const glyph = Math.round(size * 0.6); + return ( + + ); +} diff --git a/apps/web/src/lib/module-identity.ts b/apps/web/src/lib/module-identity.ts new file mode 100644 index 0000000..f4dbf65 --- /dev/null +++ b/apps/web/src/lib/module-identity.ts @@ -0,0 +1,53 @@ +/** + * Modul-Identitaet (Design „Mosaik“): jedes Modul bekommt eine feste Farbe + * und ein Symbol — wie die App-Kacheln bei Microsoft 365. Die Kachel wird in + * der Seitenleiste (20 px), auf den Marktplatz-Karten (40 px) und im Kopf der + * Modulseiten (36 px) gezeigt, immer mit weissem Symbol. + * + * Kontrast weisses Symbol gegen Kachel (WCAG, gemessen): tender-radar 3,8:1 + * (Ockerton gegenueber dem Entwurf oklch(0.68 0.14 70) = 2,96:1 abgedunkelt), + * dkv-fleet 4,5:1, cert-manager 5,1:1, domaincheck 4,3:1, proxmox 4,6:1, + * Reserve gruen 4,1:1, Reserve pink 4,3:1 — alle ueber den geforderten 3:1 + * fuer grafische Elemente. + */ + +export type ModuleIconId = + | 'radar' + | 'fuel' + | 'certificate' + | 'globe' + | 'server' + | 'tile'; + +export interface ModuleIdentity { + /** Kachelfarbe als OKLCH-Farbwert. */ + color: string; + /** Symbol auf der Kachel. */ + icon: ModuleIconId; +} + +const KNOWN: Record = { + 'tender-radar': { color: 'oklch(0.62 0.135 68)', icon: 'radar' }, + 'dkv-fleet': { color: 'oklch(0.56 0.09 195)', icon: 'fuel' }, + 'cert-manager': { color: 'oklch(0.55 0.14 292)', icon: 'certificate' }, + domaincheck: { color: 'oklch(0.58 0.14 250)', icon: 'globe' }, + proxmox: { color: 'oklch(0.58 0.16 35)', icon: 'server' }, +}; + +/** Reservefarben fuer unbekannte Module (stabil per Kennungs-Hash). */ +const RESERVE = ['oklch(0.58 0.12 150)', 'oklch(0.60 0.15 350)'] as const; + +function stableHash(value: string): number { + let hash = 0; + for (let i = 0; i < value.length; i++) { + hash = (hash * 31 + value.charCodeAt(i)) >>> 0; + } + return hash; +} + +/** Farbe und Symbol eines Moduls; unbekannte Kennungen bekommen stabil eine Reservefarbe. */ +export function getModuleIdentity(slug: string): ModuleIdentity { + const known = KNOWN[slug]; + if (known) return known; + return { color: RESERVE[stableHash(slug) % RESERVE.length], icon: 'tile' }; +} diff --git a/apps/web/src/lib/use-category-label.ts b/apps/web/src/lib/use-category-label.ts new file mode 100644 index 0000000..a8786f7 --- /dev/null +++ b/apps/web/src/lib/use-category-label.ts @@ -0,0 +1,17 @@ +'use client'; + +import { useTranslations } from 'next-intl'; +import { useCallback } from 'react'; + +/** + * Liefert den Anzeigenamen einer Modulkategorie in Alltagssprache + * (`domain-tools` → „Domains“). Unbekannte Kategorien behalten ihre Kennung. + */ +export function useCategoryLabel(): (slug: string) => string { + const t = useTranslations('moduleCategories'); + return useCallback( + // `t.has` fehlt in einfachen Test-Attrappen von next-intl — dann Kennung. + (slug: string) => (typeof t.has === 'function' && t.has(slug) ? t(slug) : slug), + [t], + ); +} diff --git a/apps/web/src/messages/de.json b/apps/web/src/messages/de.json index f64e870..52658a7 100644 --- a/apps/web/src/messages/de.json +++ b/apps/web/src/messages/de.json @@ -32,7 +32,8 @@ "networkError": "Verbindungsfehler. Bitte versuchen Sie es erneut." }, "branding": { - "tagline": "Modulare Workflow-Plattform für Ihr Unternehmen" + "tagline": "Modulare Workflow-Plattform für Ihr Unternehmen", + "claim": "Ihre Werkzeuge an einem Ort." }, "desktopDownload": { "windows": "Desktop-App herunterladen (Windows)", @@ -68,7 +69,8 @@ "wrongCurrentPassword": "Das aktuelle Passwort ist falsch.", "networkError": "Verbindungsfehler. Bitte versuchen Sie es erneut.", "passwordMismatch": "Die Passwörter stimmen nicht überein." - } + }, + "loginSubtitle": "Melden Sie sich mit Ihrem Benutzerkonto an." }, "header": { "search": "Suchen...", @@ -1306,5 +1308,12 @@ "confirm": "Verstanden", "close": "Fenster schließen", "contentLabel": "Änderungen" + }, + "moduleCategories": { + "domain-tools": "Domains", + "security-tools": "Sicherheit", + "fleet": "Fuhrpark", + "infrastructure": "Infrastruktur", + "procurement": "Beschaffung" } } diff --git a/apps/web/src/messages/en.json b/apps/web/src/messages/en.json index 4ba9647..6361430 100644 --- a/apps/web/src/messages/en.json +++ b/apps/web/src/messages/en.json @@ -32,7 +32,8 @@ "networkError": "Connection error. Please try again." }, "branding": { - "tagline": "Modular workflow platform for your organization" + "tagline": "Modular workflow platform for your organization", + "claim": "Your tools in one place." }, "desktopDownload": { "windows": "Download desktop app (Windows)", @@ -68,7 +69,8 @@ "wrongCurrentPassword": "The current password is incorrect.", "networkError": "Connection error. Please try again.", "passwordMismatch": "Passwords do not match." - } + }, + "loginSubtitle": "Sign in with your user account." }, "header": { "search": "Search...", @@ -1306,5 +1308,12 @@ "confirm": "Got it", "close": "Close window", "contentLabel": "Changes" + }, + "moduleCategories": { + "domain-tools": "Domains", + "security-tools": "Security", + "fleet": "Fleet", + "infrastructure": "Infrastructure", + "procurement": "Procurement" } } From b80db49c5e0cd3352c3691cca39f54b5031ccbd2 Mon Sep 17 00:00:00 2001 From: Schalli Date: Fri, 25 Sep 2026 14:32:37 +0200 Subject: [PATCH 03/12] style(web): dunkle App-Leiste und Fluent-Seitenleiste Kopfzeile 48 px dunkel mit Seitentitel statt immer Startseite, Dashboard-Reiter mit Akzent-Unterstrich; Seitenleiste mit Auswahlpille, Modul-Kacheln, aufgeklappten Kategorien und gefuelltem Suchfeld; eingeklappt nur Kacheln. Einheitlicher PageHeader. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../web/src/components/brand/tessera-logo.tsx | 15 +- .../bug-report/bug-report-button.tsx | 2 +- .../components/dashboard/dashboard-tabs.tsx | 37 +-- .../web/src/components/layout/header.test.tsx | 29 ++- apps/web/src/components/layout/header.tsx | 58 +++-- .../web/src/components/layout/page-header.tsx | 32 +++ .../src/components/layout/sidebar-search.tsx | 24 +- .../src/components/layout/sidebar.test.tsx | 41 ++- apps/web/src/components/layout/sidebar.tsx | 237 +++++++++++------- apps/web/src/components/theme-toggle.tsx | 2 +- apps/web/src/lib/stores/nav-store.ts | 62 +++++ 11 files changed, 389 insertions(+), 150 deletions(-) create mode 100644 apps/web/src/components/layout/page-header.tsx create mode 100644 apps/web/src/lib/stores/nav-store.ts diff --git a/apps/web/src/components/brand/tessera-logo.tsx b/apps/web/src/components/brand/tessera-logo.tsx index db8519f..e27a175 100644 --- a/apps/web/src/components/brand/tessera-logo.tsx +++ b/apps/web/src/components/brand/tessera-logo.tsx @@ -20,9 +20,10 @@ export interface TesseraLogoProps { * Schaltet die halbtransparente helle Kontur der Grundplatte im dunklen * Erscheinungsbild ein. Auf Flaechen, die unabhaengig vom * Erscheinungsbild immer hell sind (etwa das gelbe Anmelde-Panel), muss - * dies auf `false` gesetzt werden. Vorgabe: `true`. + * dies auf `false` gesetzt werden; auf immer dunklen Flaechen (App-Leiste, + * Anmelde-Panel) auf `'always'`. Vorgabe: `true`. */ - plateOutline?: boolean; + plateOutline?: boolean | 'always'; /** Zusaetzliche Klassen fuer das aeusserste Element der gewaehlten Variante. */ className?: string; /** Zusaetzliche Klassen fuer den Schriftzug (nur `horizontal`). */ @@ -38,7 +39,7 @@ function LogoMark({ ariaLabel, }: { size: number; - plateOutline: boolean; + plateOutline: boolean | 'always'; className?: string; ariaLabel: string; }) { @@ -61,9 +62,11 @@ function LogoMark({ fill={BRAND_PLATE} strokeWidth={2} className={ - plateOutline - ? 'stroke-transparent dark:stroke-white/25' - : 'stroke-transparent' + plateOutline === 'always' + ? 'stroke-white/20' + : plateOutline + ? 'stroke-transparent dark:stroke-white/25' + : 'stroke-transparent' } /> {/* diff --git a/apps/web/src/components/bug-report/bug-report-button.tsx b/apps/web/src/components/bug-report/bug-report-button.tsx index b9cc8d8..9901366 100644 --- a/apps/web/src/components/bug-report/bug-report-button.tsx +++ b/apps/web/src/components/bug-report/bug-report-button.tsx @@ -34,7 +34,7 @@ export function BugReportButton() { diff --git a/apps/web/src/components/layout/header.test.tsx b/apps/web/src/components/layout/header.test.tsx index 675d951..e99e1e3 100644 --- a/apps/web/src/components/layout/header.test.tsx +++ b/apps/web/src/components/layout/header.test.tsx @@ -1,6 +1,7 @@ import { cleanup, render, screen, waitFor } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { useAuthStore } from '@/lib/stores/auth-store'; +import { useNavStore } from '@/lib/stores/nav-store'; /** * header.test — Sitzungswaechter im Header (quick-260917-gyd). @@ -166,15 +167,35 @@ describe('Header — Mitte der Kopfzeile (quick-260924-h7x)', () => { await waitFor(() => expect(fetchSessionState).toHaveBeenCalled()); }); - it('auf anderen Seiten: „Startseite“ bleibt wie bisher', async () => { - mockPathname = '/modules/proxmox'; - vi.stubGlobal('location', { href: '', pathname: '/modules/proxmox', search: '' }); + it('auf anderen Seiten: Name der aktuellen Seite statt „Startseite“ (Design „Mosaik“)', async () => { + mockPathname = '/marketplace'; + vi.stubGlobal('location', { href: '', pathname: '/marketplace', search: '' }); fetchSessionState.mockResolvedValue({ status: 'unavailable' }); await renderHeader(); - expect(screen.getByText('Startseite')).toBeInTheDocument(); + expect(screen.getByText('Marktplatz')).toBeInTheDocument(); + expect(screen.queryByText('Startseite')).not.toBeInTheDocument(); expect(document.getElementById('header-center-slot')).not.toBeNull(); await waitFor(() => expect(fetchSessionState).toHaveBeenCalled()); }); + + it('auf Modulseiten: Modulname aus der Seitenleiste, sonst der Ersatztitel', async () => { + mockPathname = '/modules/infrastructure/proxmox'; + vi.stubGlobal('location', { href: '', pathname: mockPathname, search: '' }); + fetchSessionState.mockResolvedValue({ status: 'unavailable' }); + + await renderHeader(); + // Noch keine Module geladen: Ersatztitel aus den Uebersetzungen. + expect(screen.getByText('Proxmox')).toBeInTheDocument(); + + cleanup(); + useNavStore.getState().setModules([ + { id: 'm1', slug: 'proxmox', name: 'Proxmox-Monitor', category: 'infrastructure' }, + ]); + await renderHeader(); + expect(screen.getByText('Proxmox-Monitor')).toBeInTheDocument(); + useNavStore.getState().setModules([]); + await waitFor(() => expect(fetchSessionState).toHaveBeenCalled()); + }); }); diff --git a/apps/web/src/components/layout/header.tsx b/apps/web/src/components/layout/header.tsx index 145f605..70bfdaf 100644 --- a/apps/web/src/components/layout/header.tsx +++ b/apps/web/src/components/layout/header.tsx @@ -12,6 +12,7 @@ import { ThemeToggle } from '@/components/theme-toggle'; import { BugReportButton } from '@/components/bug-report/bug-report-button'; import { HEADER_CENTER_SLOT_ID } from '@/components/layout/header-slot'; import { usePathname } from 'next/navigation'; +import { resolvePageTitle, useNavStore } from '@/lib/stores/nav-store'; export function Header() { const t = useTranslations(); @@ -95,14 +96,25 @@ export function Header() { const roleLabel = user ? tHeader(`role.${user.role}`) : ''; + // Seitentitel in der Mitte (Design „Mosaik“): statt immer „Startseite“ + // der Name der aktuellen Seite bzw. des Moduls. + const navModules = useNavStore((s) => s.modules); + const pageTitle = resolvePageTitle(pathname, navModules); + const pageTitleText = pageTitle + ? 'key' in pageTitle + ? t(pageTitle.key) + : pageTitle.text + : null; + return ( -
- {/* Left: Logo + Mobile hamburger */} -
+
+ {/* Links: Menue (mobil) + Bildmarke. Dunkle App-Leiste wie bei GitHub + bzw. Microsoft 365 — in beiden Erscheinungsbildern dunkel. */} +
- + + +
{/* Mitte: Einhaengepunkt fuer die Dashboard-Reiter (quick-260924-h7x). @@ -143,15 +159,15 @@ export function Header() { und Aktionen rechts auch bei schmalem Fenster Platz behalten. */}
- {pathname !== '/' && ( - - {tHeader('breadcrumb.home')} + {pageTitleText !== null && ( + + {pageTitleText} )}
{/* Right: Actions */} -
+
@@ -160,7 +176,7 @@ export function Header() { - {openCategories.has(category) && ( -
    - {mods.map((mod) => ( -
  • - - {mod.name} - -
  • - ))} + {open && ( +
      + {mods.map((mod) => { + const href = `/modules/${category}/${mod.slug}`; + const active = isActive(href) || isActive(`/modules/${mod.slug}`); + return ( +
    • + + {active && } + + {mod.name} + +
    • + ); + })}
    )} - ))} -
- )} -
+ ); + })} + + )}
)} -
+
@@ -210,7 +258,7 @@ export function Sidebar() { {/* Versionszeile (quick-260914-ku1): bewusst ohne `hidden md:block`, damit auch die mobile Schublade sie zeigt; eingeklappt nichts (Muster oben). */} {!isCollapsed && ( -
+
)} @@ -220,7 +268,7 @@ export function Sidebar() { return ( <>
{/* Desktop-App-Link (D-12), blendet sich aus ohne API-Antwort */} - +
+ +
); } + +/** + * Stilles Mosaik aus den Modulfarben (Design „Mosaik“): acht Kacheln in einem + * lockeren 4 x 3-Raster, eine davon in der Akzentfarbe und — wie die + * Signalkachel der Bildmarke — leicht gedreht. Rein dekorativ. + */ +function LoginMosaic() { + const tiles: { col: number; row: number; color: string; rotate?: boolean }[] = [ + { col: 1, row: 1, color: 'oklch(0.56 0.09 195)' }, + { col: 2, row: 1, color: 'oklch(0.55 0.14 292)' }, + { col: 4, row: 1, color: 'oklch(0.58 0.14 250)' }, + { col: 2, row: 2, color: 'oklch(0.62 0.135 68)' }, + { col: 3, row: 2, color: 'var(--primary)', rotate: true }, + { col: 4, row: 2, color: 'oklch(0.58 0.16 35)' }, + { col: 1, row: 3, color: 'oklch(0.58 0.12 150)' }, + { col: 3, row: 3, color: 'oklch(1 0 0 / 0.1)' }, + ]; + return ( + + ); +} From 219038b3ee847c17f75e641e2c8abc885ecf2e2d Mon Sep 17 00:00:00 2001 From: Schalli Date: Fri, 25 Sep 2026 14:49:01 +0200 Subject: [PATCH 05/12] feat(web): Dashboard-Kacheln im Fluent-Stil und waehlbarer Hintergrund Einheitliche Kachel-Kopfzeile, ruhigere Notiz-/Kalender-/Favoriten- Darstellung, 12 px Raster-Abstand, gestrichelte Kontur und Griff im Bearbeitungsmodus. Neuer Knopf Hintergrund: Keiner, fuenf eingebaute (Nebel, Salbei, Bluete, Duenen, Mosaik) oder eigenes Bild aus den Bilderrahmen-Bildern; Prototyp speichert im localStorage je Benutzer. Mit Hintergrund werden Kacheln Mica-artig durchscheinend (Kontrast >= 4,5:1 auch im schlechtesten Fall). Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/web/src/app/(portal)/page.tsx | 12 +- .../dashboard/dashboard-background.tsx | 276 ++++++++++++++++++ .../dashboard/dashboard-grid.test.tsx | 4 +- .../components/dashboard/dashboard-grid.tsx | 2 +- .../components/dashboard/dashboard-tabs.tsx | 2 +- .../components/dashboard/edit-mode-toggle.tsx | 6 +- .../dashboard/widgets/calculator-widget.tsx | 2 +- .../dashboard/widgets/calendar-widget.tsx | 20 +- .../dashboard/widgets/favorites-widget.tsx | 16 +- .../dashboard/widgets/note-widget.tsx | 6 +- .../dashboard/widgets/proxmox-widget.tsx | 4 +- .../dashboard/widgets/search-widget.tsx | 2 +- .../dashboard/widgets/widget-wrapper.tsx | 8 +- .../dashboard/widgets/xframe-widget.test.tsx | 4 +- .../dashboard/widgets/xframe-widget.tsx | 4 +- apps/web/src/lib/dashboard-background.test.ts | 54 ++++ apps/web/src/lib/dashboard-background.ts | 142 +++++++++ 17 files changed, 521 insertions(+), 43 deletions(-) create mode 100644 apps/web/src/components/dashboard/dashboard-background.tsx create mode 100644 apps/web/src/lib/dashboard-background.test.ts create mode 100644 apps/web/src/lib/dashboard-background.ts diff --git a/apps/web/src/app/(portal)/page.tsx b/apps/web/src/app/(portal)/page.tsx index ad9523c..a4d6279 100644 --- a/apps/web/src/app/(portal)/page.tsx +++ b/apps/web/src/app/(portal)/page.tsx @@ -5,6 +5,8 @@ import { useTranslations } from 'next-intl'; import { DashboardGrid } from '@/components/dashboard/dashboard-grid'; import { DashboardTabs } from '@/components/dashboard/dashboard-tabs'; import { EditModeToggle } from '@/components/dashboard/edit-mode-toggle'; +import { BackgroundPicker, DashboardBackdrop, useDashboardBackground } from '@/components/dashboard/dashboard-background'; +import { useAuthStore } from '@/lib/stores/auth-store'; import { WidgetCatalogModal } from '@/components/dashboard/widget-catalog-modal'; import { registerWidget } from '@/components/dashboard/widget-registry'; import { ClockWidget } from '@/components/dashboard/widgets/clock-widget'; @@ -45,6 +47,10 @@ interface ActiveModule { export default function DashboardPage() { const t = useTranslations('widgets'); const [catalogOpen, setCatalogOpen] = useState(false); + // Hintergrund je Benutzer (Design „Mosaik“, Prototyp mit localStorage). + const userId = useAuthStore((s) => s.user?.id); + const { background, choose: chooseBackground } = useDashboardBackground(userId); + const hasBackground = background.kind !== 'none'; // quick-260922-m1h: Slugs der Module, die dieser Benutzer nutzen darf — // der Katalog blendet Kacheln gesperrter Module damit aus. `null` heisst // "noch unbekannt oder Abruf fehlgeschlagen" und ist fail-closed. @@ -115,7 +121,8 @@ export default function DashboardPage() { } return ( -
+
+ {/* Reiterleiste (quick-260923-ad9) — seit quick-260924-h7x per Portal in der Kopfzeile, hier nimmt sie keinen Platz mehr ein. Sie bleibt waehrend eines Reiterwechsels stehen, nur das Raster zeigt eine @@ -151,11 +158,12 @@ export default function DashboardPage() { Grid direkt unter der Kopfzeile beginnt (12 + 8 + 8 = 28 px statt 60 px). Im Bearbeitungsmodus steht "Widget hinzufuegen" links neben dem Haekchen. */}
+ {isEditMode && } {isEditMode && ( + + ); + }; + + return ( +
+ + + {open && ( +
+

{t('title')}

+

{t('hint')}

+ +

{t('builtIn')}

+
    + {option('none', t('none'), NO_BACKGROUND, { backgroundColor: 'var(--background)' }, + , + )} + {BACKGROUND_PRESETS.map((preset) => { + const css = dark ? preset.dark : preset.light; + return option( + preset.id, + t(`presets.${preset.labelKey}`), + { kind: 'preset', id: preset.id }, + css.startsWith('url(') + ? { backgroundImage: css, backgroundSize: 'cover', backgroundPosition: 'center' } + : { backgroundColor: css }, + ); + })} +
+ +

{t('ownImages')}

+
    + {(images ?? []).map((image) => + option( + image.id, + image.originalName, + { kind: 'image', imageId: image.id }, + { + backgroundImage: `url("${dashboardImageSrc(image.id)}")`, + backgroundSize: 'cover', + backgroundPosition: 'center', + }, + ), + )} +
  • + +
  • +
+ handleUpload(e.target.files?.[0])} + /> + {images !== null && images.length === 0 && ( +

{t('noImages')}

+ )} + {error && ( +

+ {error} +

+ )} +
+ )} +
+ ); +} diff --git a/apps/web/src/components/dashboard/dashboard-grid.test.tsx b/apps/web/src/components/dashboard/dashboard-grid.test.tsx index 2220bff..43fc340 100644 --- a/apps/web/src/components/dashboard/dashboard-grid.test.tsx +++ b/apps/web/src/components/dashboard/dashboard-grid.test.tsx @@ -165,7 +165,7 @@ describe('DashboardGrid', () => { expect(document.querySelector('.widget-drag-handle')).toBeInTheDocument(); }); - it('quick-260916-bwo Test 4: Grid-Props — 24/20/12/8/2 Spalten, rowHeight 20, margin 8, Breakpoints unveraendert, containerPadding folgt dem margin', async () => { + it('quick-260916-bwo Test 4: Grid-Props — 24/20/12/8/2 Spalten, rowHeight 20, margin 12, Breakpoints unveraendert, containerPadding folgt dem margin', async () => { captured.props = null; const { DashboardGrid } = await import('./dashboard-grid'); render( @@ -181,7 +181,7 @@ describe('DashboardGrid', () => { expect(captured.props).not.toBeNull(); expect(captured.props?.cols).toEqual({ lg: 24, md: 20, sm: 12, xs: 8, xxs: 2 }); expect(captured.props?.rowHeight).toBe(20); - expect(captured.props?.margin).toEqual([8, 8]); + expect(captured.props?.margin).toEqual([12, 12]); expect(captured.props?.breakpoints).toEqual({ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }); expect(captured.props?.containerPadding).toBeUndefined(); }); diff --git a/apps/web/src/components/dashboard/dashboard-grid.tsx b/apps/web/src/components/dashboard/dashboard-grid.tsx index 854921e..2d15867 100644 --- a/apps/web/src/components/dashboard/dashboard-grid.tsx +++ b/apps/web/src/components/dashboard/dashboard-grid.tsx @@ -229,7 +229,7 @@ export function DashboardGrid({ cols={COLS} layouts={effectiveLayouts as ResponsiveLayouts} rowHeight={20} - margin={[8, 8] as [number, number]} + margin={[12, 12] as [number, number]} // containerPadding bewusst nicht gesetzt: react-grid-layout 2.2.3 nimmt // dann das margin (gemessen: effectiveContainerPadding = containerPadding ?? margin). dragConfig={{ diff --git a/apps/web/src/components/dashboard/dashboard-tabs.tsx b/apps/web/src/components/dashboard/dashboard-tabs.tsx index a66068e..8a09a6d 100644 --- a/apps/web/src/components/dashboard/dashboard-tabs.tsx +++ b/apps/web/src/components/dashboard/dashboard-tabs.tsx @@ -507,7 +507,7 @@ export function DashboardTabs({ role="alertdialog" aria-modal="true" aria-label={t('tabs.deleteDialogTitle')} - className="relative z-[60] mx-4 max-w-md rounded-lg border border-border bg-card p-6 shadow-xl" + className="relative z-[60] mx-4 max-w-md rounded-lg bg-card dark:border dark:border-border p-6 shadow-xl" >

{t('tabs.deleteDialogTitle')} diff --git a/apps/web/src/components/dashboard/edit-mode-toggle.tsx b/apps/web/src/components/dashboard/edit-mode-toggle.tsx index 54ab702..b730592 100644 --- a/apps/web/src/components/dashboard/edit-mode-toggle.tsx +++ b/apps/web/src/components/dashboard/edit-mode-toggle.tsx @@ -21,11 +21,7 @@ export function EditModeToggle({ isEditMode, onToggle }: EditModeToggleProps) {