diff --git a/apps/web/src/components/changelog/changelog-view.tsx b/apps/web/src/components/changelog/changelog-view.tsx
index f749698..e34677f 100644
--- a/apps/web/src/components/changelog/changelog-view.tsx
+++ b/apps/web/src/components/changelog/changelog-view.tsx
@@ -15,8 +15,19 @@ import { useTheme } from 'next-themes';
* uebereinstimmen (mounted-Guard wie in AppShell). Der Markdown-Text kommt
* als Prop von der Server-Seite; dieses Modul importiert `@/lib/changelog`
* bewusst NICHT, damit der Text nicht in Client-Chunks landet.
+ *
+ * quick-260925-bow: `variant="plain"` laesst Rahmen, Hintergrund und
+ * Innenabstand weg — so rendert das "Was ist neu"-Fenster seine Abschnitte mit
+ * demselben Renderer wie die Seite /changelog (Vorgabe `card`, Seite
+ * unveraendert).
*/
-export function ChangelogView({ markdown }: { markdown: string }) {
+export function ChangelogView({
+ markdown,
+ variant = 'card',
+}: {
+ markdown: string;
+ variant?: 'card' | 'plain';
+}) {
const { resolvedTheme } = useTheme();
const [mounted, setMounted] = useState(false);
@@ -27,7 +38,10 @@ export function ChangelogView({ markdown }: { markdown: string }) {
const mode: 'light' | 'dark' = mounted && resolvedTheme === 'dark' ? 'dark' : 'light';
return (
-
+
s.isCollapsed);
@@ -37,6 +38,9 @@ export function AppShell({ children }: { children: React.ReactNode }) {
>
{children}
+ {/* "Was ist neu"-Fenster nach einem Versionswechsel (quick-260925-bow):
+ nur hier im Portal-Rahmen, nie auf der Anmeldeseite. */}
+
);
}
diff --git a/apps/web/src/components/release-notice/release-notice-dialog.tsx b/apps/web/src/components/release-notice/release-notice-dialog.tsx
new file mode 100644
index 0000000..c9b5280
--- /dev/null
+++ b/apps/web/src/components/release-notice/release-notice-dialog.tsx
@@ -0,0 +1,227 @@
+'use client';
+
+import Link from 'next/link';
+import { useTranslations } from 'next-intl';
+import { useEffect, useId, useRef } from 'react';
+import { ChangelogView } from '@/components/changelog/changelog-view';
+import type { ReleaseNotice } from '@/lib/release-notes';
+
+/**
+ * "Was ist neu"-Fenster nach einem Versionswechsel (quick-260925-bow, D-07).
+ *
+ * Aufbau nach `widget-catalog-modal.tsx`: Hintergrund als echte, benannte
+ * Schaltflaeche, Dialog mit `role="dialog"`, `aria-modal` und
+ * `aria-labelledby` auf die Ueberschrift. Anfangsfokus auf der Ueberschrift,
+ * Escape schliesst, Tab/Umschalt+Tab bleiben im Fenster (die fokussierbaren
+ * Elemente werden bei jedem Tastendruck neu ermittelt, weil die
+ * Markdown-Ausgabe Links enthalten kann). Beim Schliessen geht der Fokus an
+ * das zuvor aktive Element zurueck. Keine Einblendanimation.
+ *
+ * Eintraege rendert `ChangelogView` (derselbe Renderer wie /changelog, mit
+ * `rehype-sanitize`). Dieses Modul importiert `@/lib/changelog` NICHT — den
+ * Inhalt bekommt es fertig ausgewaehlt als Eigenschaft von der Server-Aktion.
+ *
+ * Jeder Schliessweg (Verstanden, Kreuz, Escape, Hintergrund, Link zu
+ * /changelog) ruft `onClose` genau einmal; gemerkt wird im Host.
+ */
+
+const FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])';
+
+interface ReleaseNoticeDialogProps {
+ notice: ReleaseNotice;
+ onClose: () => void;
+}
+
+export function ReleaseNoticeDialog({ notice, onClose }: ReleaseNoticeDialogProps) {
+ const t = useTranslations('releaseNotice');
+ const tCommon = useTranslations('common');
+ const titleId = useId();
+ const dialogRef = useRef(null);
+ const headingRef = useRef(null);
+ const onCloseRef = useRef(onClose);
+ const closedRef = useRef(false);
+
+ useEffect(() => {
+ onCloseRef.current = onClose;
+ }, [onClose]);
+
+ // Ein Schliessweg zaehlt genau einmal, auch wenn z. B. Escape und ein Klick
+ // kurz nacheinander kommen.
+ const close = () => {
+ if (closedRef.current) return;
+ closedRef.current = true;
+ onCloseRef.current();
+ };
+
+ // Anfangsfokus und Rueckgabe des Fokus beim Unmount.
+ useEffect(() => {
+ const previouslyFocused =
+ document.activeElement instanceof HTMLElement ? document.activeElement : null;
+ headingRef.current?.focus();
+ return () => {
+ previouslyFocused?.focus();
+ };
+ }, []);
+
+ useEffect(() => {
+ function handleKeyDown(event: KeyboardEvent) {
+ const active = document.activeElement;
+ // Liegt der Fokus in einem ANDEREN Dialog, gehoert die Taste ihm.
+ const otherDialog = active instanceof Element ? active.closest('[role="dialog"]') : null;
+ if (otherDialog && otherDialog !== dialogRef.current) return;
+
+ if (event.key === 'Escape') {
+ event.preventDefault();
+ if (closedRef.current) return;
+ closedRef.current = true;
+ onCloseRef.current();
+ return;
+ }
+ if (event.key !== 'Tab') return;
+
+ const dialog = dialogRef.current;
+ if (!dialog) return;
+ const focusables = Array.from(dialog.querySelectorAll(FOCUSABLE_SELECTOR));
+ if (focusables.length === 0) {
+ event.preventDefault();
+ headingRef.current?.focus();
+ return;
+ }
+ const first = focusables[0];
+ const last = focusables[focusables.length - 1];
+ const index = active instanceof HTMLElement ? focusables.indexOf(active) : -1;
+
+ if (index === -1) {
+ // Fokus auf der Ueberschrift oder ausserhalb (Hintergrund): ins Fenster holen.
+ event.preventDefault();
+ (event.shiftKey ? last : first).focus();
+ return;
+ }
+ if (!event.shiftKey && active === last) {
+ event.preventDefault();
+ first.focus();
+ } else if (event.shiftKey && active === first) {
+ event.preventDefault();
+ last.focus();
+ }
+ }
+
+ document.addEventListener('keydown', handleKeyDown);
+ return () => document.removeEventListener('keydown', handleKeyDown);
+ }, []);
+
+ const multipleVersions = notice.versions.length > 1;
+
+ return (
+
+ {/* Hintergrund als echte, benannte Schaltflaeche (wie im Kachel-Katalog). */}
+
+
+
+
+ {/* Benannter, scrollbarer Bereich. Ohne eigenes tabIndex: heutige
+ Chromium- und WebKit-Versionen (Browser, Desktop-App) machen einen
+ Scroll-Container ohne fokussierbaren Inhalt selbst per Tastatur
+ erreichbar; enthaelt er Links, scrollt der Tab-Fokus mit. */}
+
+
+ {notice.versions.map((release) => {
+ // Ueberschriften-Hierarchie: bei einer Version sind die Gruppen
+ // h3; bei mehreren traegt die Version h3 und die Gruppen h4.
+ const GroupHeading = multipleVersions ? 'h4' : 'h3';
+ return (
+