feat(web): Design Mosaik Runde 3 - Reiter in der Seitenleiste, lebendigere Kacheln

- Dashboard-Reiter stehen in der aufgeklappten Seitenleiste als Liste
  unter "Dashboard" (Ziehen sortiert senkrecht, Umbenennen/Loeschen/Neu
  im Bearbeitungsmodus); eingeklappt und auf dem Handy wie bisher in der
  Kopfzeile. Die Kopfzeile zeigt sonst den Namen des Dashboards.
- Begruessung einzeilig auf Hoehe der Befehlsleiste.
- Kachel-Groesse: kollidiert die gezogene Groesse mit einem Nachbarn, wird
  nur die freie Achse uebernommen - vorher verwarf react-grid-layout mit
  preventCollision die ganze Aenderung, sobald die Maus eine Zeile nach
  unten wackelte (Favoriten liessen sich deshalb oft nicht schmaler ziehen).
- Ansicht: belegte Spalten mittig, Begruessung/Befehlsleiste ruecken mit.
- Kalender: einzeilige Terminliste mit "Heute"/"Morgen" in Akzentfarbe,
  Ort im Tooltip, Standardhoehe 16 statt 12.
- Hintergrund "Bluete" im Dunkelmodus weggelassen (Farbstufen), Ersatz Nebel.
- Kacheln: Symbol-Chip in der Akzentfarbe, leichtes Anheben beim Zeigen,
  gestaffeltes Einblenden beim Laden (ohne bei reduzierter Bewegung).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-28 15:58:01 +02:00
