fix(quick-260921-pi9): Grossansicht per Portal, "1 Minute" statt "1 Minuten", Kachel-Vorgabe 8x12

Befunde aus dem Browser-Rundgang am 21.09.2026:

- Die Grossansicht lag in einem `react-grid-item` mit CSS-`transform`; ein
  transformierter Vorfahr wird fuer `position: fixed` zum Bezugsrahmen, der
  Dialog war deshalb auf die Kachelflaeche (531x216) beschraenkt statt den
  Viewport zu fuellen. Jetzt per `createPortal` in `document.body`, wie der
  Kalender-Tooltip.
- Wechselintervall "1 Minuten" -> ICU-Plural (`one {# Minute}`), de + en; der
  Formular-Test nutzt dafuer den echten `createTranslator` von next-intl auf
  der echten de.json statt eines `{n}`-Ersatzes.
- Standardgroesse 8x8 (216 px hoch) war zu flach fuer ein Foto -> 8x12 wie die
  Kalender-Vorgabe.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-21 19:10:52 +02:00
parent c3b45974f5
commit 8bf3601a18
6 changed files with 28 additions and 21 deletions
@@ -66,7 +66,7 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
calculator: { minW: 3, minH: 10, defaultW: 6, defaultH: 10 },
favorites: { minW: 3, minH: 3, defaultW: 6, defaultH: 10 },
stopwatch: { minW: 4, minH: 3, defaultW: 6, defaultH: 6 },
'picture-frame': { minW: 4, minH: 4, defaultW: 8, defaultH: 8 },
'picture-frame': { minW: 4, minH: 4, defaultW: 8, defaultH: 12 },
});
let counted = 0;
@@ -55,10 +55,11 @@ export const WIDGET_CONSTRAINTS: Record<
calculator: { minW: 3, minH: 10, defaultW: 6, defaultH: 10 },
favorites: { minW: 3, minH: 3, defaultW: 6, defaultH: 10 },
stopwatch: { minW: 4, minH: 3, defaultW: 6, defaultH: 6 },
// quick-260921-pi9: ein Bild braucht Flaeche — 8x8 ist ein Quadrat von der
// Groesse einer halben Notiz (6x8 -> 8x8), 4x4 die kleinste Kachel, in der
// Bild und Unterschrift-Streifen noch erkennbar sind.
'picture-frame': { minW: 4, minH: 4, defaultW: 8, defaultH: 8 },
// quick-260921-pi9: ein Bild braucht Flaeche — 8x12 entspricht der
// Kalender-Vorgabe (rund 530x330 px, Querformat wie ein Foto); 8x8 war im
// Browser-Rundgang mit 216 px Hoehe zu flach. 4x4 ist die kleinste Kachel,
// in der Bild und Unterschrift-Streifen noch erkennbar sind.
'picture-frame': { minW: 4, minH: 4, defaultW: 8, defaultH: 12 },
};
/**
@@ -1,6 +1,7 @@
'use client';
import { useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import { useTranslations } from 'next-intl';
interface PictureFrameLightboxProps {
@@ -21,6 +22,12 @@ interface PictureFrameLightboxProps {
* `referrerPolicy="no-referrer"` auch hier (T-PI9-09): ein Fremdhost erfaehrt
* die Tessera-Adresse nicht. Die Unterschrift ist ein React-Textknoten,
* kein HTML (T-PI9-06).
*
* Gerendert per `createPortal` in `document.body` (wie der Kalender-Tooltip):
* die Kachel liegt in einem `react-grid-item` mit CSS-`transform`, und ein
* transformierter Vorfahr macht sich fuer `position: fixed` zum Bezugsrahmen —
* ohne Portal waere die Grossansicht auf die Kachelflaeche beschraenkt
* (im Browser-Rundgang am 21.09.2026 so gesehen: Dialog 531x216 statt Viewport).
*/
export function PictureFrameLightbox({ src, caption, onClose }: PictureFrameLightboxProps) {
const t = useTranslations('widgets');
@@ -38,7 +45,9 @@ export function PictureFrameLightbox({ src, caption, onClose }: PictureFrameLigh
return () => document.removeEventListener('keydown', handleKeyDown);
}, [onClose]);
return (
if (typeof document === 'undefined') return null;
return createPortal(
<div className="fixed inset-0 z-50 flex items-center justify-center">
<button
type="button"
@@ -93,6 +102,7 @@ export function PictureFrameLightbox({ src, caption, onClose }: PictureFrameLigh
</p>
)}
</div>
</div>
</div>,
document.body,
);
}
@@ -8,19 +8,14 @@ import de from '@/messages/de.json';
* Eintragsliste mit Vorschau/Unterschrift/Pfeilen/Entfernen, Upload-Kette,
* https-Pruefung, Grenze 30.
*/
vi.mock('next-intl', async () => {
const messages = (await import('@/messages/de.json')).default as Record<string, unknown>;
const lookup = (path: string): string | undefined =>
path.split('.').reduce<unknown>((o, k) => (o && typeof o === 'object' ? (o as Record<string, unknown>)[k] : undefined), messages) as
| string
| undefined;
vi.mock('next-intl', async (importOriginal) => {
// Echter ICU-Uebersetzer von next-intl auf der echten de.json: die
// Intervall-Beschriftung nutzt `{n, plural, one {…} other {…}}`, ein
// handgeschriebener `{n}`-Ersatz wuerde das Muster nicht verstehen.
const actual = await importOriginal<typeof import('next-intl')>();
const messages = (await import('@/messages/de.json')).default;
return {
useTranslations:
(ns?: string) =>
(key: string, values?: Record<string, unknown>) => {
const raw = lookup(ns ? `${ns}.${key}` : key) ?? key;
return values ? raw.replace(/\{(\w+)\}/g, (_: string, n: string) => String(values[n] ?? '')) : raw;
},
useTranslations: (ns?: string) => actual.createTranslator({ locale: 'de', messages, namespace: ns }),
};
});
@@ -70,6 +65,7 @@ describe('PictureFrameConfigForm (quick-260921-pi9)', () => {
expect(Array.from(interval.options).map((o) => o.textContent)).toContain('Kein Wechsel');
expect(Array.from(interval.options).map((o) => o.textContent)).toContain('5 Sekunden');
expect(Array.from(interval.options).map((o) => o.textContent)).toContain('30 Minuten');
expect(Array.from(interval.options).map((o) => o.textContent)).toContain('1 Minute');
fireEvent.change(fit, { target: { value: 'cover' } });
expect(onChange).toHaveBeenLastCalledWith({ fit: 'cover' });