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 && (
+
+ )}
{isRenaming ? (
) : (
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"
>
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`}
>
{t('tabs.deleteDialogTitle')}
@@ -510,7 +519,7 @@ export function DashboardTabs({
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')}
diff --git a/apps/web/src/components/dashboard/edit-mode-toggle.tsx b/apps/web/src/components/dashboard/edit-mode-toggle.tsx
index 54ab702..4021140 100644
--- a/apps/web/src/components/dashboard/edit-mode-toggle.tsx
+++ b/apps/web/src/components/dashboard/edit-mode-toggle.tsx
@@ -21,13 +21,8 @@ export function EditModeToggle({ isEditMode, onToggle }: EditModeToggleProps) {
{isEditMode ? (
@@ -35,8 +30,8 @@ export function EditModeToggle({ isEditMode, onToggle }: EditModeToggleProps) {
- ) : (
+ ) : null}
+ {isEditMode ? null : (
// Pencil icon
)}
+ {isEditMode ? t('done') : t('editShort')}
);
}
diff --git a/apps/web/src/components/dashboard/widget-registry.test.tsx b/apps/web/src/components/dashboard/widget-registry.test.tsx
index 8ac9b5f..b2d2295 100644
--- a/apps/web/src/components/dashboard/widget-registry.test.tsx
+++ b/apps/web/src/components/dashboard/widget-registry.test.tsx
@@ -74,7 +74,7 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
expect(WIDGET_CONSTRAINTS).toEqual({
clock: { minW: 2, minH: 2, defaultW: 4, 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 },
calculator: { minW: 3, minH: 10, defaultW: 6, defaultH: 10 },
favorites: { minW: 1, minH: 3, defaultW: 6, defaultH: 10 },
diff --git a/apps/web/src/components/dashboard/widget-registry.tsx b/apps/web/src/components/dashboard/widget-registry.tsx
index d7d61ca..2005e93 100644
--- a/apps/web/src/components/dashboard/widget-registry.tsx
+++ b/apps/web/src/components/dashboard/widget-registry.tsx
@@ -50,7 +50,9 @@ export const WIDGET_CONSTRAINTS: Record<
search: { minW: 6, minH: 2, defaultW: 12, defaultH: 4 },
// quick-260916-htc: Monatsraster braucht Breite fuer 7 Spalten und Hoehe
// 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 },
// Phase 8 new widgets (D-01)
// minH 10, nicht 9 (260916-dyv, Browser-Messung des Orchestrators): der Rechner hat
diff --git a/apps/web/src/components/dashboard/widgets/calculator-widget.tsx b/apps/web/src/components/dashboard/widgets/calculator-widget.tsx
index f137571..fe7e700 100644
--- a/apps/web/src/components/dashboard/widgets/calculator-widget.tsx
+++ b/apps/web/src/components/dashboard/widgets/calculator-widget.tsx
@@ -313,7 +313,7 @@ export function CalculatorWidget({ isEditMode }: WidgetProps) {
// Klassen stehen woertlich als Literale, damit der Tailwind-Scanner sie findet.
const baseBtn = 'h-full min-h-7 cursor-pointer select-none active:scale-95';
const numBtn = `${baseBtn} bg-background hover:bg-muted border border-border text-foreground text-[clamp(11px,min(4cqw,4.5cqh),40px)]`;
- const opBtn = `${baseBtn} bg-muted hover:bg-muted/80 border border-border text-primary font-semibold text-[clamp(11px,min(4cqw,4.5cqh),40px)]`;
+ const opBtn = `${baseBtn} bg-muted hover:bg-muted/80 border border-border text-foreground font-semibold text-[clamp(11px,min(4cqw,4.5cqh),40px)]`;
const utilBtn = `${baseBtn} bg-muted/50 hover:bg-muted border border-border text-muted-foreground text-[clamp(10px,min(3.2cqw,3.6cqh),32px)]`;
const eqBtn = `${baseBtn} bg-primary hover:bg-primary/90 text-primary-foreground font-semibold text-[clamp(11px,min(4cqw,4.5cqh),40px)]`;
// Speicherzeile bleibt bewusst klein (h-7 text-xs) — Nebenfunktion, kein Hauptinhalt.
diff --git a/apps/web/src/components/dashboard/widgets/calendar-month.ts b/apps/web/src/components/dashboard/widgets/calendar-month.ts
index 7d46865..8bf1015 100644
--- a/apps/web/src/components/dashboard/widgets/calendar-month.ts
+++ b/apps/web/src/components/dashboard/widgets/calendar-month.ts
@@ -283,6 +283,27 @@ export function formatEventDate(event: CalendarEvent): string {
}).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“. */
export function formatEventTime(iso: string): string {
return new Intl.DateTimeFormat('de-DE', {
diff --git a/apps/web/src/components/dashboard/widgets/calendar-widget.test.tsx b/apps/web/src/components/dashboard/widgets/calendar-widget.test.tsx
index f3e9de3..41dd182 100644
--- a/apps/web/src/components/dashboard/widgets/calendar-widget.test.tsx
+++ b/apps/web/src/components/dashboard/widgets/calendar-widget.test.tsx
@@ -169,7 +169,7 @@ describe('CalendarWidget', () => {
const badge = within(day20).getByTestId('calendar-day-count');
expect(badge).toHaveTextContent('2');
expect(badge).toHaveStyle({ backgroundColor: '#c44040' });
- expect(badge).toHaveClass('text-white');
+ expect(badge).toHaveAttribute('data-count', '2');
expect(badge).not.toHaveClass('bg-primary');
fireEvent.mouseEnter(day20);
@@ -183,7 +183,7 @@ describe('CalendarWidget', () => {
);
});
- it('Test 3c: Plakette ohne Kalenderfarbe behaelt bg-primary', async () => {
+ it('Test 3c: Terminpunkt ohne Kalenderfarbe traegt den Akzentton', async () => {
mockFetchEvents.mockResolvedValue([
ev('Ohne Farbe', new Date(2026, 6, 21, 9, 0), new Date(2026, 6, 21, 10, 0), {
color: undefined,
@@ -199,9 +199,9 @@ describe('CalendarWidget', () => {
const day21 = document.querySelector('[data-date="2026-07-21"]') as HTMLElement;
const badge = within(day21).getByTestId('calendar-day-count');
- expect(badge).toHaveClass('bg-primary');
- expect(badge).toHaveClass('text-primary-foreground');
- expect(badge).not.toHaveClass('text-white');
+ // Design „Mosaik“: Terminpunkt ohne Kalenderfarbe im abgedunkelten Akzent.
+ expect(badge).toHaveClass('bg-primary-strong');
+ expect(badge).toHaveAttribute('data-count', '1');
expect((badge as HTMLElement).style.backgroundColor).toBe('');
fireEvent.mouseEnter(day21);
@@ -240,9 +240,9 @@ describe('CalendarWidget', () => {
expect(badges[1]).toHaveTextContent('2');
expect(badges[1]).toHaveStyle({ backgroundColor: '#4060c4' });
for (const badge of badges) {
- expect(badge).toHaveClass('text-white');
+ expect(badge).toHaveClass('rounded-full');
expect(badge).not.toHaveClass('bg-primary');
- expect(badge).toHaveClass('h-[clamp(8px,2.4cqw,12px)]');
+ expect(badge).toHaveClass('h-1');
}
expect(within(day20).queryByTestId('calendar-day-count-rest')).toBeNull();
expect(within(day20).getByTestId('calendar-day-badges').childElementCount).toBe(2);
@@ -300,7 +300,7 @@ describe('CalendarWidget', () => {
const restBadge = within(day21).getByTestId('calendar-day-count-rest');
expect(restBadge).toHaveTextContent('4');
expect(restBadge).toHaveClass('bg-muted-foreground');
- expect(restBadge).toHaveClass('text-background');
+ expect(restBadge).toHaveAttribute('data-count', '4');
expect((restBadge as HTMLElement).style.backgroundColor).toBe('');
expect(restBadge).not.toHaveClass('text-white');
});
@@ -409,12 +409,15 @@ describe('CalendarWidget', () => {
expect(items).toHaveLength(2);
expect(items[0]).toHaveTextContent('E1');
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) {
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 () => {
diff --git a/apps/web/src/components/dashboard/widgets/calendar-widget.tsx b/apps/web/src/components/dashboard/widgets/calendar-widget.tsx
index fb4a596..c3b11b9 100644
--- a/apps/web/src/components/dashboard/widgets/calendar-widget.tsx
+++ b/apps/web/src/components/dashboard/widgets/calendar-widget.tsx
@@ -10,7 +10,9 @@ import {
buildCalendarDays,
buildDayBadges,
computeFetchWindow,
+ eventDayOffset,
formatEventDate,
+ formatEventDay,
formatEventTime,
formatMonthLabel,
groupEventsByDate,
@@ -243,21 +245,23 @@ export function CalendarWidget({ config }: WidgetProps) {
const hoverEvents = hover ? (eventsByDate.get(hover.key) ?? []) : [];
return (
-
+
{showMonth && (
- {/* Nav-Zeile */}
-
+ {/* Nav-Zeile: Pfeilknoepfe links/rechts, Monatsname mittig */}
+
- {t('calendar.monthPrev')}
+
@@ -265,31 +269,35 @@ export function CalendarWidget({ config }: WidgetProps) {
- {t('calendar.monthNext')}
+
{/* Wochentagskopf */}
-
+
{WEEKDAY_LABELS.map((label) => (
{label}
))}
{/* Raster */}
-
+
{days.map((day) => {
const hasEvents = day.events.length > 0;
const badges = buildDayBadges(day.events);
- const single = badges.length === 1;
+ // Tag als Kreis (Design „Mosaik“): heute gelb gefuellt, Termine
+ // als kleine Punkte unter der Zahl — je Kalenderquelle einer,
+ // in deren Farbe; ohne Farbe im Akzentton, Rest grau.
const cellClass = [
- 'widgetNoDrag relative flex min-h-[clamp(16px,5.5cqh,40px)] items-start rounded bg-muted/50 px-1 py-0.5 text-[clamp(9px,2.4cqw,13px)] leading-none',
- day.inCurrentMonth ? 'text-foreground' : 'text-muted-foreground/60',
- day.isToday ? 'ring-1 ring-primary font-semibold text-primary' : '',
- hasEvents ? 'cursor-default hover:bg-muted' : '',
+ 'widgetNoDrag relative mx-auto flex h-[clamp(20px,7cqw,28px)] w-[clamp(20px,7cqw,28px)] items-center justify-center rounded-full text-[clamp(11px,2.9cqw,13px)] leading-none tabular-nums',
+ day.inCurrentMonth ? 'text-foreground' : 'text-muted-foreground/70',
+ day.isToday ? 'bg-primary font-semibold text-primary-foreground' : '',
+ hasEvents && !day.isToday ? 'cursor-default font-medium hover:bg-muted' : '',
]
.filter(Boolean)
.join(' ');
@@ -298,23 +306,20 @@ export function CalendarWidget({ config }: WidgetProps) {
<>
{day.date.getDate()}
{hasEvents && (
- // Wrapper traegt die Positionierung, die einzelnen Kreise nicht
- // mehr (mehrere Kreise stehen sonst uebereinander).
{badges.map((badge) => {
const badgeClass = [
- 'flex items-center justify-center rounded-full px-0.5 font-semibold leading-none',
- single
- ? 'h-[clamp(10px,3cqw,16px)] min-w-[clamp(10px,3cqw,16px)] text-[clamp(7px,1.8cqw,10px)]'
- : 'h-[clamp(8px,2.4cqw,12px)] min-w-[clamp(8px,2.4cqw,12px)] text-[clamp(6px,1.5cqw,8px)]',
+ 'block h-1 w-1 rounded-full',
badge.rest
- ? 'bg-muted-foreground text-background'
+ ? 'bg-muted-foreground'
: badge.color
- ? 'text-white'
- : 'bg-primary text-primary-foreground',
+ ? ''
+ : day.isToday
+ ? 'bg-primary-foreground'
+ : 'bg-primary-strong',
]
.filter(Boolean)
.join(' ');
@@ -322,10 +327,11 @@ export function CalendarWidget({ config }: WidgetProps) {
- {badge.count}
+ {badge.count}
);
})}
@@ -360,7 +366,7 @@ export function CalendarWidget({ config }: WidgetProps) {
data-date={day.key}
data-today={day.isToday || undefined}
data-outside={!day.inCurrentMonth || undefined}
- className={`${cellClass} w-full text-left`}
+ className={cellClass}
aria-label={ariaLabel}
onMouseEnter={(e) =>
setHover({ key: day.key, rect: e.currentTarget.getBoundingClientRect() })
@@ -395,7 +401,7 @@ export function CalendarWidget({ config }: WidgetProps) {
{maxEvents > 0 && (
-
+
{t('calendar.upcomingTitle')}
{upcoming.length === 0 ? (
@@ -403,30 +409,48 @@ export function CalendarWidget({ config }: WidgetProps) {
{t('calendar.emptyNoEvents')}
) : (
-
- {upcoming.map((event) => (
-
-
-
-
- {formatEventDate(event)}
-
-
+ // Einzeilige Terminliste (Design „Mosaik“, Runde 3): Farbstrich,
+ // Tag, Titel, Uhrzeit rechts — rund 26 px je Termin statt 40, so
+ // passen auch in eine kleine Kachel mehrere Termine. „Heute“ und
+ // „Morgen“ stehen in der Akzentfarbe.
+
+ {upcoming.map((event) => {
+ const offset = eventDayOffset(event);
+ const dayLabel =
+ offset === 0
+ ? t('calendar.today')
+ : offset === 1
+ ? t('calendar.tomorrow')
+ : formatEventDay(event);
+ const soon = offset === 0 || offset === 1;
+ return (
+
+
+
+ {dayLabel}
+
+
{event.title}
-
- {event.location && (
-
- {event.location}
-
- )}
-
-
- ))}
+
+
+ {event.allDay ? t('calendar.allDay') : formatEventTime(event.start)}
+
+
+ );
+ })}
)}
@@ -438,7 +462,7 @@ export function CalendarWidget({ config }: WidgetProps) {
{/* Header: optional title (quick-260916-iex) — mirrors note-widget.tsx look */}
{showHeader && (
-
+
+
{isEditMode ? (
)}
-
+
{/* View mode toggle (edit mode only) */}
{isEditMode && (
// role="toolbar" (D-03): die bisherige Beschriftung ("Favoriten")
@@ -359,8 +361,8 @@ export function FavoritesWidget({
onClick={() => handleViewMode('list')}
className={`px-2 py-1 text-xs rounded ${
viewMode === 'list'
- ? 'bg-primary text-primary-foreground'
- : 'bg-muted text-muted-foreground'
+ ? 'bg-foreground text-background'
+ : 'bg-muted text-muted-foreground hover:text-foreground'
}`}
>
{t('favorites.listView')}
@@ -370,8 +372,8 @@ export function FavoritesWidget({
onClick={() => handleViewMode('grid')}
className={`px-2 py-1 text-xs rounded ${
viewMode === 'grid'
- ? 'bg-primary text-primary-foreground'
- : 'bg-muted text-muted-foreground'
+ ? 'bg-foreground text-background'
+ : 'bg-muted text-muted-foreground hover:text-foreground'
}`}
>
{t('favorites.gridView')}
@@ -396,10 +398,11 @@ export function FavoritesWidget({
viewMode === 'grid' ? (
{sortedFavorites.map((fav, index) => (
{sortedFavorites.map((fav, index) => (
string;
+ /** Kachelansicht: 40-px-Feld mit 24-px-Symbol, sonst 24-px-Feld mit 16-px-Symbol. */
+ large?: boolean;
}) {
+ const box = large ? 'h-10 w-10 rounded-lg' : 'h-6 w-6 rounded-md';
+ const img = large ? 'h-6 w-6' : 'h-4 w-4';
+ const px = large ? 24 : 16;
const hasServerIcon = Boolean(fav.iconUrl) || Boolean(fav.uploadedIconMime);
const proxySrc = hasServerIcon
? `/api-proxy/favorites/${encodeURIComponent(fav.id)}/icon?v=${fav.iconVersion ?? 0}`
@@ -572,10 +581,10 @@ function FavoriteIcon({
);
return (
-
+
{getFallbackLetter(fav.title)}
@@ -585,10 +594,10 @@ function FavoriteIcon({
src={proxySrc}
alt=""
aria-hidden="true"
- width={20}
- height={20}
+ width={px}
+ height={px}
loading="lazy"
- className="absolute inset-0 w-5 h-5 rounded"
+ className={`relative rounded-sm ${img}`}
onError={() => setStage('direct')}
/>
)}
@@ -598,11 +607,11 @@ function FavoriteIcon({
src={directSrc}
alt=""
aria-hidden="true"
- width={20}
- height={20}
+ width={px}
+ height={px}
loading="lazy"
referrerPolicy="no-referrer"
- className="absolute inset-0 w-5 h-5 rounded"
+ className={`relative rounded-sm ${img}`}
onError={() => setStage('none')}
/>
)}
@@ -638,6 +647,8 @@ type FavoriteTileProps = {
canMoveDown: boolean;
onMove: (id: string, direction: 'up' | 'down') => void;
t: (key: string) => string;
+ /** Kachelansicht wie ein App-Starter (Symbol oben, Name darunter). */
+ launcher?: boolean;
};
function FavoriteTile({
@@ -663,19 +674,24 @@ function FavoriteTile({
canMoveDown,
onMove,
t,
+ launcher = false,
}: FavoriteTileProps) {
const isEditing = editingId === fav.id;
return (
{/* Main link row */}
-
+
{/* Anchor — opens in new tab, no server-side redirect (T-08-08) */}
isEditMode && e.preventDefault()} // block navigation in edit mode
>
{/* Icon container with letter fallback (T-08-07 — no dangerouslySetInnerHTML) */}
@@ -683,9 +699,12 @@ function FavoriteTile({
key={`${fav.iconUrl ?? ''}|${fav.url}|${fav.iconVersion ?? 0}|${fav.uploadedIconMime ?? ''}`}
fav={fav}
getFallbackLetter={getFallbackLetter}
+ large={launcher}
/>
- {fav.title}
+
+ {fav.title}
+
{/* Action buttons (edit mode only, D-04) */}
diff --git a/apps/web/src/components/dashboard/widgets/note-widget.tsx b/apps/web/src/components/dashboard/widgets/note-widget.tsx
index 23bbee1..021aee4 100644
--- a/apps/web/src/components/dashboard/widgets/note-widget.tsx
+++ b/apps/web/src/components/dashboard/widgets/note-widget.tsx
@@ -1,5 +1,6 @@
'use client';
+import { WidgetIcon } from './widget-icon';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslations } from 'next-intl';
import { useTheme } from 'next-themes';
@@ -163,7 +164,8 @@ export function NoteWidget({ instanceId, config }: WidgetProps) {
return (
{/* Header: title + pencil toggle */}
-
+
+
setIsEditing((v) => !v)}
- className={`flex h-6 w-6 shrink-0 items-center justify-center rounded transition-colors hover:bg-muted ${isEditing ? 'text-destructive' : 'text-muted-foreground'}`}
+ className={`widget-action flex h-7 w-7 shrink-0 items-center justify-center rounded-md transition-colors hover:bg-muted ${isEditing ? 'widget-action-pinned bg-muted text-foreground' : 'text-muted-foreground hover:text-foreground'}`}
title={isEditing ? t('note.viewMode') : t('note.editMode')}
>
diff --git a/apps/web/src/components/dashboard/widgets/proxmox-widget.tsx b/apps/web/src/components/dashboard/widgets/proxmox-widget.tsx
index 1b1d572..105469c 100644
--- a/apps/web/src/components/dashboard/widgets/proxmox-widget.tsx
+++ b/apps/web/src/components/dashboard/widgets/proxmox-widget.tsx
@@ -1,5 +1,6 @@
'use client';
+import { WidgetIcon } from './widget-icon';
import Link from 'next/link';
import { useLocale, useTranslations } from 'next-intl';
import { type ChangeEvent, type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
@@ -162,7 +163,8 @@ export function ProxmoxWidget({ instanceId, config, isEditMode }: WidgetProps) {
let header: ReactNode = null;
if (isEditMode) {
header = (
-
+
+
+
+
{trimmedTitle}
);
diff --git a/apps/web/src/components/dashboard/widgets/search-widget.tsx b/apps/web/src/components/dashboard/widgets/search-widget.tsx
index 6daeca1..209479d 100644
--- a/apps/web/src/components/dashboard/widgets/search-widget.tsx
+++ b/apps/web/src/components/dashboard/widgets/search-widget.tsx
@@ -113,7 +113,7 @@ export function SearchWidget({ instanceId, config }: WidgetProps) {
{/* Search button (right) */}
diff --git a/apps/web/src/components/dashboard/widgets/widget-icon.tsx b/apps/web/src/components/dashboard/widgets/widget-icon.tsx
new file mode 100644
index 0000000..c8664b1
--- /dev/null
+++ b/apps/web/src/components/dashboard/widgets/widget-icon.tsx
@@ -0,0 +1,97 @@
+import type { ReactNode } from 'react';
+
+/**
+ * Kleines einfarbiges Symbol je Kacheltyp fuer die Kachel-Kopfzeile
+ * (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 = {
+ calendar: (
+ <>
+
+
+ >
+ ),
+ note: (
+ <>
+
+
+ >
+ ),
+ favorites: ,
+ clock: (
+ <>
+
+
+ >
+ ),
+ search: (
+ <>
+
+
+ >
+ ),
+ calculator: (
+ <>
+
+
+ >
+ ),
+ stopwatch: (
+ <>
+
+
+ >
+ ),
+ 'picture-frame': (
+ <>
+
+
+
+ >
+ ),
+ xframe: (
+ <>
+
+
+ >
+ ),
+ proxmox: (
+ <>
+
+
+
+ >
+ ),
+};
+
+export function WidgetIcon({ type, variant = 'chip' }: { type: string; variant?: 'chip' | 'plain' }) {
+ const paths = PATHS[type];
+ if (!paths) return null;
+ const svg = (
+
+ {paths}
+
+ );
+ if (variant === 'plain') return svg;
+ return (
+
+ {svg}
+
+ );
+}
diff --git a/apps/web/src/components/dashboard/widgets/widget-wrapper.tsx b/apps/web/src/components/dashboard/widgets/widget-wrapper.tsx
index c6f1500..9df5fd3 100644
--- a/apps/web/src/components/dashboard/widgets/widget-wrapper.tsx
+++ b/apps/web/src/components/dashboard/widgets/widget-wrapper.tsx
@@ -1,7 +1,18 @@
'use client';
+import type { CSSProperties } from 'react';
import { useTranslations } from 'next-intl';
import { WIDGET_REGISTRY, type WidgetType } from '../widget-registry';
+import { WidgetIcon } from './widget-icon';
+
+/**
+ * Kacheltypen ohne eigene Kopfzeile, denen der Rahmen eine einheitliche
+ * Kopfzeile (Symbol + Name) gibt (Design „Mosaik“, Runde 2). Notiz,
+ * Favoriten, Proxmox und XFrame bringen ihre eigene (mit editierbarem Titel)
+ * mit; Suche und Bilderrahmen bleiben bewusst randlos — ein Suchfeld bzw.
+ * ein Foto braucht keine Ueberschrift.
+ */
+const FRAME_HEADER_TYPES = new Set(['calendar', 'clock', 'calculator', 'stopwatch']);
interface WidgetWrapperProps {
widget: {
@@ -11,6 +22,8 @@ interface WidgetWrapperProps {
};
isEditMode: boolean;
onRemove: (id: string) => void;
+ /** Position im Raster — staffelt das Einblenden beim Laden (Runde 3). */
+ enterIndex?: number;
}
/**
@@ -25,18 +38,21 @@ interface WidgetWrapperProps {
* .widgetNoDrag), entscheidet der cancel-Selektor in dashboard-grid.tsx.
* 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 definition = WIDGET_REGISTRY[widget.widgetType as WidgetType];
const WidgetComponent = definition?.component;
+ // Design „Mosaik“: in der Ansicht eine ruhige Fluent-Karte (hell Schatten,
+ // dunkel Haarlinie), die Akzent-Umrandung nur im Bearbeitungsmodus.
const cardClassName = isEditMode
- ? 'widget-drag-handle relative h-full w-full cursor-grab overflow-hidden rounded-lg border border-primary/40 bg-card shadow-sm active:cursor-grabbing'
- : 'relative h-full w-full overflow-hidden rounded-lg border border-primary/20 bg-card shadow-sm';
+ ? 'widget-card widget-drag-handle relative h-full w-full cursor-grab overflow-hidden rounded-lg bg-card shadow-md active:cursor-grabbing'
+ : 'widget-card relative h-full w-full overflow-hidden rounded-lg bg-card shadow-sm dark:border dark:border-border';
return (
@@ -45,7 +61,7 @@ export function WidgetWrapper({ widget, isEditMode, onRemove }: WidgetWrapperPro
Hoehenkette Karte -> Rumpf bleibt definit (cqh loest weiter auf). */}
{isEditMode && (
@@ -103,7 +119,14 @@ export function WidgetWrapper({ widget, isEditMode, onRemove }: WidgetWrapperPro
im Bearbeitungsmodus zusaetzlich den Griff traegt.
quick-260916-dyv: Die Kopfleiste liegt als Overlay ueber dem Rumpf und
aendert die Hoehenkette nicht. */}
-
+
+ {WidgetComponent && definition && FRAME_HEADER_TYPES.has(widget.widgetType) && (
+
+
+
{t(definition.nameKey)}
+
+ )}
+
{WidgetComponent ? (
)}
+
);
}
diff --git a/apps/web/src/components/dashboard/widgets/xframe-widget.test.tsx b/apps/web/src/components/dashboard/widgets/xframe-widget.test.tsx
index 261441c..2f9d19c 100644
--- a/apps/web/src/components/dashboard/widgets/xframe-widget.test.tsx
+++ b/apps/web/src/components/dashboard/widgets/xframe-widget.test.tsx
@@ -84,12 +84,12 @@ describe('XframeWidget (quick-260921-qd3)', () => {
expect(link.className).toContain('absolute');
});
- it('Test 5: mit Titel ->
Board,