feat(quick-260916-bwo): Widget-Inhalt skaliert per Container-Queries (Uhr, Stoppuhr, Rechner), Punktgroesse der Uhrzeit einstellbar
- widget-wrapper.tsx: Rumpf ist Groessen-Container (@container-size) fuer cqw/cqh - clock-font-size.ts (neu): Grenzen 8..200, resolveClockTimeFontSizePt nur fuer endliche Zahlen (T-BWO-01) - clock-widget.tsx: Container-Query-Klassen mit clamp(), data-font-mode auto/fixed, Inline-pt nur bei Zahl, keine vw-Formel mehr, p-1 - stopwatch-widget.tsx: Anzeige per Container-Query, p-1.5; calculator-widget.tsx: Anzeige und Tasten skalieren, Tasten h-full min-h-7, p-1 - widget-settings-panel.tsx: Zahlenfeld Schriftgroesse der Uhrzeit (Blur/Enter, leer = automatisch, Fehlermeldung ausserhalb 8..200) - de.json/en.json: widgets.clock.fontSizeLabel/fontSizeAuto/fontSizeHint/fontSizeInvalid (Sie-Form, Waechter gruen) - Tests: Uhr +4, Stoppuhr +1, Rechner +1, widget-settings-panel.test.tsx neu (4) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018N9CD3ebPKm1b32bPpBknY
This commit is contained in:
@@ -82,4 +82,14 @@ describe('CalculatorWidget', () => {
|
||||
|
||||
expect(screen.getByLabelText('Anzeige')).toHaveTextContent('3');
|
||||
});
|
||||
|
||||
it('quick-260916-bwo: Anzeige und Tasten tragen Container-Query-Klassen, Tasten fuellen ihre Zelle', () => {
|
||||
render(<CalculatorWidget {...defaultProps} />);
|
||||
|
||||
expect(screen.getByLabelText('Anzeige').className).toMatch(/cqw/);
|
||||
const seven = screen.getByRole('button', { name: '7' });
|
||||
expect(seven.className).toMatch(/cqw/);
|
||||
expect(seven.className).toContain('h-full');
|
||||
expect(screen.getByRole('button', { name: 'CE' }).className).toMatch(/cqw/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -84,7 +84,7 @@ function CalcButton({ label, ariaLabel, className = '', onClick, disabled }: But
|
||||
aria-label={ariaLabel ?? label}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`flex items-center justify-center rounded text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${className}`}
|
||||
className={`flex items-center justify-center rounded font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${className}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
@@ -307,16 +307,21 @@ export function CalculatorWidget({ isEditMode }: WidgetProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const baseBtn = 'h-9 cursor-pointer select-none active:scale-95';
|
||||
const numBtn = `${baseBtn} bg-background hover:bg-muted border border-border text-foreground`;
|
||||
const opBtn = `${baseBtn} bg-muted hover:bg-muted/80 border border-border text-primary font-semibold`;
|
||||
const utilBtn = `${baseBtn} bg-muted/50 hover:bg-muted border border-border text-muted-foreground text-xs`;
|
||||
const eqBtn = `${baseBtn} bg-primary hover:bg-primary/90 text-primary-foreground font-semibold`;
|
||||
// quick-260916-bwo: Tasten fuellen ihre Rasterzelle (h-full) und skalieren
|
||||
// ihre Schrift per Container-Query mit der Kachel; jede Tastenart traegt genau
|
||||
// EINE Schriftgroessen-Klasse (die Basis in CalcButton hat keine mehr). Alle
|
||||
// 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 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.
|
||||
const memBtn = 'h-7 cursor-pointer select-none rounded text-xs font-medium transition-colors hover:bg-muted text-muted-foreground disabled:opacity-30 disabled:cursor-default px-1';
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-full flex-col gap-1 p-2 select-none"
|
||||
className="flex h-full flex-col gap-1 p-1 select-none"
|
||||
tabIndex={0}
|
||||
role="application"
|
||||
aria-label={t('calculator.name')}
|
||||
@@ -324,7 +329,7 @@ export function CalculatorWidget({ isEditMode }: WidgetProps) {
|
||||
>
|
||||
{/* Display */}
|
||||
<output
|
||||
className="flex min-h-[2.5rem] items-end justify-end rounded bg-muted px-3 py-1 font-mono text-xl font-semibold text-foreground overflow-hidden"
|
||||
className="flex min-h-[2.5rem] items-end justify-end rounded bg-muted px-3 py-1 font-mono text-[clamp(14px,min(8cqw,10cqh),96px)] font-semibold text-foreground overflow-hidden"
|
||||
aria-label="Anzeige"
|
||||
>
|
||||
<span className="truncate">{display}</span>
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* clock-font-size — Grenzen und Aufloesung der festen Punktgroesse der
|
||||
* Uhrzeit (quick-260916-bwo).
|
||||
*
|
||||
* Das Uhr-Widget skaliert seine Zeit standardmaessig automatisch ueber
|
||||
* CSS-Container-Queries mit der Kachelgroesse. Optional legt der Benutzer
|
||||
* unter Einstellungen -> Dashboard eine feste Schriftgroesse in Punkt fest
|
||||
* (Config-Feld `timeFontSizePt`, Zahl oder null; leer = automatisch).
|
||||
*
|
||||
* T-BWO-01: Der Wert landet als Inline-Style im Widget. Die API prueft
|
||||
* Config-Felder nicht (`@IsObject()`), deshalb entscheidet ALLEIN diese
|
||||
* Funktion, was zum Style wird: nur ein `typeof number`, endlich und im
|
||||
* Bereich MIN..MAX. Zeichenketten, NaN, Infinity und Werte ausserhalb fallen
|
||||
* auf `null` = automatisch zurueck — kein Text aus der Konfiguration erreicht
|
||||
* jemals den Style. Formular UND Widget benutzen dieselben Grenzen von hier.
|
||||
*/
|
||||
|
||||
export const CLOCK_FONT_SIZE_MIN_PT = 8;
|
||||
export const CLOCK_FONT_SIZE_MAX_PT = 200;
|
||||
/** Das Datum unter der Uhrzeit bekommt diesen Anteil der festen Punktgroesse. */
|
||||
export const CLOCK_DATE_FACTOR = 0.4;
|
||||
|
||||
export function resolveClockTimeFontSizePt(config: Record<string, unknown>): number | null {
|
||||
const value = config.timeFontSizePt;
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
return null;
|
||||
}
|
||||
if (value < CLOCK_FONT_SIZE_MIN_PT || value > CLOCK_FONT_SIZE_MAX_PT) {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
import { cleanup, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
CLOCK_FONT_SIZE_MAX_PT,
|
||||
CLOCK_FONT_SIZE_MIN_PT,
|
||||
resolveClockTimeFontSizePt,
|
||||
} from './clock-font-size';
|
||||
|
||||
// Mock next-intl
|
||||
vi.mock('next-intl', () => ({
|
||||
@@ -65,4 +70,87 @@ describe('ClockWidget', () => {
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// --- quick-260916-bwo: Skalierung per Container-Query, feste Punktgroesse ---
|
||||
// jsdom verwirft clamp()/min() in style.fontSize (gemessen) — deshalb liegt die
|
||||
// automatische Groesse in einer Tailwind-KLASSE und nur die feste Punktgroesse
|
||||
// im Inline-Style. Die Tests lesen fuer 'auto' die Klasse, fuer 'fixed' style.
|
||||
|
||||
it('Test 3 (quick-260916-bwo): resolveClockTimeFontSizePt laesst nur endliche Zahlen 8..200 durch (T-BWO-01)', () => {
|
||||
expect(resolveClockTimeFontSizePt({ timeFontSizePt: 36 })).toBe(36);
|
||||
expect(resolveClockTimeFontSizePt({ timeFontSizePt: 8 })).toBe(8);
|
||||
expect(resolveClockTimeFontSizePt({ timeFontSizePt: 200 })).toBe(200);
|
||||
expect(resolveClockTimeFontSizePt({ timeFontSizePt: 36.5 })).toBe(36.5);
|
||||
expect(resolveClockTimeFontSizePt({ timeFontSizePt: 7 })).toBeNull();
|
||||
expect(resolveClockTimeFontSizePt({ timeFontSizePt: 201 })).toBeNull();
|
||||
expect(resolveClockTimeFontSizePt({ timeFontSizePt: '36' })).toBeNull();
|
||||
expect(resolveClockTimeFontSizePt({ timeFontSizePt: Number.NaN })).toBeNull();
|
||||
expect(resolveClockTimeFontSizePt({ timeFontSizePt: Number.POSITIVE_INFINITY })).toBeNull();
|
||||
expect(resolveClockTimeFontSizePt({ timeFontSizePt: null })).toBeNull();
|
||||
expect(resolveClockTimeFontSizePt({})).toBeNull();
|
||||
expect(CLOCK_FONT_SIZE_MIN_PT).toBe(8);
|
||||
expect(CLOCK_FONT_SIZE_MAX_PT).toBe(200);
|
||||
});
|
||||
|
||||
it('Test 4 (quick-260916-bwo): feste Punktgroesse -> Inline-Style 36pt, Datum 14.4pt, data-font-mode fixed', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-06-24T12:30:00Z'));
|
||||
|
||||
const { ClockWidget } = await import('./clock-widget');
|
||||
render(
|
||||
<ClockWidget
|
||||
instanceId="clock-3"
|
||||
config={{ timezone: 'Europe/Berlin', showDate: true, timeFontSizePt: 36 }}
|
||||
isEditMode={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
const timeEl = screen.getByRole('time');
|
||||
expect(timeEl.style.fontSize).toBe('36pt');
|
||||
expect(timeEl.getAttribute('data-font-mode')).toBe('fixed');
|
||||
expect(screen.getByTestId('clock-date').style.fontSize).toBe('14.4pt');
|
||||
});
|
||||
|
||||
it('Test 5 (quick-260916-bwo): ohne Punktgroesse -> kein Inline-Style, data-font-mode auto, Container-Query-Klassen', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-06-24T12:30:00Z'));
|
||||
|
||||
const { ClockWidget } = await import('./clock-widget');
|
||||
render(
|
||||
<ClockWidget
|
||||
instanceId="clock-4"
|
||||
config={{ timezone: 'Europe/Berlin', showDate: true }}
|
||||
isEditMode={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
const timeEl = screen.getByRole('time');
|
||||
expect(timeEl.style.fontSize).toBe('');
|
||||
expect(timeEl.getAttribute('data-font-mode')).toBe('auto');
|
||||
expect(timeEl.className).toMatch(/cqw/);
|
||||
expect(timeEl.className).toMatch(/cqh/);
|
||||
const dateEl = screen.getByTestId('clock-date');
|
||||
expect(dateEl.className).toMatch(/cq[wh]/);
|
||||
expect(dateEl.style.fontSize).toBe('');
|
||||
});
|
||||
|
||||
it('Test 6 (quick-260916-bwo): Zeichenketten-Wert wird ignoriert — auto, kein Inline-Style (T-BWO-01)', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-06-24T12:30:00Z'));
|
||||
|
||||
const { ClockWidget } = await import('./clock-widget');
|
||||
render(
|
||||
<ClockWidget
|
||||
instanceId="clock-5"
|
||||
config={{ timezone: 'Europe/Berlin', showDate: true, timeFontSizePt: '36' }}
|
||||
isEditMode={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
const timeEl = screen.getByRole('time');
|
||||
expect(timeEl.style.fontSize).toBe('');
|
||||
expect(timeEl.getAttribute('data-font-mode')).toBe('auto');
|
||||
expect(timeEl.className).toMatch(/cqw/);
|
||||
expect(screen.getByTestId('clock-date').style.fontSize).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { WidgetProps } from '../widget-registry';
|
||||
import { CLOCK_DATE_FACTOR, resolveClockTimeFontSizePt } from './clock-font-size';
|
||||
|
||||
/**
|
||||
* Digital clock widget displaying time in a configurable timezone.
|
||||
@@ -10,10 +11,14 @@ import type { WidgetProps } from '../widget-registry';
|
||||
* Config:
|
||||
* - timezone: IANA timezone string (default 'Europe/Berlin')
|
||||
* - showDate: boolean (default false) — show date below time
|
||||
* - timeFontSizePt: number | null (quick-260916-bwo) — feste Schriftgroesse der
|
||||
* Uhrzeit in Punkt; leer/null = automatisch ueber Container-Queries mit der
|
||||
* Kachelgroesse. Grenzen 8..200 in `clock-font-size.ts` (T-BWO-01).
|
||||
*/
|
||||
export function ClockWidget({ config }: WidgetProps) {
|
||||
const timezone = (config.timezone as string) || 'Europe/Berlin';
|
||||
const showDate = config.showDate === true;
|
||||
const fixedPt = resolveClockTimeFontSizePt(config);
|
||||
|
||||
const [now, setNow] = useState(() => new Date());
|
||||
|
||||
@@ -39,11 +44,16 @@ export function ClockWidget({ config }: WidgetProps) {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-1 p-2">
|
||||
<div className="flex h-full flex-col items-center justify-center gap-1 p-1">
|
||||
{/* Automatisch = CSS (Container-Query-Klasse, skaliert mit der Kachel),
|
||||
fest = Zahl als Inline-Style in pt. Die Trennung ist bewusst: jsdom
|
||||
verwirft clamp() im Inline-Style, der Browser bekommt die Klasse
|
||||
ohnehin — und "auto = Klasse, fest = Zahl" ist die einfachere Form. */}
|
||||
<time
|
||||
role="time"
|
||||
className="font-semibold tabular-nums text-foreground"
|
||||
style={{ fontSize: 'clamp(28px, 4vw, 40px)' }}
|
||||
data-font-mode={fixedPt === null ? 'auto' : 'fixed'}
|
||||
className="font-semibold tabular-nums leading-none text-foreground text-[clamp(12px,min(20cqw,50cqh),400px)]"
|
||||
style={fixedPt === null ? undefined : { fontSize: `${fixedPt}pt` }}
|
||||
dateTime={now.toISOString()}
|
||||
>
|
||||
{timeFormatter.format(now)}
|
||||
@@ -51,7 +61,8 @@ export function ClockWidget({ config }: WidgetProps) {
|
||||
{showDate && (
|
||||
<span
|
||||
data-testid="clock-date"
|
||||
className="text-xs text-muted-foreground"
|
||||
className="text-muted-foreground text-[clamp(10px,min(8cqw,20cqh),160px)]"
|
||||
style={fixedPt === null ? undefined : { fontSize: `${fixedPt * CLOCK_DATE_FACTOR}pt` }}
|
||||
>
|
||||
{dateFormatter.format(now)}
|
||||
</span>
|
||||
|
||||
@@ -195,4 +195,13 @@ describe('StopwatchWidget', () => {
|
||||
const totalSeconds = minutes * 60 + seconds;
|
||||
expect(totalSeconds).toBeGreaterThanOrEqual(5);
|
||||
});
|
||||
|
||||
it('quick-260916-bwo: die Anzeige skaliert per Container-Query-Klasse, keine Fensterbreiten-Formel im Style', () => {
|
||||
render(<StopwatchWidget instanceId="sw-cq" config={{}} isEditMode={false} />);
|
||||
|
||||
const display = screen.getByTestId('stopwatch-display');
|
||||
expect(display.className).toMatch(/cqw/);
|
||||
expect(display.className).toMatch(/cqh/);
|
||||
expect(display.style.fontSize).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -189,13 +189,12 @@ export function StopwatchWidget({ instanceId, config, isEditMode: _isEditMode }:
|
||||
const isRunning = sw.state === 'running';
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden p-3">
|
||||
{/* Timer display */}
|
||||
<div className="flex h-full flex-col overflow-hidden p-1.5">
|
||||
{/* Timer display — quick-260916-bwo: skaliert per Container-Query mit der Kachel */}
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<span
|
||||
data-testid="stopwatch-display"
|
||||
className="font-mono font-semibold tabular-nums text-foreground"
|
||||
style={{ fontSize: 'clamp(24px, 5vw, 48px)' }}
|
||||
className="font-mono font-semibold tabular-nums leading-none text-foreground text-[clamp(14px,min(16cqw,35cqh),400px)]"
|
||||
>
|
||||
{formatMs(displayMs)}
|
||||
</span>
|
||||
|
||||
@@ -65,8 +65,13 @@ export function WidgetWrapper({ widget, isEditMode, onRemove }: WidgetWrapperPro
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Widget body */}
|
||||
<div className={`h-full ${isEditMode ? 'pt-0' : ''}`}>
|
||||
{/* Widget body.
|
||||
quick-260916-bwo: Groessen-Container (@container-size -> container-type: size),
|
||||
damit die Widget-Inhalte per cqw/cqh mit der Kachel skalieren. Braucht eine
|
||||
definite Hoehe — die kommt ueber h-full aus der Karte, die das RGL-Element
|
||||
mit Pixelhoehe fuellt. Steht am Rumpf statt an der Karte, weil die Karte
|
||||
im Bearbeitungsmodus zusaetzlich den Griff traegt. */}
|
||||
<div className={`@container-size h-full ${isEditMode ? 'pt-0' : ''}`}>
|
||||
{WidgetComponent ? (
|
||||
<WidgetComponent
|
||||
instanceId={widget.id}
|
||||
|
||||
Reference in New Issue
Block a user