feat(web): Dashboard-Kacheln im Fluent-Stil und waehlbarer Hintergrund
Einheitliche Kachel-Kopfzeile, ruhigere Notiz-/Kalender-/Favoriten- Darstellung, 12 px Raster-Abstand, gestrichelte Kontur und Griff im Bearbeitungsmodus. Neuer Knopf Hintergrund: Keiner, fuenf eingebaute (Nebel, Salbei, Bluete, Duenen, Mosaik) oder eigenes Bild aus den Bilderrahmen-Bildern; Prototyp speichert im localStorage je Benutzer. Mit Hintergrund werden Kacheln Mica-artig durchscheinend (Kontrast >= 4,5:1 auch im schlechtesten Fall). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -243,21 +243,21 @@ export function CalendarWidget({ config }: WidgetProps) {
|
||||
const hoverEvents = hover ? (eventsByDate.get(hover.key) ?? []) : [];
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-1 overflow-hidden p-1.5">
|
||||
<div className="flex h-full flex-col gap-2 overflow-hidden p-3">
|
||||
{showMonth && (
|
||||
<div data-testid="calendar-month" className="flex shrink-0 flex-col gap-1">
|
||||
{/* Nav-Zeile */}
|
||||
<div className="grid grid-cols-[1fr_1.4fr_1fr] gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className="widgetNoDrag rounded border border-border bg-muted/50 px-1 py-[clamp(2px,0.8cqh,6px)] text-[clamp(10px,2.6cqw,13px)] leading-none text-foreground hover:bg-muted"
|
||||
className="widgetNoDrag rounded-md px-1 py-[clamp(3px,0.9cqh,6px)] text-[clamp(10px,2.6cqw,13px)] leading-none text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
onClick={showPrev}
|
||||
>
|
||||
{t('calendar.monthPrev')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="widgetNoDrag truncate rounded border border-border bg-muted/50 px-1 py-[clamp(2px,0.8cqh,6px)] text-[clamp(10px,2.6cqw,13px)] font-semibold leading-none text-foreground hover:bg-muted"
|
||||
className="widgetNoDrag truncate rounded-md px-1 py-[clamp(3px,0.9cqh,6px)] text-[clamp(11px,2.8cqw,14px)] font-semibold leading-none text-foreground transition-colors hover:bg-muted"
|
||||
title={t('calendar.monthToday')}
|
||||
onClick={showToday}
|
||||
>
|
||||
@@ -265,7 +265,7 @@ export function CalendarWidget({ config }: WidgetProps) {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="widgetNoDrag rounded border border-border bg-muted/50 px-1 py-[clamp(2px,0.8cqh,6px)] text-[clamp(10px,2.6cqw,13px)] leading-none text-foreground hover:bg-muted"
|
||||
className="widgetNoDrag rounded-md px-1 py-[clamp(3px,0.9cqh,6px)] text-[clamp(10px,2.6cqw,13px)] leading-none text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
onClick={showNext}
|
||||
>
|
||||
{t('calendar.monthNext')}
|
||||
@@ -286,9 +286,9 @@ export function CalendarWidget({ config }: WidgetProps) {
|
||||
const badges = buildDayBadges(day.events);
|
||||
const single = badges.length === 1;
|
||||
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' : '',
|
||||
'widgetNoDrag relative flex min-h-[clamp(16px,5.5cqh,40px)] items-start rounded-md px-1 py-0.5 text-[clamp(9px,2.4cqw,13px)] leading-none',
|
||||
day.inCurrentMonth ? 'text-foreground' : 'text-muted-foreground/70',
|
||||
day.isToday ? 'bg-primary font-semibold text-primary-foreground' : '',
|
||||
hasEvents ? 'cursor-default hover:bg-muted' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
@@ -405,7 +405,7 @@ export function CalendarWidget({ config }: WidgetProps) {
|
||||
) : (
|
||||
<ul data-testid="calendar-upcoming" className="min-h-0 flex-1 space-y-1 overflow-y-auto">
|
||||
{upcoming.map((event) => (
|
||||
<li key={event.id} className="flex items-start gap-2 rounded bg-muted/50 px-2 py-1">
|
||||
<li key={event.id} className="flex items-start gap-2 rounded-md px-2 py-1 hover:bg-muted">
|
||||
<span
|
||||
data-testid="event-color-dot"
|
||||
className="mt-1 h-2 w-2 shrink-0 rounded-full"
|
||||
@@ -416,7 +416,7 @@ export function CalendarWidget({ config }: WidgetProps) {
|
||||
<p className="truncate text-[clamp(9px,2.2cqw,12px)] text-muted-foreground">
|
||||
{formatEventDate(event)}
|
||||
</p>
|
||||
<p className="truncate text-[clamp(10px,2.5cqw,14px)] font-semibold text-foreground">
|
||||
<p className="truncate text-[clamp(10px,2.5cqw,14px)] font-medium text-foreground">
|
||||
{event.title}
|
||||
</p>
|
||||
{event.location && (
|
||||
@@ -438,7 +438,7 @@ export function CalendarWidget({ config }: WidgetProps) {
|
||||
<div
|
||||
data-testid="calendar-day-tooltip"
|
||||
role="tooltip"
|
||||
className="pointer-events-none fixed z-50 rounded border border-border bg-card p-2 text-xs text-foreground shadow-lg"
|
||||
className="pointer-events-none fixed z-50 rounded bg-card dark:border dark:border-border p-2 text-xs text-foreground shadow-lg"
|
||||
style={{
|
||||
width: TOOLTIP_WIDTH_PX,
|
||||
top: hover.rect.bottom + TOOLTIP_EDGE_PX,
|
||||
|
||||
@@ -321,7 +321,7 @@ export function FavoritesWidget({
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
{/* Header: optional title (quick-260916-iex) — mirrors note-widget.tsx look */}
|
||||
{showHeader && (
|
||||
<div className="flex items-center gap-2 border-b border-border px-1.5 py-1.5">
|
||||
<div className="widget-head">
|
||||
{isEditMode ? (
|
||||
<input
|
||||
type="text"
|
||||
@@ -341,7 +341,7 @@ export function FavoritesWidget({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-1 flex-col gap-2 overflow-auto p-1">
|
||||
<div className={`flex flex-1 flex-col gap-2 overflow-auto px-2 pb-2 ${showHeader ? '' : 'pt-2'}`}>
|
||||
{/* View mode toggle (edit mode only) */}
|
||||
{isEditMode && (
|
||||
// role="toolbar" (D-03): die bisherige Beschriftung ("Favoriten")
|
||||
@@ -359,8 +359,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 +370,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')}
|
||||
@@ -675,7 +675,7 @@ function FavoriteTile({
|
||||
href={fav.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex items-center gap-2 flex-1 min-w-0 hover:underline widgetNoDrag"
|
||||
className="flex h-8 min-w-0 flex-1 items-center gap-2.5 rounded-md px-1.5 transition-colors hover:bg-muted widgetNoDrag"
|
||||
onClick={(e) => isEditMode && e.preventDefault()} // block navigation in edit mode
|
||||
>
|
||||
{/* Icon container with letter fallback (T-08-07 — no dangerouslySetInnerHTML) */}
|
||||
@@ -685,7 +685,7 @@ function FavoriteTile({
|
||||
getFallbackLetter={getFallbackLetter}
|
||||
/>
|
||||
|
||||
<span className="text-xs truncate">{fav.title}</span>
|
||||
<span className="truncate text-sm">{fav.title}</span>
|
||||
</a>
|
||||
|
||||
{/* Action buttons (edit mode only, D-04) */}
|
||||
|
||||
@@ -163,7 +163,7 @@ export function NoteWidget({ instanceId, config }: WidgetProps) {
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden" data-color-mode={colorMode}>
|
||||
{/* Header: title + pencil toggle */}
|
||||
<div className="relative flex items-center border-b border-border px-1.5 py-1.5 gap-2">
|
||||
<div className="widget-head relative">
|
||||
<input
|
||||
type="text"
|
||||
className="flex-1 bg-transparent text-sm font-semibold text-foreground outline-none placeholder:text-muted-foreground"
|
||||
@@ -180,7 +180,7 @@ export function NoteWidget({ instanceId, config }: WidgetProps) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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')}
|
||||
>
|
||||
<svg
|
||||
@@ -203,7 +203,7 @@ export function NoteWidget({ instanceId, config }: WidgetProps) {
|
||||
|
||||
{/* Content: preview or editor */}
|
||||
<div
|
||||
className="flex-1 overflow-auto"
|
||||
className="widget-note-body flex-1 overflow-auto"
|
||||
data-testid="note-preview"
|
||||
ref={previewRef}
|
||||
>
|
||||
|
||||
@@ -162,7 +162,7 @@ export function ProxmoxWidget({ instanceId, config, isEditMode }: WidgetProps) {
|
||||
let header: ReactNode = null;
|
||||
if (isEditMode) {
|
||||
header = (
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-border px-2.5 py-1.5">
|
||||
<div className="widget-head">
|
||||
<input
|
||||
type="text"
|
||||
className="widgetNoDrag min-w-0 flex-1 bg-transparent text-sm font-semibold text-foreground outline-none placeholder:text-muted-foreground"
|
||||
@@ -186,7 +186,7 @@ export function ProxmoxWidget({ instanceId, config, isEditMode }: WidgetProps) {
|
||||
);
|
||||
} else if (trimmedTitle !== '') {
|
||||
header = (
|
||||
<div className="shrink-0 border-b border-border px-2.5 py-1.5">
|
||||
<div className="widget-head">
|
||||
<h2 className="truncate text-sm font-semibold text-foreground">{trimmedTitle}</h2>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -113,7 +113,7 @@ export function SearchWidget({ instanceId, config }: WidgetProps) {
|
||||
{/* Search button (right) */}
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-8 shrink-0 items-center justify-center rounded bg-primary px-3 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
className="btn btn-primary shrink-0"
|
||||
onClick={handleSearch}
|
||||
aria-label={t('search.searchButton')}
|
||||
>
|
||||
|
||||
@@ -30,9 +30,11 @@ export function WidgetWrapper({ widget, isEditMode, onRemove }: WidgetWrapperPro
|
||||
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-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';
|
||||
|
||||
return (
|
||||
<article
|
||||
@@ -45,7 +47,7 @@ export function WidgetWrapper({ widget, isEditMode, onRemove }: WidgetWrapperPro
|
||||
Hoehenkette Karte -> Rumpf bleibt definit (cqh loest weiter auf). */}
|
||||
{isEditMode && (
|
||||
<div
|
||||
className="absolute inset-x-0 top-0 z-10 flex h-5 items-center justify-center bg-muted/70"
|
||||
className="absolute inset-x-0 top-0 z-10 flex h-5 items-center justify-center bg-muted/80"
|
||||
title={t('dragHint')}
|
||||
data-testid="widget-drag-head"
|
||||
>
|
||||
|
||||
@@ -84,12 +84,12 @@ describe('XframeWidget (quick-260921-qd3)', () => {
|
||||
expect(link.className).toContain('absolute');
|
||||
});
|
||||
|
||||
it('Test 5: mit Titel -> <h2> Board, <iframe title="Board">, Link in der Kopfleiste (Vorfahre border-b), nicht absolute', () => {
|
||||
it('Test 5: mit Titel -> <h2> Board, <iframe title="Board">, Link in der Kopfleiste (Vorfahre widget-head), nicht absolute', () => {
|
||||
const { container } = renderWidget({ url: URL_A, title: 'Board' });
|
||||
expect(container.querySelector('h2')).toHaveTextContent('Board');
|
||||
expect(frame().getAttribute('title')).toBe('Board');
|
||||
const link = screen.getByRole('link', { name: 'xframe.openInNewTab' });
|
||||
expect(link.closest('.border-b')).not.toBeNull();
|
||||
expect(link.closest('.widget-head')).not.toBeNull();
|
||||
expect(link.className).not.toContain('absolute');
|
||||
});
|
||||
|
||||
|
||||
@@ -119,12 +119,12 @@ export function XframeWidget({ config, isEditMode }: WidgetProps) {
|
||||
// Bekannt und akzeptiert (wie die Titelzeile der Favoriten): im
|
||||
// Bearbeitungsmodus ueberdeckt die 20-px-Griffleiste der Karte den
|
||||
// oberen Teil der Kopfleiste — Bearbeiten ist Anordnen, nicht Lesen.
|
||||
<div className="flex items-center gap-2 border-b border-border px-1.5 py-1.5">
|
||||
<div className="widget-head">
|
||||
<h2 className="min-w-0 flex-1 truncate text-sm font-semibold text-foreground">{title}</h2>
|
||||
<NewTabLink
|
||||
url={url}
|
||||
label={openLabel}
|
||||
className="shrink-0 rounded p-0.5 text-muted-foreground hover:text-foreground"
|
||||
className="widget-action shrink-0 rounded-md p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user