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) <noreply@anthropic.com>
This commit is contained in:
2026-09-25 14:32:37 +02:00
parent 7192be223a
commit b80db49c5e
11 changed files with 389 additions and 150 deletions
@@ -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'
}
/>
{/*
@@ -34,7 +34,7 @@ export function BugReportButton() {
<button
type="button"
onClick={handleClick}
className="inline-flex items-center justify-center rounded-md p-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors disabled:opacity-50"
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-appbar-muted hover:bg-appbar-hover hover:text-appbar-foreground transition-colors disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-appbar-foreground"
aria-label={t('button')}
title={t('button')}
disabled={capturing}
@@ -312,18 +312,20 @@ export function DashboardTabs({
const bar = (
<nav
aria-label={t('tabs.navLabel')}
className="flex min-w-0 max-w-full items-center gap-1.5"
className="flex h-[var(--header-height)] min-w-0 max-w-full items-center gap-1.5"
title={dashboards.length > 1 ? t('tabs.dragHint') : undefined}
>
{/* Eingelassene Spur (32 px): der aktive Reiter liegt erhaben darauf. */}
<div className="h-8 min-w-0 max-w-full rounded-lg bg-muted p-0.5 shadow-[inset_0_1px_2px_oklch(0_0_0/0.08)] md:max-w-[min(56vw,720px)]">
{/* Reiter im Fluent-Stil auf der dunklen App-Leiste (Design „Mosaik“):
volle Leistenhoehe, der aktive Reiter weiss und halbfett mit 2 px
Akzentlinie an der Unterkante der Leiste. */}
<div className="h-[var(--header-height)] min-w-0 max-w-full md:max-w-[min(56vw,720px)]">
<div
ref={scrollRef}
role="tablist"
aria-describedby={dashboards.length > 1 ? hintId : undefined}
onScroll={measureOverflow}
style={maskStyle}
className="flex h-7 items-center gap-0.5 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
className="flex h-full items-stretch gap-0.5 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
{orderedTabs.map((tab, index) => {
const isActive = tab.id === activeDashboardId;
@@ -339,16 +341,22 @@ export function DashboardTabs({
}}
data-tab-index={index}
data-testid={`dashboard-tab-${tab.id}`}
className={`flex h-7 shrink-0 touch-none items-center rounded-md transition-colors ${
className={`relative flex shrink-0 touch-none items-center transition-colors ${
isActive
? 'bg-card text-foreground shadow-sm'
: 'text-muted-foreground hover:bg-card/60 hover:text-foreground'
? 'text-appbar-foreground'
: 'text-appbar-muted hover:text-appbar-foreground'
}`}
onPointerDown={(e) => handlePointerDown(e, tab.id)}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerCancel}
>
{isActive && (
<span
aria-hidden="true"
className="pointer-events-none absolute inset-x-3 bottom-0 h-[2px] rounded-full bg-primary"
/>
)}
{isRenaming ? (
<input
ref={renameInputRef}
@@ -366,7 +374,7 @@ export function DashboardTabs({
}}
maxLength={40}
aria-label={t('tabs.renameInputLabel')}
className="h-6 w-32 rounded border border-border bg-background px-2 text-sm text-foreground"
className="h-7 w-32 rounded-md border border-white/25 bg-white/10 px-2 text-sm text-appbar-foreground outline-none focus:border-primary"
/>
) : (
<button
@@ -380,8 +388,9 @@ export function DashboardTabs({
tabIndex={isFocusable ? 0 : -1}
onClick={() => handleTabClick(tab.id)}
onKeyDown={(e) => handleTabKeyDown(e, index)}
className={`h-7 cursor-grab whitespace-nowrap rounded-md px-3 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
isActive ? 'font-medium' : ''
data-text={tab.name}
className={`inline-flex h-8 cursor-grab flex-col items-center justify-center whitespace-nowrap rounded-md px-3 text-sm transition-colors hover:bg-appbar-hover focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-appbar-foreground after:invisible after:h-0 after:overflow-hidden after:font-semibold after:content-[attr(data-text)] ${
isActive ? 'font-semibold' : ''
}`}
>
{tab.name}
@@ -394,7 +403,7 @@ export function DashboardTabs({
onClick={() => startRename(tab)}
aria-label={t('tabs.renameButtonLabel')}
title={t('tabs.renameButtonLabel')}
className="-ml-1.5 rounded p-1 text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="-ml-1.5 rounded p-1 text-appbar-muted hover:text-appbar-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-appbar-foreground"
>
<svg
aria-hidden="true"
@@ -419,7 +428,7 @@ export function DashboardTabs({
onClick={() => setPendingDeleteId(tab.id)}
aria-label={t('tabs.deleteButtonLabel')}
title={t('tabs.deleteButtonLabel')}
className={`${isEditMode && isActive ? '' : '-ml-1.5'} mr-1 rounded p-1 text-muted-foreground hover:text-destructive focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring`}
className={`${isEditMode && isActive ? '' : '-ml-1.5'} mr-1 rounded p-1 text-appbar-muted hover:text-red-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-appbar-foreground`}
>
<svg
aria-hidden="true"
@@ -453,7 +462,7 @@ export function DashboardTabs({
onClick={onCreate}
aria-label={t('tabs.add')}
title={t('tabs.add')}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-border bg-card text-muted-foreground shadow-sm transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md border border-white/20 text-appbar-muted transition-colors hover:bg-appbar-hover hover:text-appbar-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-appbar-foreground"
>
<svg
aria-hidden="true"
@@ -510,7 +519,7 @@ export function DashboardTabs({
<button
type="button"
onClick={() => setPendingDeleteId(null)}
className="rounded border border-border px-4 py-2 text-sm text-foreground transition-colors hover:bg-muted"
className="btn btn-secondary"
>
{tCommon('cancel')}
</button>
+25 -4
View File
@@ -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());
});
});
+37 -21
View File
@@ -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 (
<header className="sticky top-0 z-50 flex h-[var(--header-height)] items-center border-b border-border bg-background px-4 shadow-sm">
{/* Left: Logo + Mobile hamburger */}
<div className="flex shrink-0 items-center gap-3">
<header className="sticky top-0 z-50 flex h-[var(--header-height)] items-center bg-appbar px-2 text-appbar-foreground sm:px-3 dark:shadow-[inset_0_-1px_0_oklch(1_0_0/0.07)]">
{/* Links: Menue (mobil) + Bildmarke. Dunkle App-Leiste wie bei GitHub
bzw. Microsoft 365 — in beiden Erscheinungsbildern dunkel. */}
<div className="flex shrink-0 items-center gap-2">
<button
type="button"
onClick={() => setMobileOpen(true)}
className="inline-flex items-center justify-center rounded-md p-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors md:hidden"
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-appbar-muted hover:bg-appbar-hover hover:text-appbar-foreground transition-colors md:hidden"
aria-label={t('common.menu')}
>
<svg
@@ -122,17 +134,21 @@ export function Header() {
<line x1="3" y1="18" x2="21" y2="18" />
</svg>
</button>
<TesseraLogo
variant="horizontal"
size={28}
// Gelb traegt nur auf dunklem Grund: gemessen 13,1:1 im dunklen
// Erscheinungsbild gegen 1,26:1 im hellen (WCAG verlangt 3:1 fuer
// grossen fetten Text). Im hellen Modus erbt der Schriftzug daher
// die Vordergrundfarbe, im dunklen steht die Markenfarbe.
// Unter 640 px nur die Bildmarke: der Platz gehoert dort den
// Dashboard-Reitern in der Kopfzeilenmitte (260924-h7x).
wordmarkClassName="hidden sm:inline dark:text-primary"
/>
<Link
href="/"
className="flex items-center rounded-md px-1 py-1 focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-appbar-foreground"
>
<TesseraLogo
variant="horizontal"
size={26}
plateOutline="always"
className="gap-2.5"
// Schriftzug weiss auf der dunklen Leiste (in beiden
// Erscheinungsbildern). Unter 640 px nur die Bildmarke: der Platz
// gehoert dort den Dashboard-Reitern (260924-h7x).
wordmarkClassName="hidden sm:inline text-[15px] font-semibold tracking-normal text-appbar-foreground"
/>
</Link>
</div>
{/* 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. */}
<div className="flex min-w-0 flex-1 items-center justify-center px-2 text-center md:px-4">
<div id={HEADER_CENTER_SLOT_ID} className="flex min-w-0 max-w-full justify-center" />
{pathname !== '/' && (
<span className="text-sm text-muted-foreground truncate">
{tHeader('breadcrumb.home')}
{pageTitleText !== null && (
<span className="truncate text-sm font-semibold text-appbar-foreground">
{pageTitleText}
</span>
)}
</div>
{/* Right: Actions */}
<div className="flex shrink-0 items-center gap-2">
<div className="flex shrink-0 items-center gap-1">
<BugReportButton />
<ThemeToggle />
@@ -160,7 +176,7 @@ export function Header() {
<button
type="button"
onClick={() => setDropdownOpen(!dropdownOpen)}
className="h-8 w-8 rounded-full overflow-hidden bg-primary flex items-center justify-center text-xs font-medium text-primary-foreground hover:opacity-90 transition-opacity"
className="ml-1 flex h-8 w-8 items-center justify-center overflow-hidden rounded-full bg-primary text-xs font-semibold text-primary-foreground ring-1 ring-white/15 transition-opacity hover:opacity-90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-appbar-foreground"
aria-label={tHeader('userMenu')}
>
{user?.hasAvatar && !avatarError ? (
@@ -178,7 +194,7 @@ export function Header() {
{/* Dropdown menu */}
{dropdownOpen && (
<div className="absolute right-0 top-full mt-2 w-56 rounded-md border border-border bg-card shadow-lg py-2 z-50">
<div className="absolute right-0 top-full z-50 mt-2 w-60 rounded-lg border border-border bg-card py-1.5 text-card-foreground shadow-lg">
{/* User info */}
<div className="px-4 py-2 border-b border-border">
<p className="text-sm font-medium text-foreground truncate">
@@ -0,0 +1,32 @@
import type { ReactNode } from 'react';
import { ModuleTile } from '@/components/modules/module-tile';
interface PageHeaderProps {
title: ReactNode;
description?: ReactNode;
/** Aktionen rechtsbuendig (Knoepfe, Verweise). */
actions?: ReactNode;
/** Modulkennung: zeigt die App-Kachel des Moduls links neben dem Titel. */
moduleSlug?: string;
className?: string;
}
/**
* Einheitlicher Seitenkopf (Design „Mosaik“, Fluent-Typografie): Titel
* 24/32 px halbfett, optionale Beschreibung 14 px gedaempft, Aktionen rechts.
* Modulseiten tragen ihre App-Kachel (36 px) links vom Titel.
*/
export function PageHeader({ title, description, actions, moduleSlug, className = '' }: PageHeaderProps) {
return (
<div className={`flex flex-wrap items-start justify-between gap-x-6 gap-y-3 ${className}`}>
<div className="flex min-w-0 items-center gap-3">
{moduleSlug && <ModuleTile slug={moduleSlug} size={36} />}
<div className="min-w-0">
<h1 className="text-2xl font-semibold text-foreground">{title}</h1>
{description && <p className="mt-0.5 text-sm text-muted-foreground">{description}</p>}
</div>
</div>
{actions && <div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div>}
</div>
);
}
@@ -11,14 +11,32 @@ export function SidebarSearch({ value, onChange }: SidebarSearchProps) {
const t = useTranslations('sidebar');
return (
<div className="px-3 pb-2">
<div className="relative pb-2">
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="pointer-events-none absolute left-2.5 top-2 text-muted-foreground"
>
<circle cx="11" cy="11" r="7" />
<path d="m20 20-3.5-3.5" />
</svg>
{/* Gefuelltes Suchfeld ohne harten Rahmen (Fluent „filled“); Fokus =
2 px Akzentlinie unten plus Tastaturring. */}
<input
type="text"
type="search"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={t('search')}
aria-label={t('search')}
className="flex h-9 w-full rounded-md border border-border bg-background px-3 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:border-transparent"
className="flex h-8 w-full rounded-md border-0 bg-sidebar-hover pl-8 pr-2.5 text-sm text-foreground placeholder:text-muted-foreground shadow-[inset_0_-1px_0_var(--input)] transition-shadow focus:bg-card focus:shadow-[inset_0_-2px_0_var(--primary-strong)] focus:outline-none [&::-webkit-search-cancel-button]:hidden"
/>
</div>
);
@@ -147,12 +147,10 @@ describe('Sidebar', () => {
const Sidebar = await importSidebar();
render(<Sidebar />);
// Categories are collapsed by default — wait for category header, then expand
// Design „Mosaik“: Kategorien sind anfangs aufgeklappt.
await waitFor(() => {
expect(screen.getByText('Domain Tools')).toBeInTheDocument();
expect(screen.getByText('Domain-Tools')).toBeInTheDocument();
});
await userEvent.click(screen.getByText('Domain Tools'));
await waitFor(() => {
expect(screen.getByText('Domaincheck')).toBeInTheDocument();
});
@@ -165,13 +163,46 @@ describe('Sidebar', () => {
});
});
it('Kategorien sind aufgeklappt, ein Klick auf die Kopfzeile klappt zu und wieder auf', async () => {
const Sidebar = await importSidebar();
render(<Sidebar />);
await waitFor(() => {
expect(screen.getByText('Domaincheck')).toBeInTheDocument();
});
const header = screen.getByText('Domain-Tools').closest('button');
expect(header).toHaveAttribute('aria-expanded', 'true');
await userEvent.click(screen.getByText('Domain-Tools'));
expect(screen.queryByText('Domaincheck')).not.toBeInTheDocument();
expect(header).toHaveAttribute('aria-expanded', 'false');
await userEvent.click(screen.getByText('Domain-Tools'));
expect(screen.getByText('Domaincheck')).toBeInTheDocument();
});
it('Modul-Eintraege tragen die App-Kachel, die Auswahl die Akzent-Pille', async () => {
mockPathname = '/modules/Domain-Tools/domaincheck';
const Sidebar = await importSidebar();
render(<Sidebar />);
await waitFor(() => {
expect(screen.getByText('Domaincheck')).toBeInTheDocument();
});
const link = screen.getByText('Domaincheck').closest('a');
expect(link?.querySelector('[data-module-tile="domaincheck"]')).not.toBeNull();
expect(link?.querySelector('[data-testid="nav-selection-pill"]')).not.toBeNull();
const dashLink = screen.getByText('Dashboard').closest('a');
expect(dashLink?.querySelector('[data-testid="nav-selection-pill"]')).toBeNull();
});
it('re-fetches modules when sidebarRefreshKey changes', async () => {
const Sidebar = await importSidebar();
const { rerender } = render(<Sidebar />);
// Wait for category header to confirm initial fetch completed
await waitFor(() => {
expect(screen.getByText('Domain Tools')).toBeInTheDocument();
expect(screen.getByText('Domain-Tools')).toBeInTheDocument();
});
const initialCallCount = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls.length;
+142 -95
View File
@@ -9,6 +9,20 @@ import { useSidebarStore } from '@/lib/stores/sidebar-store';
import { useMarketplaceStore } from '@/lib/stores/marketplace-store';
import { SidebarSearch } from '@/components/layout/sidebar-search';
import { AppVersionBadge } from '@/components/layout/app-version-badge';
import { ModuleTile } from '@/components/modules/module-tile';
import { useCategoryLabel } from '@/lib/use-category-label';
import { useNavStore } from '@/lib/stores/nav-store';
/** Fluent-Auswahlmarke: 3 x 16 px Pille am linken Rand des gewaehlten Eintrags. */
function SelectionPill() {
return (
<span
aria-hidden="true"
data-testid="nav-selection-pill"
className="absolute left-0 top-1/2 h-4 w-[3px] -translate-y-1/2 rounded-full bg-primary-strong"
/>
);
}
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
@@ -25,7 +39,11 @@ export function Sidebar() {
const { isCollapsed, isMobileOpen, toggle, setMobileOpen } = useSidebarStore();
const [activeModules, setActiveModules] = useState<SidebarModule[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [openCategories, setOpenCategories] = useState<Set<string>>(new Set());
// Kategorien sind anfangs aufgeklappt (Design „Mosaik“): die Modul-Kacheln
// sind der wichtigste Wegweiser. Gemerkt werden die ZUGEKLAPPTEN.
const [closedCategories, setClosedCategories] = useState<Set<string>>(new Set());
const categoryLabel = useCategoryLabel();
const setNavModules = useNavStore((s) => s.setModules);
const pathname = usePathname();
const sidebarRefreshKey = useMarketplaceStore((s) => s.sidebarRefreshKey);
@@ -34,25 +52,34 @@ export function Sidebar() {
return pathname.startsWith(href);
};
const linkClass = (href: string) =>
`flex items-center gap-3 rounded-md px-2 py-2 text-sm transition-colors ${
isActive(href)
? 'bg-sidebar-accent text-sidebar-accent-foreground font-medium'
: 'text-sidebar-foreground hover:bg-muted'
// Design „Mosaik“ (Fluent 2): 36 px hohe Eintraege, 8 px Radius, die
// Auswahl traegt eine neutrale Flaeche, halbfette Schrift und links eine
// 3 x 16 px grosse Pille in der Akzentfarbe (`SelectionPill`).
const itemClass = (active: boolean) =>
`relative flex h-9 items-center gap-3 rounded-lg text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring ${
isCollapsed ? 'justify-center px-0' : 'px-2.5'
} ${
active
? 'bg-sidebar-accent font-semibold text-sidebar-accent-foreground'
: 'text-sidebar-foreground hover:bg-sidebar-hover'
}`;
const linkClass = (href: string) => itemClass(isActive(href));
const fetchActiveModules = useCallback(async () => {
try {
const res = await fetch(`${API_URL}/modules/active`, {
credentials: 'include',
});
if (res.ok) {
setActiveModules(await res.json());
const modules: SidebarModule[] = await res.json();
setActiveModules(modules);
setNavModules(modules);
}
} catch {
// silently fail
}
}, []);
}, [setNavModules]);
// Befund 17 (quick-260921-gof): sidebarRefreshKey ist der Auffrisch-
// Ausloeser aus dem Marketplace-Speicher — ohne ihn erscheint ein frisch
@@ -64,7 +91,7 @@ export function Sidebar() {
}, [fetchActiveModules, sidebarRefreshKey]);
const toggleCategory = (cat: string) => {
setOpenCategories((prev) => {
setClosedCategories((prev) => {
const next = new Set(prev);
if (next.has(cat)) next.delete(cat);
else next.add(cat);
@@ -85,7 +112,7 @@ export function Sidebar() {
const q = searchQuery.toLowerCase();
const result: [string, SidebarModule[]][] = [];
for (const [cat, mods] of categories.entries()) {
if (cat.toLowerCase().includes(q)) {
if (cat.toLowerCase().includes(q) || categoryLabel(cat).toLowerCase().includes(q)) {
result.push([cat, mods]);
} else {
const filtered = mods.filter((m) => m.name.toLowerCase().includes(q));
@@ -93,115 +120,136 @@ export function Sidebar() {
}
}
return result;
}, [activeModules, searchQuery]);
}, [activeModules, searchQuery, categoryLabel]);
const navIcon = (children: React.ReactNode) => (
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
{children}
</svg>
);
const sidebarContent = (
<div className="flex h-full flex-col bg-sidebar">
<nav className="flex-1 overflow-y-auto p-3">
<ul className="flex flex-col gap-1">
<nav className={`flex-1 overflow-y-auto py-3 ${isCollapsed ? 'px-2' : 'px-3'}`}>
<ul className="flex flex-col gap-0.5">
<li>
<Link href="/" className={linkClass('/')} aria-label={t('dashboard')}>
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
<rect x="3" y="3" width="7" height="7" />
<rect x="14" y="3" width="7" height="7" />
<rect x="14" y="14" width="7" height="7" />
<rect x="3" y="14" width="7" height="7" />
</svg>
<Link href="/" className={linkClass('/')} aria-label={t('dashboard')} title={isCollapsed ? t('dashboard') : undefined}>
{isActive('/') && <SelectionPill />}
{navIcon(
<>
<rect x="3" y="3" width="7" height="9" rx="1.5" />
<rect x="14" y="3" width="7" height="5" rx="1.5" />
<rect x="14" y="12" width="7" height="9" rx="1.5" />
<rect x="3" y="16" width="7" height="5" rx="1.5" />
</>,
)}
{!isCollapsed && <span className="truncate">{t('dashboard')}</span>}
</Link>
</li>
<li>
<Link href="/marketplace" className={linkClass('/marketplace')} aria-label={t('marketplace')}>
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
<circle cx="9" cy="21" r="1" />
<circle cx="20" cy="21" r="1" />
<path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6" />
</svg>
<Link href="/marketplace" className={linkClass('/marketplace')} aria-label={t('marketplace')} title={isCollapsed ? t('marketplace') : undefined}>
{isActive('/marketplace') && <SelectionPill />}
{navIcon(
<>
<path d="M3 9h18l-1.5 10.5a2 2 0 0 1-2 1.5h-11a2 2 0 0 1-2-1.5z" />
<path d="M8 9V7a4 4 0 0 1 8 0v2" />
</>,
)}
{!isCollapsed && <span className="truncate">{t('marketplace')}</span>}
</Link>
</li>
</ul>
{/* Eingeklappt: nur die Modul-Kacheln untereinander (Name als Tooltip). */}
{isCollapsed && activeModules.length > 0 && (
<ul className="mt-3 flex flex-col gap-0.5 border-t border-sidebar-border pt-3">
{activeModules.map((mod) => {
const href = `/modules/${mod.category || 'other'}/${mod.slug}`;
const active = isActive(href) || isActive(`/modules/${mod.slug}`);
return (
<li key={mod.id}>
<Link href={href} className={itemClass(active)} aria-label={mod.name} title={mod.name}>
{active && <SelectionPill />}
<ModuleTile slug={mod.slug} size={20} />
</Link>
</li>
);
})}
</ul>
)}
{!isCollapsed && (
<>
<div className="mt-4">
<div className="mt-4 border-t border-sidebar-border pt-3">
<SidebarSearch value={searchQuery} onChange={setSearchQuery} />
</div>
<div className="mt-2">
<div className="px-2 py-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{t('categories.label')}
</div>
<div className="mt-1">
{activeModules.length === 0 ? (
<div className="px-2 py-2 text-sm text-muted-foreground">
{t('categories.empty')}
</div>
) : filteredCategories.length === 0 ? (
<div className="px-2 py-2 text-sm text-muted-foreground">
{t('noResults')}
</div>
) : (
<ul className="flex flex-col gap-1">
{filteredCategories.map(([category, mods]) => (
<div className="mt-1" aria-label={t('categories.label')} role="group">
{activeModules.length === 0 ? (
<div className="px-2.5 py-2 text-sm text-muted-foreground">
{t('categories.empty')}
</div>
) : filteredCategories.length === 0 ? (
<div className="px-2.5 py-2 text-sm text-muted-foreground">
{t('noResults')}
</div>
) : (
<ul className="flex flex-col gap-2">
{filteredCategories.map(([category, mods]) => {
// Bei aktiver Suche immer aufgeklappt, sonst nach Wahl.
const open = searchQuery !== '' || !closedCategories.has(category);
return (
<li key={category}>
<button
type="button"
onClick={() => toggleCategory(category)}
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-xs font-medium transition-colors ${
isActive(`/modules/${category}`)
? 'bg-sidebar-accent text-sidebar-accent-foreground'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
}`}
aria-expanded={open}
className="group flex h-7 w-full items-center gap-1.5 rounded-md px-2.5 text-xs font-semibold text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
>
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
<span className="truncate">{categoryLabel(category)}</span>
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round" className={`ml-auto shrink-0 opacity-70 transition-transform duration-150 ${open ? '' : '-rotate-90'}`}>
<polyline points="6 9 12 15 18 9" />
</svg>
<span className="capitalize">{category.replace(/-/g, ' ')}</span>
<span className="ml-auto flex items-center gap-1.5">
<span className="text-[10px] text-muted-foreground/60">{mods.length}</span>
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={`transition-transform duration-200 ${openCategories.has(category) ? 'rotate-180' : ''}`}>
<polyline points="6 9 12 15 18 9" />
</svg>
</span>
</button>
{openCategories.has(category) && (
<ul className="ml-5 flex flex-col gap-0.5">
{mods.map((mod) => (
<li key={mod.id}>
<Link
href={`/modules/${category}/${mod.slug}`}
className={`block rounded-md px-2 py-1 text-xs transition-colors ${
isActive(`/modules/${category}/${mod.slug}`)
? 'bg-sidebar-accent text-sidebar-accent-foreground font-medium'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
}`}
>
{mod.name}
</Link>
</li>
))}
{open && (
<ul className="mt-0.5 flex flex-col gap-0.5">
{mods.map((mod) => {
const href = `/modules/${category}/${mod.slug}`;
const active = isActive(href) || isActive(`/modules/${mod.slug}`);
return (
<li key={mod.id}>
<Link href={href} className={itemClass(active)}>
{active && <SelectionPill />}
<ModuleTile slug={mod.slug} size={20} />
<span className="truncate">{mod.name}</span>
</Link>
</li>
);
})}
</ul>
)}
</li>
))}
</ul>
)}
</div>
);
})}
</ul>
)}
</div>
</>
)}
</nav>
<div className="hidden md:block border-t border-sidebar-border p-2">
<div className="hidden border-t border-sidebar-border px-3 py-2 md:block">
<button
type="button"
onClick={toggle}
className="flex w-full items-center gap-3 rounded-md px-2 py-1.5 text-sm text-sidebar-foreground hover:bg-muted transition-colors"
className={`flex h-8 w-full items-center gap-3 rounded-lg text-sm text-muted-foreground transition-colors hover:bg-sidebar-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring ${isCollapsed ? 'justify-center' : 'px-2.5'}`}
aria-label={isCollapsed ? t('expand') : t('collapse')}
title={isCollapsed ? t('expand') : undefined}
>
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={`shrink-0 transition-transform duration-200 ${isCollapsed ? 'rotate-180' : ''}`}>
<polyline points="15 18 9 12 15 6" />
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" className={`shrink-0 transition-transform duration-200 ${isCollapsed ? 'rotate-180' : ''}`}>
<rect x="3" y="3" width="18" height="18" rx="2" />
<path d="M9 3v18" />
<path d="m16 15-3-3 3-3" />
</svg>
{!isCollapsed && <span className="truncate">{t('collapse')}</span>}
</button>
@@ -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 && (
<div className="border-t border-sidebar-border px-4 py-2">
<div className="px-5 pb-3 pt-1">
<AppVersionBadge />
</div>
)}
@@ -220,7 +268,7 @@ export function Sidebar() {
return (
<>
<aside
className="fixed left-0 top-[var(--header-height)] z-30 hidden h-[calc(100vh-var(--header-height))] border-r border-sidebar-border transition-all duration-200 md:block"
className="fixed left-0 top-[var(--header-height)] z-30 hidden h-[calc(100vh-var(--header-height))] border-r border-sidebar-border transition-[width] duration-200 md:block"
style={{
width: isCollapsed
? 'var(--sidebar-width-collapsed)'
@@ -237,22 +285,21 @@ export function Sidebar() {
onClick={() => setMobileOpen(false)}
aria-hidden="true"
/>
<aside className="fixed left-0 top-0 z-50 h-full w-[var(--sidebar-width)] bg-sidebar shadow-lg">
<div className="flex h-[var(--header-height)] items-center justify-between border-b border-sidebar-border px-4">
<aside className="fixed left-0 top-0 z-50 flex h-full w-[var(--sidebar-width)] flex-col bg-sidebar shadow-xl">
{/* Kopf der Schublade im Stil der App-Leiste (immer dunkel). */}
<div className="flex h-[var(--header-height)] items-center justify-between bg-appbar px-3 text-appbar-foreground">
<TesseraLogo
variant="horizontal"
size={28}
// Gelb traegt nur auf dunklem Grund: gemessen 13,1:1 im dunklen
// Erscheinungsbild gegen 1,26:1 im hellen (WCAG verlangt 3:1 fuer
// grossen fetten Text). Im hellen Modus erbt der Schriftzug daher
// die Vordergrundfarbe, im dunklen steht die Markenfarbe.
wordmarkClassName="dark:text-primary"
/>
variant="horizontal"
size={26}
plateOutline="always"
className="gap-2.5"
wordmarkClassName="text-[15px] font-semibold tracking-normal text-appbar-foreground"
/>
<button
type="button"
onClick={() => setMobileOpen(false)}
aria-label={tCommon('close')}
className="rounded-md p-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-appbar-muted transition-colors hover:bg-appbar-hover hover:text-appbar-foreground"
>
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18" />
@@ -260,7 +307,7 @@ export function Sidebar() {
</svg>
</button>
</div>
{sidebarContent}
<div className="min-h-0 flex-1">{sidebarContent}</div>
</aside>
</div>
)}
+1 -1
View File
@@ -35,7 +35,7 @@ export function ThemeToggle() {
<button
type="button"
onClick={cycleTheme}
className="inline-flex items-center justify-center rounded-md p-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-appbar-muted hover:bg-appbar-hover hover:text-appbar-foreground transition-colors focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-appbar-foreground"
aria-label={label}
title={label}
>