parent b06a2d1066
commit 8532b63097
20 changed files with 552 additions and 73 deletions
+8 -1
View File
@@ -48,6 +48,9 @@ interface ActiveModule {
export default function DashboardPage() { export default function DashboardPage() {
const t = useTranslations('widgets'); const t = useTranslations('widgets');
const [catalogOpen, setCatalogOpen] = useState(false); const [catalogOpen, setCatalogOpen] = useState(false);
// Seitlicher Versatz der zentrierten Kacheln — die Zeile mit Begruessung
// und Befehlsleiste rueckt mit, damit sie buendig ueber den Kacheln steht.
const [gridInset, setGridInset] = useState(0);
// Hintergrund je Benutzer (Design „Mosaik“, Prototyp mit localStorage). // Hintergrund je Benutzer (Design „Mosaik“, Prototyp mit localStorage).
const userId = useAuthStore((s) => s.user?.id); const userId = useAuthStore((s) => s.user?.id);
const { background, choose: chooseBackground } = useDashboardBackground(userId); const { background, choose: chooseBackground } = useDashboardBackground(userId);
@@ -143,7 +146,10 @@ export default function DashboardPage() {
links, Befehlsleiste rechts (Fluent „command bar“) statt des links, Befehlsleiste rechts (Fluent „command bar“) statt des
frueheren schwebenden Knopfs unten rechts. Mit Hintergrund liegen frueheren schwebenden Knopfs unten rechts. Mit Hintergrund liegen
beide auf der durchscheinenden Kachelflaeche (.dashboard-chrome). */} beide auf der durchscheinenden Kachelflaeche (.dashboard-chrome). */}
<div className="flex flex-wrap items-end justify-between gap-x-4 gap-y-2 px-3 pb-1 pt-2"> <div
className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 px-3 pt-1 transition-[padding] duration-200 ease-out motion-reduce:transition-none"
style={gridInset > 0 ? { paddingInline: 12 + gridInset } : undefined}
>
<div className="dashboard-chrome min-w-0 rounded-lg"> <div className="dashboard-chrome min-w-0 rounded-lg">
<DashboardGreeting /> <DashboardGreeting />
</div> </div>
@@ -198,6 +204,7 @@ export default function DashboardPage() {
onRemoveWidget={removeWidget} onRemoveWidget={removeWidget}
onOpenCatalog={() => setCatalogOpen(true)} onOpenCatalog={() => setCatalogOpen(true)}
onQuickAdd={(type) => addWidget(type)} onQuickAdd={(type) => addWidget(type)}
onInsetChange={setGridInset}
/> />
)} )}
+64
View File
@@ -433,6 +433,70 @@ input[type="checkbox"], input[type="radio"] {
line-height: 1.25rem; line-height: 1.25rem;
} }
/* Seitenleiste (Runde 3): stehen die Dashboard-Reiter unter „Dashboard“,
traegt nur der gewaehlte Reiter die Auswahl — der Obereintrag bleibt
neutral, sonst waere „Dashboard“ doppelt markiert. */
li:has(> #sidebar-dashboard-slot > nav) > a {
background-color: transparent;
font-weight: 500;
}
li:has(> #sidebar-dashboard-slot > nav) > a:hover {
background-color: var(--sidebar-hover);
}
li:has(> #sidebar-dashboard-slot > nav) > a [data-testid="nav-selection-pill"] {
display: none;
}
/* Runde 3 — mehr Leben in den Kacheln, ohne bunt zu werden.
Symbol-Chip: Flaeche und Symbol aus der persoenlichen Akzentfarbe. */
.widget-icon-chip {
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 1.625rem;
height: 1.625rem;
border-radius: 0.4375rem;
background-color: color-mix(in oklab, var(--primary) 24%, var(--card));
color: color-mix(in oklab, var(--primary) 38%, oklch(0.27 0.02 90));
}
.dark .widget-icon-chip {
background-color: color-mix(in oklab, var(--primary) 16%, var(--card));
color: var(--primary);
}
/* Karte hebt sich beim Zeigen leicht an (nur Maus, nur Ansicht). */
@media (hover: hover) {
.widget-card {
transition: box-shadow 160ms ease-out, translate 160ms ease-out;
}
.widget-card:not(.widget-drag-handle):hover {
translate: 0 -1px;
box-shadow: 0 0 2px oklch(0 0 0 / 0.12), 0 6px 16px oklch(0 0 0 / 0.12);
}
.dark .widget-card:not(.widget-drag-handle):hover {
box-shadow: 0 0 0 1px oklch(1 0 0 / 0.08), 0 8px 20px oklch(0 0 0 / 0.45);
}
}
/* Beim Laden blenden die Kacheln gestaffelt ein (je 40 ms, --enter-delay). */
@media (prefers-reduced-motion: no-preference) {
.widget-card {
animation: widget-enter 320ms cubic-bezier(0.2, 0.7, 0.3, 1) both;
animation-delay: var(--enter-delay, 0ms);
}
}
@keyframes widget-enter {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: none;
}
}
/* Aktionen der Kachel erscheinen erst beim Zeigen oder mit Tastaturfokus — /* Aktionen der Kachel erscheinen erst beim Zeigen oder mit Tastaturfokus —
nur auf Geraeten mit Maus; auf Touch bleiben sie sichtbar. */ nur auf Geraeten mit Maus; auf Touch bleiben sie sichtbar. */
@media (hover: hover) { @media (hover: hover) {
@@ -209,8 +209,8 @@ export function BackgroundPicker({ value, onChange }: BackgroundPickerProps) {
{option('none', t('none'), NO_BACKGROUND, { backgroundColor: 'var(--background)' }, {option('none', t('none'), NO_BACKGROUND, { backgroundColor: 'var(--background)' },
<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"><circle cx="12" cy="12" r="8" /><path d="m6.5 17.5 11-11" /></svg>, <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"><circle cx="12" cy="12" r="8" /><path d="m6.5 17.5 11-11" /></svg>,
)} )}
{BACKGROUND_PRESETS.map((preset) => { {BACKGROUND_PRESETS.filter((preset) => !dark || preset.dark !== null).map((preset) => {
const css = dark ? preset.dark : preset.light; const css = (dark ? preset.dark : preset.light) ?? preset.light;
return option( return option(
preset.id, preset.id,
t(`presets.${preset.labelKey}`), t(`presets.${preset.labelKey}`),
@@ -19,8 +19,8 @@ export function greetingName(displayName: string | null | undefined, username: s
} }
/** /**
* Ruhige Begruessung ueber dem Raster (Design „Mosaik“, Runde 2): „Guten * Ruhige Begruessung ueber dem Raster (Design „Mosaik“, Runde 2/3): „Guten
* Morgen, Max“ plus Wochentag und Datum. Keine Kennzahlen. Die Uhrzeit wird * Morgen, Max“ plus Wochentag und Datum in einer Zeile. Keine Kennzahlen. Die Uhrzeit wird
* erst im Browser gelesen (kein Unterschied zwischen Server und Browser) und * erst im Browser gelesen (kein Unterschied zwischen Server und Browser) und
* jede Minute aufgefrischt. * jede Minute aufgefrischt.
*/ */
@@ -36,7 +36,7 @@ export function DashboardGreeting() {
return () => window.clearInterval(id); return () => window.clearInterval(id);
}, []); }, []);
if (!now) return <div className="h-14" aria-hidden="true" />; if (!now) return <div className="h-8" aria-hidden="true" />;
const name = greetingName(user?.displayName, user?.username); const name = greetingName(user?.displayName, user?.username);
const period = greetingPeriod(now); const period = greetingPeriod(now);
@@ -46,12 +46,14 @@ export function DashboardGreeting() {
month: 'long', month: 'long',
}).format(now); }).format(now);
// Runde 3: eine Zeile auf Hoehe der Befehlsleiste statt zweizeiliger
// Ueberschrift — die Begruessung bleibt, kostet aber kaum noch Platz.
return ( return (
<div data-testid="dashboard-greeting" className="min-w-0"> <div data-testid="dashboard-greeting" className="flex h-8 min-w-0 items-baseline gap-2 leading-8">
<h1 className="truncate text-2xl font-semibold text-foreground"> <h1 className="truncate text-base font-semibold text-foreground">
{name ? t(`${period}Named`, { name }) : t(period)} {name ? t(`${period}Named`, { name }) : t(period)}
</h1> </h1>
<p className="text-sm text-muted-foreground">{dateLine}</p> <p className="hidden shrink-0 text-sm text-muted-foreground sm:block">{dateLine}</p>
</div> </div>
); );
} }
@@ -2,6 +2,7 @@ import { Children, isValidElement } from 'react';
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'; import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { stubResizeObserver } from '@/test/fake-resize-observer'; import { stubResizeObserver } from '@/test/fake-resize-observer';
import { centeringOffset, RESIZE_AXIS_FALLBACK } from './dashboard-grid';
// Mock CSS imports that vitest cannot resolve // Mock CSS imports that vitest cannot resolve
vi.mock('react-grid-layout/css/styles.css', () => ({})); vi.mock('react-grid-layout/css/styles.css', () => ({}));
@@ -481,3 +482,30 @@ describe('DashboardGrid', () => {
expect(captured.props?.width).toBe(1600); expect(captured.props?.width).toBe(1600);
}); });
}); });
describe('RESIZE_AXIS_FALLBACK (Design „Mosaik“, Runde 3)', () => {
// Favoriten (0,0,6x10) mit einer Kachel direkt darunter (0,10,6x8).
const layout = [
{ i: 'fav', x: 0, y: 0, w: 6, h: 10 },
{ i: 'below', x: 0, y: 10, w: 6, h: 8 },
];
const context = { layout } as unknown as Parameters<NonNullable<typeof RESIZE_AXIS_FALLBACK.constrainSize>>[4];
const item = layout[0] as Parameters<NonNullable<typeof RESIZE_AXIS_FALLBACK.constrainSize>>[0];
it('uebernimmt die Breite, wenn die Maus beim Schmalerziehen eine Zeile nach unten wackelt', () => {
expect(RESIZE_AXIS_FALLBACK.constrainSize?.(item, 2, 11, 'se', context)).toEqual({ w: 2, h: 10 });
});
it('laesst eine freie Groesse unveraendert', () => {
expect(RESIZE_AXIS_FALLBACK.constrainSize?.(item, 3, 9, 'se', context)).toEqual({ w: 3, h: 9 });
});
});
describe('centeringOffset (Design „Mosaik“, Runde 3)', () => {
it('stellt belegte Spalten mittig und laesst eine volle Anordnung stehen', () => {
// 24 Spalten auf 1212 px: Spalte 38 px + 12 px Abstand = 50 px je Spalte.
expect(centeringOffset([{ x: 0, w: 12 }], 24, 1212)).toBe(300);
expect(centeringOffset([{ x: 12, w: 12 }], 24, 1212)).toBe(0);
expect(centeringOffset([], 24, 1212)).toBe(0);
});
});
@@ -3,6 +3,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Responsive, noCompactor } from 'react-grid-layout'; import { Responsive, noCompactor } from 'react-grid-layout';
import type { Compactor, ResponsiveLayouts } from 'react-grid-layout'; import type { Compactor, ResponsiveLayouts } from 'react-grid-layout';
import { defaultConstraints, type LayoutConstraint } from 'react-grid-layout/core';
import 'react-grid-layout/css/styles.css'; import 'react-grid-layout/css/styles.css';
import 'react-resizable/css/styles.css'; import 'react-resizable/css/styles.css';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
@@ -44,6 +45,74 @@ export const WIDGET_DRAG_CANCEL_SELECTOR =
// `preventCollision` lebt am Compactor-Objekt (chunk-WGL5FSZH.mjs:666). // `preventCollision` lebt am Compactor-Objekt (chunk-WGL5FSZH.mjs:666).
export const FREE_PLACEMENT_COMPACTOR: Compactor = { ...noCompactor, preventCollision: true }; export const FREE_PLACEMENT_COMPACTOR: Compactor = { ...noCompactor, preventCollision: true };
/**
* Groesse aendern trotz Nachbarn (Design „Mosaik“, Runde 3).
*
* Mit `preventCollision` verwirft react-grid-layout 2.2.3 beim Ziehen am
* Groessengriff die GANZE neue Groesse, sobald sie irgendeinen Nachbarn
* beruehrt (chunk-WGL5FSZH.mjs, onResize: w UND h zurueck auf alt). Wer eine
* Kachel nur schmaler ziehen will und dabei die Maus eine Rasterzeile nach
* unten wackelt, stoesst an die Kachel darunter — und die Breite bleibt
* stehen. Genau so liess sich die Favoriten-Kachel oft nicht schmaler machen.
*
* Diese Regel laeuft vor der Kollisionspruefung: kollidiert die gewuenschte
* Groesse, wird zuerst nur die Breite (alte Hoehe), dann nur die Hoehe (alte
* Breite) uebernommen — was frei ist, gewinnt. Nur fuer Griffe, die x/y nicht
* verschieben (se, e, s); unser Raster zeigt nur den se-Griff.
*/
function overlaps(
a: { x: number; y: number; w: number; h: number },
b: { x: number; y: number; w: number; h: number },
): boolean {
return a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
}
export const RESIZE_AXIS_FALLBACK: LayoutConstraint = {
name: 'resizeAxisFallback',
constrainSize(item, w, h, handle, context) {
if (handle !== 'se' && handle !== 'e' && handle !== 's') return { w, h };
const current = context.layout.find((l) => l.i === item.i) ?? item;
const others = context.layout.filter((l) => l.i !== item.i);
const free = (cw: number, ch: number) =>
!others.some((o) => overlaps({ x: current.x, y: current.y, w: cw, h: ch }, o));
if (free(w, h)) return { w, h };
if (free(w, current.h)) return { w, h: current.h };
if (free(current.w, h)) return { w: current.w, h };
return { w, h };
},
};
const GRID_CONSTRAINTS: LayoutConstraint[] = [...defaultConstraints, RESIZE_AXIS_FALLBACK];
const GRID_MARGIN = 12;
/**
* Seitlicher Versatz, um die belegten Spalten in der Ansicht mittig zu
* stellen (Design „Mosaik“, Runde 3): auf breiten Bildschirmen blieb rechts
* ein leerer Streifen, weil die Kacheln links beginnen. Belegt die Anordnung
* schon (fast) die ganze Breite, bleibt es bei 0.
*/
export function centeringOffset(
items: Array<{ x: number; w: number }> | undefined,
cols: number,
width: number,
): number {
if (!items || items.length === 0 || cols <= 0 || width <= 0) return 0;
const usedCols = Math.min(cols, Math.max(...items.map((l) => l.x + l.w)));
if (usedCols >= cols) return 0;
const colWidth = (width - GRID_MARGIN * (cols + 1)) / cols;
const freeWidth = (cols - usedCols) * (colWidth + GRID_MARGIN);
return Math.max(0, Math.floor(freeWidth / 2));
}
/** Breakpoint zur Breite — gleiche Regel wie react-grid-layout (strikt groesser). */
function breakpointFor(width: number): keyof typeof COLS {
const order = (Object.keys(BREAKPOINTS) as Array<keyof typeof BREAKPOINTS>).sort(
(a, b) => BREAKPOINTS[b] - BREAKPOINTS[a],
);
return order.find((bp) => width > BREAKPOINTS[bp]) ?? 'xxs';
}
interface LayoutItemShape { interface LayoutItemShape {
i: string; i: string;
x: number; x: number;
@@ -64,6 +133,8 @@ interface DashboardGridProps {
onOpenCatalog?: () => void; onOpenCatalog?: () => void;
/** Leerer Zustand: fuegt eine vorgeschlagene Kachel direkt hinzu. */ /** Leerer Zustand: fuegt eine vorgeschlagene Kachel direkt hinzu. */
onQuickAdd?: (type: WidgetType) => void; onQuickAdd?: (type: WidgetType) => void;
/** Meldet den seitlichen Versatz der zentrierten Ansicht (0 im Bearbeitungsmodus). */
onInsetChange?: (px: number) => void;
} }
/** Vorschlaege im leeren Zustand — Plattform-Kacheln, immer verfuegbar. */ /** Vorschlaege im leeren Zustand — Plattform-Kacheln, immer verfuegbar. */
@@ -132,6 +203,7 @@ export function DashboardGrid({
onRemoveWidget, onRemoveWidget,
onOpenCatalog, onOpenCatalog,
onQuickAdd, onQuickAdd,
onInsetChange,
}: DashboardGridProps) { }: DashboardGridProps) {
const t = useTranslations('widgets'); const t = useTranslations('widgets');
const [width, setWidth] = useState(1200); const [width, setWidth] = useState(1200);
@@ -212,6 +284,17 @@ export function DashboardGrid({
// stabil bleibt. // stabil bleibt.
const effectiveLayouts = useMemo(() => applyConstraintMinima(layouts, widgets), [layouts, widgets]); const effectiveLayouts = useMemo(() => applyConstraintMinima(layouts, widgets), [layouts, widgets]);
// Zentrieren nur in der Ansicht — im Bearbeitungsmodus braucht es das
// volle Raster, damit Kacheln auch in die freien Spalten rechts passen.
const breakpoint = breakpointFor(width);
const inset = isEditMode
? 0
: centeringOffset(effectiveLayouts[breakpoint] ?? effectiveLayouts.lg, COLS[breakpoint], width);
useEffect(() => {
onInsetChange?.(widgets.length === 0 ? 0 : inset);
}, [inset, onInsetChange, widgets.length]);
// Leerer Zustand (D-02, Design „Mosaik“ Runde 2): freundlich ohne // Leerer Zustand (D-02, Design „Mosaik“ Runde 2): freundlich ohne
// Illustration — Titel, ein Satz, drei Vorschlaege zum direkten // Illustration — Titel, ein Satz, drei Vorschlaege zum direkten
// Hinzufuegen und der Hauptknopf zum Katalog. // Hinzufuegen und der Hauptknopf zum Katalog.
@@ -238,7 +321,7 @@ export function DashboardGrid({
onClick={() => onQuickAdd(type)} onClick={() => onQuickAdd(type)}
className="inline-flex h-8 items-center gap-2 rounded-full border border-border bg-card px-3 text-sm text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" className="inline-flex h-8 items-center gap-2 rounded-full border border-border bg-card px-3 text-sm text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
> >
<WidgetIcon type={type} /> <WidgetIcon type={type} variant="plain" />
{t(WIDGET_REGISTRY[type].nameKey)} {t(WIDGET_REGISTRY[type].nameKey)}
</button> </button>
</li> </li>
@@ -257,14 +340,22 @@ export function DashboardGrid({
} }
return ( return (
<div ref={measureRef}> // overflow-x-clip: die verschobene Rasterflaeche ragt rechts ueber den
// Rand; `clip` (nicht `hidden`) laesst die Schatten oben/unten stehen.
<div ref={measureRef} className="overflow-x-clip">
<div
data-testid="dashboard-grid-inset"
className="transition-transform duration-200 ease-out motion-reduce:transition-none"
style={inset > 0 ? { transform: `translateX(${inset}px)` } : undefined}
>
<Responsive <Responsive
width={width} width={width}
breakpoints={BREAKPOINTS} breakpoints={BREAKPOINTS}
cols={COLS} cols={COLS}
layouts={effectiveLayouts as ResponsiveLayouts} layouts={effectiveLayouts as ResponsiveLayouts}
rowHeight={20} rowHeight={20}
margin={[12, 12] as [number, number]} margin={[GRID_MARGIN, GRID_MARGIN] as [number, number]}
constraints={GRID_CONSTRAINTS}
// containerPadding bewusst nicht gesetzt: react-grid-layout 2.2.3 nimmt // containerPadding bewusst nicht gesetzt: react-grid-layout 2.2.3 nimmt
// dann das margin (gemessen: effectiveContainerPadding = containerPadding ?? margin). // dann das margin (gemessen: effectiveContainerPadding = containerPadding ?? margin).
dragConfig={{ dragConfig={{
@@ -279,7 +370,7 @@ export function DashboardGrid({
compactor={FREE_PLACEMENT_COMPACTOR} compactor={FREE_PLACEMENT_COMPACTOR}
onLayoutChange={(_currentLayout, allLayouts) => onLayoutChange(allLayouts)} onLayoutChange={(_currentLayout, allLayouts) => onLayoutChange(allLayouts)}
> >
{widgets.map((widget) => { {widgets.map((widget, index) => {
const constraints = WIDGET_CONSTRAINTS[widget.widgetType as WidgetType]; const constraints = WIDGET_CONSTRAINTS[widget.widgetType as WidgetType];
return ( return (
<div <div
@@ -299,11 +390,13 @@ export function DashboardGrid({
widget={widget} widget={widget}
isEditMode={isEditMode} isEditMode={isEditMode}
onRemove={onRemoveWidget} onRemove={onRemoveWidget}
enterIndex={index}
/> />
</div> </div>
); );
})} })}
</Responsive> </Responsive>
</div>
</div> </div>
); );
} }
@@ -12,7 +12,8 @@ import {
useState, useState,
} from 'react'; } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { HEADER_CENTER_SLOT_ID } from '@/components/layout/header-slot'; import { HEADER_CENTER_SLOT_ID, SIDEBAR_DASHBOARD_SLOT_ID } from '@/components/layout/header-slot';
import { useSidebarStore } from '@/lib/stores/sidebar-store';
import type { DashboardTab } from '@/lib/dashboard-api'; import type { DashboardTab } from '@/lib/dashboard-api';
interface DashboardTabsProps { interface DashboardTabsProps {
@@ -46,6 +47,30 @@ interface DragState {
id: string; id: string;
pointerId: number; pointerId: number;
startX: number; startX: number;
startY: number;
}
/**
* Wo die Reiter stehen (Design „Mosaik“, Runde 3): in der aufgeklappten
* Seitenleiste am Desktop als Liste unter „Dashboard“ — dort kosten sie
* keinen Platz ueber den Kacheln, und die Kopfzeile zeigt nur den Namen des
* gewaehlten Dashboards. Eingeklappt oder auf dem Handy gibt es diese Liste
* nicht, dann wie bisher als Umschalter in der Kopfzeile.
*/
type Placement = 'sidebar' | 'header';
function useTabsPlacement(): Placement {
const isCollapsed = useSidebarStore((st) => st.isCollapsed);
const [isDesktop, setIsDesktop] = useState(false);
useEffect(() => {
if (typeof window.matchMedia !== 'function') return;
const mq = window.matchMedia('(min-width: 768px)');
const update = () => setIsDesktop(mq.matches);
update();
mq.addEventListener('change', update);
return () => mq.removeEventListener('change', update);
}, []);
return isDesktop && !isCollapsed ? 'sidebar' : 'header';
} }
/** /**
@@ -57,16 +82,16 @@ interface DragState {
function computeReorderedIds( function computeReorderedIds(
order: string[], order: string[],
draggedId: string, draggedId: string,
rects: Map<string, { left: number; width: number }>, rects: Map<string, { start: number; size: number }>,
clientX: number, pointer: number,
): string[] { ): string[] {
const others = order.filter((id) => id !== draggedId); const others = order.filter((id) => id !== draggedId);
let insertAt = others.length; let insertAt = others.length;
for (let i = 0; i < others.length; i++) { for (let i = 0; i < others.length; i++) {
const rect = rects.get(others[i]); const rect = rects.get(others[i]);
if (!rect) continue; if (!rect) continue;
const midpoint = rect.left + rect.width / 2; const midpoint = rect.start + rect.size / 2;
if (clientX < midpoint) { if (pointer < midpoint) {
insertAt = i; insertAt = i;
break; break;
} }
@@ -131,13 +156,20 @@ export function DashboardTabs({
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const hintId = useId(); const hintId = useId();
const [slot, setSlot] = useState<HTMLElement | null>(null); const [slot, setSlot] = useState<HTMLElement | null>(null);
const [headerSlot, setHeaderSlot] = useState<HTMLElement | null>(null);
const [overflow, setOverflow] = useState({ left: false, right: false }); const [overflow, setOverflow] = useState({ left: false, right: false });
const placement = useTabsPlacement();
const vertical = placement === 'sidebar';
// Einhaengepunkt der Kopfzeile suchen. Die Kopfzeile steht im selben // Einhaengepunkte suchen. Kopfzeile und Seitenleiste stehen im selben
// Seitenrahmen (AppShell) und ist nach dem ersten Zeichnen im DOM. // Seitenrahmen (AppShell) und sind nach dem ersten Zeichnen im DOM; der
// Punkt der Seitenleiste entsteht bzw. verschwindet mit dem Ein-/Ausklappen
// im selben Durchgang, der `placement` aendert.
useEffect(() => { useEffect(() => {
setSlot(document.getElementById(HEADER_CENTER_SLOT_ID)); const header = document.getElementById(HEADER_CENTER_SLOT_ID);
}, []); setHeaderSlot(header);
setSlot(vertical ? (document.getElementById(SIDEBAR_DASHBOARD_SLOT_ID) ?? header) : header);
}, [vertical]);
/** Misst, ob die Spur links/rechts ueberlaeuft — steuert die weiche Ausblendung. */ /** Misst, ob die Spur links/rechts ueberlaeuft — steuert die weiche Ausblendung. */
const measureOverflow = useCallback(() => { const measureOverflow = useCallback(() => {
@@ -200,7 +232,7 @@ export function DashboardTabs({
function handlePointerDown(e: PointerEvent<HTMLDivElement>, tabId: string) { function handlePointerDown(e: PointerEvent<HTMLDivElement>, tabId: string) {
if (e.button !== 0 || renamingId) return; if (e.button !== 0 || renamingId) return;
dragRef.current = { id: tabId, pointerId: e.pointerId, startX: e.clientX }; dragRef.current = { id: tabId, pointerId: e.pointerId, startX: e.clientX, startY: e.clientY };
} }
function handlePointerMove(e: PointerEvent<HTMLDivElement>) { function handlePointerMove(e: PointerEvent<HTMLDivElement>) {
@@ -209,7 +241,8 @@ export function DashboardTabs({
const wasNotDragging = previewOrder === null; const wasNotDragging = previewOrder === null;
if (wasNotDragging) { if (wasNotDragging) {
if (Math.abs(e.clientX - drag.startX) < DRAG_THRESHOLD_PX) return; const moved = vertical ? e.clientY - drag.startY : e.clientX - drag.startX;
if (Math.abs(moved) < DRAG_THRESHOLD_PX) return;
hasDraggedRef.current = true; hasDraggedRef.current = true;
capturePointer(e.currentTarget, drag.pointerId); capturePointer(e.currentTarget, drag.pointerId);
} }
@@ -221,15 +254,18 @@ export function DashboardTabs({
// wird) keine Verschiebung zeigen, weil der ERSTE Move-Aufruf nur in den // wird) keine Verschiebung zeigen, weil der ERSTE Move-Aufruf nur in den
// Ziehzustand wechselt, ohne die Position auszuwerten. // Ziehzustand wechselt, ohne die Position auszuwerten.
const baseOrder = previewOrder ?? dashboards.map((d) => d.id); const baseOrder = previewOrder ?? dashboards.map((d) => d.id);
const rects = new Map<string, { left: number; width: number }>(); const rects = new Map<string, { start: number; size: number }>();
for (const id of baseOrder) { for (const id of baseOrder) {
const el = tabRefs.current.get(id); const el = tabRefs.current.get(id);
if (el) { if (el) {
const rect = el.getBoundingClientRect(); const rect = el.getBoundingClientRect();
rects.set(id, { left: rect.left, width: rect.width }); rects.set(
id,
vertical ? { start: rect.top, size: rect.height } : { start: rect.left, size: rect.width },
);
} }
} }
const next = computeReorderedIds(baseOrder, drag.id, rects, e.clientX); const next = computeReorderedIds(baseOrder, drag.id, rects, vertical ? e.clientY : e.clientX);
if (wasNotDragging || next.join('\u0000') !== baseOrder.join('\u0000')) { if (wasNotDragging || next.join('\u0000') !== baseOrder.join('\u0000')) {
setPreviewOrder(next); setPreviewOrder(next);
} }
@@ -287,8 +323,10 @@ export function DashboardTabs({
function handleTabKeyDown(e: KeyboardEvent<HTMLButtonElement>, index: number) { function handleTabKeyDown(e: KeyboardEvent<HTMLButtonElement>, index: number) {
const count = orderedTabs.length; const count = orderedTabs.length;
let next: number; let next: number;
if (e.key === 'ArrowRight') next = (index + 1) % count; const forward = vertical ? 'ArrowDown' : 'ArrowRight';
else if (e.key === 'ArrowLeft') next = (index - 1 + count) % count; const backward = vertical ? 'ArrowUp' : 'ArrowLeft';
if (e.key === forward) next = (index + 1) % count;
else if (e.key === backward) next = (index - 1 + count) % count;
else if (e.key === 'Home') next = 0; else if (e.key === 'Home') next = 0;
else if (e.key === 'End') next = count - 1; else if (e.key === 'End') next = count - 1;
else return; else return;
@@ -492,6 +530,153 @@ export function DashboardTabs({
</nav> </nav>
); );
// Senkrechte Liste unter „Dashboard“ in der Seitenleiste (Runde 3).
// Gleiche Bedienung wie die Leiste: Klick waehlt, Ziehen sortiert (hier
// senkrecht), Pfeiltasten hoch/runter wandern, Umbenennen/Loeschen/Neu nur
// im Bearbeitungsmodus. Mit nur einem Dashboard und ausserhalb des
// Bearbeitungsmodus bleibt die Liste weg — dann gibt es nichts zu waehlen.
const pencilIcon = (
<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">
<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
</svg>
);
const closeIcon = (
<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">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
);
const showList = dashboards.length > 1 || isEditMode;
const list = showList ? (
<nav aria-label={t('tabs.navLabel')} className="ml-[19px] mt-0.5 border-l border-sidebar-border pl-2">
<div
role="tablist"
aria-orientation="vertical"
aria-describedby={dashboards.length > 1 ? hintId : undefined}
title={dashboards.length > 1 ? t('tabs.dragHint') : undefined}
className="flex flex-col gap-0.5"
>
{orderedTabs.map((tab, index) => {
const isActive = tab.id === activeDashboardId;
const isRenaming = renamingId === tab.id;
const isFocusable = isActive || (!hasActive && index === 0);
return (
<div
key={tab.id}
ref={(el) => {
if (el) tabRefs.current.set(tab.id, el);
else tabRefs.current.delete(tab.id);
}}
data-tab-index={index}
data-testid={`dashboard-tab-${tab.id}`}
className={`group relative flex h-8 touch-none items-center rounded-md transition-colors ${
isActive
? 'bg-sidebar-hover font-semibold text-foreground'
: 'text-muted-foreground hover:bg-sidebar-hover hover:text-foreground'
}`}
onPointerDown={(e) => handlePointerDown(e, tab.id)}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerCancel}
>
{isActive && (
<span
aria-hidden="true"
className="pointer-events-none absolute -left-[10px] top-1/2 h-4 w-[3px] -translate-y-1/2 rounded-full bg-primary-strong"
/>
)}
{isRenaming ? (
<input
ref={renameInputRef}
value={draftName}
onChange={(e) => setDraftName(e.target.value)}
onBlur={commitRename}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
commitRename();
} else if (e.key === 'Escape') {
e.preventDefault();
cancelRename();
}
}}
maxLength={40}
aria-label={t('tabs.renameInputLabel')}
className="mx-1 h-7 min-w-0 flex-1 rounded-md border border-input bg-background px-2 text-sm font-normal text-foreground outline-none focus:border-primary-strong"
/>
) : (
<button
ref={(el) => {
if (el) tabButtonRefs.current.set(tab.id, el);
else tabButtonRefs.current.delete(tab.id);
}}
type="button"
role="tab"
aria-selected={isActive}
tabIndex={isFocusable ? 0 : -1}
onClick={() => handleTabClick(tab.id)}
onKeyDown={(e) => handleTabKeyDown(e, index)}
className="flex h-full min-w-0 flex-1 cursor-pointer items-center rounded-md px-2 text-left text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
>
<span className="truncate">{tab.name}</span>
</button>
)}
{isEditMode && isActive && !isRenaming && (
<button
type="button"
onClick={() => startRename(tab)}
aria-label={t('tabs.renameButtonLabel')}
title={t('tabs.renameButtonLabel')}
className="rounded p-1 text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{pencilIcon}
</button>
)}
{isEditMode && dashboards.length > 1 && !isRenaming && (
<button
type="button"
onClick={() => setPendingDeleteId(tab.id)}
aria-label={t('tabs.deleteButtonLabel')}
title={t('tabs.deleteButtonLabel')}
className="mr-1 rounded p-1 text-muted-foreground hover:text-destructive focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{closeIcon}
</button>
)}
</div>
);
})}
</div>
{isEditMode && (
<button
type="button"
onClick={onCreate}
className="mt-0.5 flex h-8 w-full items-center justify-start gap-2 whitespace-nowrap rounded-md px-2 text-left 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"
>
<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">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
{t('tabs.add')}
</button>
)}
{dashboards.length > 1 && (
<span id={hintId} className="sr-only">
{t('tabs.dragHint')}
</span>
)}
</nav>
) : null;
// Kopfzeile, solange die Reiter in der Seitenleiste stehen: nur der Name
// des gewaehlten Dashboards, wie die anderen Seiten ihren Titel zeigen.
const activeName = dashboards.find((d) => d.id === activeDashboardId)?.name ?? '';
const headerTitle = (
<span className="truncate text-sm font-semibold text-appbar-foreground">{activeName}</span>
);
const inSidebar = vertical && slot !== null && slot !== headerSlot;
// Der Loeschdialog haengt am Dokumentkoerper, nicht in der Kopfzeile — // Der Loeschdialog haengt am Dokumentkoerper, nicht in der Kopfzeile —
// sonst saesse er in deren Stapelkontext. // sonst saesse er in deren Stapelkontext.
const dialog = pendingDeleteTab const dialog = pendingDeleteTab
@@ -542,7 +727,14 @@ export function DashboardTabs({
return ( return (
<> <>
{createPortal(bar, slot)} {inSidebar ? (
<>
{list && createPortal(list, slot)}
{headerSlot && createPortal(headerTitle, headerSlot)}
</>
) : (
createPortal(bar, slot)
)}
{dialog} {dialog}
</> </>
); );
@@ -74,7 +74,7 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
expect(WIDGET_CONSTRAINTS).toEqual({ expect(WIDGET_CONSTRAINTS).toEqual({
clock: { minW: 2, minH: 2, defaultW: 4, defaultH: 4 }, clock: { minW: 2, minH: 2, defaultW: 4, defaultH: 4 },
search: { minW: 6, minH: 2, defaultW: 12, defaultH: 4 }, search: { minW: 6, minH: 2, defaultW: 12, defaultH: 4 },
calendar: { minW: 6, minH: 8, defaultW: 8, defaultH: 12 }, calendar: { minW: 6, minH: 8, defaultW: 8, defaultH: 16 },
note: { minW: 4, minH: 4, defaultW: 6, defaultH: 8 }, note: { minW: 4, minH: 4, defaultW: 6, defaultH: 8 },
calculator: { minW: 3, minH: 10, defaultW: 6, defaultH: 10 }, calculator: { minW: 3, minH: 10, defaultW: 6, defaultH: 10 },
favorites: { minW: 1, minH: 3, defaultW: 6, defaultH: 10 }, favorites: { minW: 1, minH: 3, defaultW: 6, defaultH: 10 },
@@ -50,7 +50,9 @@ export const WIDGET_CONSTRAINTS: Record<
search: { minW: 6, minH: 2, defaultW: 12, defaultH: 4 }, search: { minW: 6, minH: 2, defaultW: 12, defaultH: 4 },
// quick-260916-htc: Monatsraster braucht Breite fuer 7 Spalten und Hoehe // quick-260916-htc: Monatsraster braucht Breite fuer 7 Spalten und Hoehe
// fuer Nav-Zeile + Wochentagskopf + 6 Rasterzeilen + Naechste-Termine-Block. // fuer Nav-Zeile + Wochentagskopf + 6 Rasterzeilen + Naechste-Termine-Block.
calendar: { minW: 6, minH: 8, defaultW: 8, defaultH: 12 }, // Runde 3 (Design „Mosaik“): defaultH 16 statt 12 — mit 12 passte unter
// den Monat nur ein Termin.
calendar: { minW: 6, minH: 8, defaultW: 8, defaultH: 16 },
note: { minW: 4, minH: 4, defaultW: 6, defaultH: 8 }, note: { minW: 4, minH: 4, defaultW: 6, defaultH: 8 },
// Phase 8 new widgets (D-01) // Phase 8 new widgets (D-01)
// minH 10, nicht 9 (260916-dyv, Browser-Messung des Orchestrators): der Rechner hat // minH 10, nicht 9 (260916-dyv, Browser-Messung des Orchestrators): der Rechner hat
@@ -283,6 +283,27 @@ export function formatEventDate(event: CalendarEvent): string {
}).format(start); }).format(start);
} }
/**
* Abstand des Terminbeginns zu heute in Kalendertagen (0 = heute,
* 1 = morgen, negativ = laeuft seit gestern oder frueher). Gezaehlt wird in
* Ortszeit, nicht in 24-Stunden-Bloecken.
*/
export function eventDayOffset(event: CalendarEvent, now: Date = new Date()): number {
const start = new Date(event.start);
const a = Date.UTC(start.getFullYear(), start.getMonth(), start.getDate());
const b = Date.UTC(now.getFullYear(), now.getMonth(), now.getDate());
return Math.round((a - b) / 86_400_000);
}
/** Kurzes Datum ohne Uhrzeit, „Sa., 03.10.“. */
export function formatEventDay(event: CalendarEvent): string {
return new Intl.DateTimeFormat('de-DE', {
weekday: 'short',
day: '2-digit',
month: '2-digit',
}).format(new Date(event.start));
}
/** Nur die Uhrzeit eines ISO-Zeitstempels, „09:05“. */ /** Nur die Uhrzeit eines ISO-Zeitstempels, „09:05“. */
export function formatEventTime(iso: string): string { export function formatEventTime(iso: string): string {
return new Intl.DateTimeFormat('de-DE', { return new Intl.DateTimeFormat('de-DE', {
@@ -409,12 +409,15 @@ describe('CalendarWidget', () => {
expect(items).toHaveLength(2); expect(items).toHaveLength(2);
expect(items[0]).toHaveTextContent('E1'); expect(items[0]).toHaveTextContent('E1');
expect(items[1]).toHaveTextContent('E2'); expect(items[1]).toHaveTextContent('E2');
expect(items[1]).toHaveTextContent('Raum 2'); // Runde 3: einzeilige Zeile, der Ort steht im Tooltip der Zeile.
expect(items[1]).toHaveAttribute('title', expect.stringContaining('Raum 2'));
for (const item of items) { for (const item of items) {
expect(within(item).getByTestId('event-color-dot')).toBeInTheDocument(); expect(within(item).getByTestId('event-color-dot')).toBeInTheDocument();
} }
expect(items[0].textContent).toMatch(/\d{2}\.\d{2}\./); // Runde 3: morgen steht als Wort (Akzentfarbe), spaetere Tage als Datum.
expect(items[0]).toHaveTextContent('calendar.tomorrow');
expect(items[1].textContent).toMatch(/\d{2}\.\d{2}\./);
}); });
it('Test 6: showMonth=false blendet das Raster aus, maxEvents=0 zeigt die Leermeldung', async () => { it('Test 6: showMonth=false blendet das Raster aus, maxEvents=0 zeigt die Leermeldung', async () => {
@@ -10,7 +10,9 @@ import {
buildCalendarDays, buildCalendarDays,
buildDayBadges, buildDayBadges,
computeFetchWindow, computeFetchWindow,
eventDayOffset,
formatEventDate, formatEventDate,
formatEventDay,
formatEventTime, formatEventTime,
formatMonthLabel, formatMonthLabel,
groupEventsByDate, groupEventsByDate,
@@ -407,30 +409,48 @@ export function CalendarWidget({ config }: WidgetProps) {
{t('calendar.emptyNoEvents')} {t('calendar.emptyNoEvents')}
</p> </p>
) : ( ) : (
<ul data-testid="calendar-upcoming" className="min-h-0 flex-1 space-y-0.5 overflow-y-auto"> // Einzeilige Terminliste (Design „Mosaik“, Runde 3): Farbstrich,
{upcoming.map((event) => ( // Tag, Titel, Uhrzeit rechts — rund 26 px je Termin statt 40, so
<li key={event.id} className="flex items-stretch gap-2.5 rounded-md px-1.5 py-1 hover:bg-muted"> // passen auch in eine kleine Kachel mehrere Termine. „Heute“ und
<span // „Morgen“ stehen in der Akzentfarbe.
data-testid="event-color-dot" <ul data-testid="calendar-upcoming" className="min-h-0 flex-1 overflow-y-auto">
className="w-[3px] shrink-0 rounded-full" {upcoming.map((event) => {
style={{ backgroundColor: event.color || 'var(--muted-foreground)' }} const offset = eventDayOffset(event);
aria-hidden="true" const dayLabel =
/> offset === 0
<div className="min-w-0 flex-1"> ? t('calendar.today')
<p className="truncate text-[11px] leading-4 text-muted-foreground"> : offset === 1
{formatEventDate(event)} ? t('calendar.tomorrow')
</p> : formatEventDay(event);
<p className="truncate text-[13px] font-medium leading-[18px] text-foreground"> const soon = offset === 0 || offset === 1;
return (
<li
key={event.id}
title={[formatEventDate(event), event.title, event.location].filter(Boolean).join(' · ')}
className="flex h-[26px] items-center gap-2 rounded-md px-1.5 hover:bg-muted"
>
<span
data-testid="event-color-dot"
className="h-3.5 w-[3px] shrink-0 rounded-full"
style={{ backgroundColor: event.color || 'var(--muted-foreground)' }}
aria-hidden="true"
/>
<span
className={`w-[62px] shrink-0 truncate text-[11px] tabular-nums ${
soon ? 'font-semibold text-primary-strong' : 'text-muted-foreground'
}`}
>
{dayLabel}
</span>
<span className="min-w-0 flex-1 truncate text-[13px] font-medium text-foreground">
{event.title} {event.title}
</p> </span>
{event.location && ( <span className="shrink-0 text-[11px] tabular-nums text-muted-foreground">
<p className="truncate text-[11px] leading-4 text-muted-foreground"> {event.allDay ? t('calendar.allDay') : formatEventTime(event.start)}
{event.location} </span>
</p> </li>
)} );
</div> })}
</li>
))}
</ul> </ul>
)} )}
</section> </section>
@@ -2,7 +2,10 @@ import type { ReactNode } from 'react';
/** /**
* Kleines einfarbiges Symbol je Kacheltyp fuer die Kachel-Kopfzeile * Kleines einfarbiges Symbol je Kacheltyp fuer die Kachel-Kopfzeile
* (Design „Mosaik“, Runde 2): 16 px, gedaempftes Grau, rein dekorativ. * (Design „Mosaik“). Seit Runde 3 als „Chip“: 16-px-Symbol auf einer
* 26-px-Flaeche, beides aus der persoenlichen Akzentfarbe abgeleitet
* (`.widget-icon-chip` in globals.css) — bringt Farbe in die Kacheln, ohne
* bunt zu werden. `plain` ist das nackte graue Symbol (Vorschlagsknoepfe).
*/ */
const PATHS: Record<string, ReactNode> = { const PATHS: Record<string, ReactNode> = {
calendar: ( calendar: (
@@ -64,10 +67,10 @@ const PATHS: Record<string, ReactNode> = {
), ),
}; };
export function WidgetIcon({ type }: { type: string }) { export function WidgetIcon({ type, variant = 'chip' }: { type: string; variant?: 'chip' | 'plain' }) {
const paths = PATHS[type]; const paths = PATHS[type];
if (!paths) return null; if (!paths) return null;
return ( const svg = (
<svg <svg
aria-hidden="true" aria-hidden="true"
data-widget-icon={type} data-widget-icon={type}
@@ -80,9 +83,15 @@ export function WidgetIcon({ type }: { type: string }) {
strokeWidth="1.8" strokeWidth="1.8"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
className="shrink-0 text-muted-foreground" className={variant === 'plain' ? 'shrink-0 text-muted-foreground' : 'shrink-0'}
> >
{paths} {paths}
</svg> </svg>
); );
if (variant === 'plain') return svg;
return (
<span aria-hidden="true" className="widget-icon-chip">
{svg}
</span>
);
} }
@@ -1,5 +1,6 @@
'use client'; 'use client';
import type { CSSProperties } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { WIDGET_REGISTRY, type WidgetType } from '../widget-registry'; import { WIDGET_REGISTRY, type WidgetType } from '../widget-registry';
import { WidgetIcon } from './widget-icon'; import { WidgetIcon } from './widget-icon';
@@ -21,6 +22,8 @@ interface WidgetWrapperProps {
}; };
isEditMode: boolean; isEditMode: boolean;
onRemove: (id: string) => void; onRemove: (id: string) => void;
/** Position im Raster — staffelt das Einblenden beim Laden (Runde 3). */
enterIndex?: number;
} }
/** /**
@@ -35,7 +38,7 @@ interface WidgetWrapperProps {
* .widgetNoDrag), entscheidet der cancel-Selektor in dashboard-grid.tsx. * .widgetNoDrag), entscheidet der cancel-Selektor in dashboard-grid.tsx.
* Renders the actual widget component via WIDGET_REGISTRY lookup. * Renders the actual widget component via WIDGET_REGISTRY lookup.
*/ */
export function WidgetWrapper({ widget, isEditMode, onRemove }: WidgetWrapperProps) { export function WidgetWrapper({ widget, isEditMode, onRemove, enterIndex = 0 }: WidgetWrapperProps) {
const t = useTranslations('widgets'); const t = useTranslations('widgets');
const definition = WIDGET_REGISTRY[widget.widgetType as WidgetType]; const definition = WIDGET_REGISTRY[widget.widgetType as WidgetType];
const WidgetComponent = definition?.component; const WidgetComponent = definition?.component;
@@ -43,12 +46,13 @@ export function WidgetWrapper({ widget, isEditMode, onRemove }: WidgetWrapperPro
// Design „Mosaik“: in der Ansicht eine ruhige Fluent-Karte (hell Schatten, // Design „Mosaik“: in der Ansicht eine ruhige Fluent-Karte (hell Schatten,
// dunkel Haarlinie), die Akzent-Umrandung nur im Bearbeitungsmodus. // dunkel Haarlinie), die Akzent-Umrandung nur im Bearbeitungsmodus.
const cardClassName = isEditMode const cardClassName = isEditMode
? 'widget-drag-handle relative h-full w-full cursor-grab overflow-hidden rounded-lg bg-card shadow-md active:cursor-grabbing' ? 'widget-card widget-drag-handle relative h-full w-full cursor-grab overflow-hidden rounded-lg bg-card shadow-md active:cursor-grabbing'
: 'relative h-full w-full overflow-hidden rounded-lg bg-card shadow-sm dark:border dark:border-border'; : 'widget-card relative h-full w-full overflow-hidden rounded-lg bg-card shadow-sm dark:border dark:border-border';
return ( return (
<article <article
className={cardClassName} className={cardClassName}
style={{ '--enter-delay': `${Math.min(enterIndex, 8) * 40}ms` } as CSSProperties}
data-widget-id={widget.id} data-widget-id={widget.id}
aria-label={definition ? t(definition.nameKey) : widget.widgetType} aria-label={definition ? t(definition.nameKey) : widget.widgetType}
> >
@@ -4,3 +4,10 @@
* zeichnen per `createPortal` hinein (heute: die Dashboard-Reiter). * zeichnen per `createPortal` hinein (heute: die Dashboard-Reiter).
*/ */
export const HEADER_CENTER_SLOT_ID = 'header-center-slot'; export const HEADER_CENTER_SLOT_ID = 'header-center-slot';
/**
* Einhaengepunkt unter „Dashboard“ in der Seitenleiste (Design „Mosaik“,
* Runde 3). Nur in der aufgeklappten Seitenleiste am Desktop vorhanden; dort
* zeichnen die Dashboard-Reiter hinein, sonst in {@link HEADER_CENTER_SLOT_ID}.
*/
export const SIDEBAR_DASHBOARD_SLOT_ID = 'sidebar-dashboard-slot';
+10 -3
View File
@@ -12,6 +12,7 @@ import { AppVersionBadge } from '@/components/layout/app-version-badge';
import { ModuleTile } from '@/components/modules/module-tile'; import { ModuleTile } from '@/components/modules/module-tile';
import { useCategoryLabel } from '@/lib/use-category-label'; import { useCategoryLabel } from '@/lib/use-category-label';
import { useNavStore } from '@/lib/stores/nav-store'; import { useNavStore } from '@/lib/stores/nav-store';
import { SIDEBAR_DASHBOARD_SLOT_ID } from '@/components/layout/header-slot';
/** Fluent-Auswahlmarke: 3 x 16 px Pille am linken Rand des gewaehlten Eintrags. */ /** Fluent-Auswahlmarke: 3 x 16 px Pille am linken Rand des gewaehlten Eintrags. */
function SelectionPill() { function SelectionPill() {
@@ -128,7 +129,10 @@ export function Sidebar() {
</svg> </svg>
); );
const sidebarContent = ( // `inDrawer`: die mobile Schublade rendert denselben Inhalt ein zweites
// Mal — der Einhaengepunkt der Dashboard-Reiter darf nur einmal im DOM
// stehen und gehoert zur Desktop-Leiste.
const renderContent = (inDrawer: boolean) => (
<div className="flex h-full flex-col bg-sidebar"> <div className="flex h-full flex-col bg-sidebar">
<nav className={`flex-1 overflow-y-auto py-3 ${isCollapsed ? 'px-2' : 'px-3'}`}> <nav className={`flex-1 overflow-y-auto py-3 ${isCollapsed ? 'px-2' : 'px-3'}`}>
<ul className="flex flex-col gap-0.5"> <ul className="flex flex-col gap-0.5">
@@ -145,6 +149,9 @@ export function Sidebar() {
)} )}
{!isCollapsed && <span className="truncate">{t('dashboard')}</span>} {!isCollapsed && <span className="truncate">{t('dashboard')}</span>}
</Link> </Link>
{/* Dashboard-Reiter (Runde 3): `DashboardTabs` zeichnet per Portal
hierher, der Punkt bleibt im React-Baum der Leiste leer. */}
{!inDrawer && !isCollapsed && <div id={SIDEBAR_DASHBOARD_SLOT_ID} />}
</li> </li>
<li> <li>
<Link href="/marketplace" className={linkClass('/marketplace')} aria-label={t('marketplace')} title={isCollapsed ? t('marketplace') : undefined}> <Link href="/marketplace" className={linkClass('/marketplace')} aria-label={t('marketplace')} title={isCollapsed ? t('marketplace') : undefined}>
@@ -275,7 +282,7 @@ export function Sidebar() {
: 'var(--sidebar-width)', : 'var(--sidebar-width)',
}} }}
> >
{sidebarContent} {renderContent(false)}
</aside> </aside>
{isMobileOpen && ( {isMobileOpen && (
@@ -307,7 +314,7 @@ export function Sidebar() {
</svg> </svg>
</button> </button>
</div> </div>
<div className="min-h-0 flex-1">{sidebarContent}</div> <div className="min-h-0 flex-1">{renderContent(true)}</div>
</aside> </aside>
</div> </div>
)} )}
@@ -1,6 +1,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { import {
BACKGROUND_PRESETS, BACKGROUND_PRESETS,
DARK_FALLBACK_ID,
loadDashboardBackground, loadDashboardBackground,
presetBackground, presetBackground,
saveDashboardBackground, saveDashboardBackground,
@@ -44,11 +45,13 @@ describe('dashboard-background (Design „Mosaik“, Prototyp)', () => {
expect(loadDashboardBackground('u1')).toEqual({ kind: 'none' }); expect(loadDashboardBackground('u1')).toEqual({ kind: 'none' });
}); });
it('jeder eingebaute Hintergrund hat eine helle und eine dunkle Fassung', () => { it('jeder eingebaute Hintergrund liefert hell und dunkel ein Bild (dunkel notfalls den Ersatz)', () => {
for (const preset of BACKGROUND_PRESETS) { for (const preset of BACKGROUND_PRESETS) {
expect(presetBackground(preset.id, false)).toBeTruthy(); expect(presetBackground(preset.id, false)).toBeTruthy();
expect(presetBackground(preset.id, true)).toBeTruthy(); expect(presetBackground(preset.id, true)).toBeTruthy();
expect(presetBackground(preset.id, false)).not.toBe(presetBackground(preset.id, true)); expect(presetBackground(preset.id, false)).not.toBe(presetBackground(preset.id, true));
} }
// Runde 3: „Bluete“ hat keine dunkle Fassung, dunkel greift der Ersatz.
expect(presetBackground('bloom', true)).toBe(presetBackground(DARK_FALLBACK_ID, true));
}); });
}); });
+16 -3
View File
@@ -75,9 +75,17 @@ export interface BackgroundPreset {
/** Uebersetzungsschluessel unter `dashboard.background.presets`. */ /** Uebersetzungsschluessel unter `dashboard.background.presets`. */
labelKey: string; labelKey: string;
light: string; light: string;
dark: string; /**
* `null` = im Dunkelmodus nicht angeboten (Runde 3). Wer den Hintergrund
* im hellen Modus gewaehlt hat, sieht im dunklen den Ersatz
* {@link DARK_FALLBACK_ID}.
*/
dark: string | null;
} }
/** Ersatz im Dunkelmodus fuer Hintergruende ohne dunkle Fassung. */
export const DARK_FALLBACK_ID: BackgroundPresetId = 'mist';
// Runde 2: nur Gelb-, Grau- und Graphittoene — keine bunten Modulfarben. // Runde 2: nur Gelb-, Grau- und Graphittoene — keine bunten Modulfarben.
export const BACKGROUND_PRESETS: BackgroundPreset[] = [ export const BACKGROUND_PRESETS: BackgroundPreset[] = [
{ id: 'mist', labelKey: 'mist', light: '#e4e9f1', dark: '#1d2330' }, { id: 'mist', labelKey: 'mist', light: '#e4e9f1', dark: '#1d2330' },
@@ -86,7 +94,10 @@ export const BACKGROUND_PRESETS: BackgroundPreset[] = [
id: 'bloom', id: 'bloom',
labelKey: 'bloom', labelKey: 'bloom',
light: bloom('#eceef1', '#f0e9c6', '#d3d7de', '#dddbd5'), light: bloom('#eceef1', '#f0e9c6', '#d3d7de', '#dddbd5'),
dark: bloom('#1b1d22', '#37342a', '#2a2e35', '#2f2e2a'), // Runde 3: im Dunkelmodus weggelassen — der weichgezeichnete Verlauf
// zwischen sehr dunklen Toenen zeigt sichtbare Farbstufen (8 Bit reichen
// dort nicht), auch mit Korn. Ersatz: DARK_FALLBACK_ID.
dark: null,
}, },
{ {
id: 'dunes', id: 'dunes',
@@ -105,7 +116,9 @@ export const BACKGROUND_PRESETS: BackgroundPreset[] = [
export function presetBackground(id: BackgroundPresetId, dark: boolean): string | null { export function presetBackground(id: BackgroundPresetId, dark: boolean): string | null {
const preset = BACKGROUND_PRESETS.find((p) => p.id === id); const preset = BACKGROUND_PRESETS.find((p) => p.id === id);
if (!preset) return null; if (!preset) return null;
return dark ? preset.dark : preset.light; if (!dark) return preset.light;
if (preset.dark !== null) return preset.dark;
return BACKGROUND_PRESETS.find((p) => p.id === DARK_FALLBACK_ID)?.dark ?? null;
} }
function storageKey(userId: string): string { function storageKey(userId: string): string {
+3 -1
View File
@@ -327,7 +327,9 @@
"saveSuccess": "Kalenderquelle gespeichert.", "saveSuccess": "Kalenderquelle gespeichert.",
"saveError": "Die Kalenderquelle konnte nicht gespeichert werden.", "saveError": "Die Kalenderquelle konnte nicht gespeichert werden.",
"deleteDialogCancel": "Löschen abbrechen", "deleteDialogCancel": "Löschen abbrechen",
"deleteDialogLabel": "Löschen bestätigen" "deleteDialogLabel": "Löschen bestätigen",
"today": "Heute",
"tomorrow": "Morgen"
}, },
"note": { "note": {
"name": "Notizen", "name": "Notizen",
+3 -1
View File
@@ -327,7 +327,9 @@
"saveSuccess": "Calendar source saved.", "saveSuccess": "Calendar source saved.",
"saveError": "The calendar source could not be saved.", "saveError": "The calendar source could not be saved.",
"deleteDialogCancel": "Cancel deletion", "deleteDialogCancel": "Cancel deletion",
"deleteDialogLabel": "Confirm deletion" "deleteDialogLabel": "Confirm deletion",
"today": "Today",
"tomorrow": "Tomorrow"
}, },
"note": { "note": {
"name": "Notes", "name": "Notes",