UI-Gesamtumbau: Du-Form, Inspector-Panel, Onboarding-Assistent, Tour, Palette
Deploy App / deploy (push) Successful in 1m1s

Rein an der Oberflaeche -- Berechnung, Datenmodell und API-Semantik unveraendert
(103 Tests unveraendert gruen).

Paket A (Fundament):
- durchgehend Du-Form und echte Umlaute in allen sichtbaren Texten,
  inkl. API-Fehlermeldungen (vorher Mix aus Sie/Du und ae/oe/ue)
- neue UI-Primitiven (ui.tsx): Button, Modal mit ESC/Fokus-Falle/Animation,
  Bestaetigungs-Dialog statt window.confirm, Toasts statt alert,
  Skeleton-Loader, EmptyState
- eigene Attention-Farbe (Amber) fuer offene Entscheide, getrennt vom Akzent
- Micro-Interactions mit prefers-reduced-motion-Fallback

Paket B (Onboarding, Roadmap Nr. 10):
- gefuehrter Plan-Assistent in 5 Schritten; Einkommen bewusst pro Person
  (raeumt die 9.9-AHV-Falle aus); reine Orchestrierung bestehender Endpunkte
- Beispielplan mit einem Klick; Uebergaenge absichtlich offen
- interaktive Tour ueber die Planansicht (localStorage, jederzeit neu startbar)
- abgeleitete "Naechste Schritte"-Karte (offene Entscheide, fehlende Elemente,
  fehlende Pensionsphase, Ruin -> Einflussfaktoren)

Paket C (Struktur):
- Inspector-Panel rechts statt Modals fuer alle Einzel-Bearbeitungen;
  Matrix bleibt sichtbar, Zellklick wechselt den Inhalt
- Phasenkopf auf vier Kern-Infos entschlackt (Rest in der 0.11-Detailansicht)
- Matrix mit eigenem Scrollbereich, Koepfe beidachsig fixiert
- Sidebar-Gruppen "Meine Plaene" / "Wissen"; "So rechnet FPT" statt
  SPEZIFIKATION; "Szenario-Profil" statt "Plan-Einstellungen"
- Aktions-Icons ohne Hover sichtbar (Touch)

Paket D (Extras):
- Sparklines je Element-Zeile aus den 0.11-Verlaufswerten
- Befehls-Palette (Ctrl/Cmd+K)
- Ruin-Banner verlinkt auf die Einflussfaktoren

Nebenbei: der ProfileMenu-Lint-Fehler und der Selection-Rest (9.17) sind
behoben -- npm run lint laeuft erstmals fehlerfrei.

SPEZIFIKATION auf 0.13: neue Kapitel 3.2.8, 3.7.6-3.7.9, 9.23, 9.24;
3.6.3 und 3.7.1 ueberarbeitet, 9.17 bereinigt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 07:40:42 +02:00
parent e1f74fca95
commit c2fb82b0de
60 changed files with 2915 additions and 1210 deletions
+381
View File
@@ -0,0 +1,381 @@
"use client";
// UI-Primitiven des Tools. Ein einziger Ort für Buttons, Dialoge, Bestätigungen, Toasts
// und Ladezustände -- damit Verhalten (ESC, Fokus-Falle, Animation) und Optik überall
// identisch sind, statt in jeder Komponente von Hand nachgebaut zu werden.
//
// Ersetzt insbesondere window.confirm()/alert(): Browser-Systemdialoge folgen keinem der
// drei Farbschemata und wirken wie ein Fremdkörper.
import {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { AlertTriangle, CheckCircle2, X } from "lucide-react";
// --- ESC-Verwaltung ----------------------------------------------------------------------
// Mehrere Schichten können gleichzeitig offen sein (Inspector + Dialog). ESC soll nur die
// OBERSTE schliessen. Jede Schicht registriert sich beim Öffnen; ESC trifft die letzte.
const escStack: (() => void)[] = [];
let escListenerAttached = false;
function ensureEscListener() {
if (escListenerAttached || typeof window === "undefined") return;
escListenerAttached = true;
window.addEventListener("keydown", (e) => {
if (e.key !== "Escape" || escStack.length === 0) return;
e.stopPropagation();
escStack[escStack.length - 1]();
});
}
function useEscClose(onClose: () => void) {
const closeRef = useRef(onClose);
useEffect(() => {
closeRef.current = onClose;
}, [onClose]);
useEffect(() => {
ensureEscListener();
const handler = () => closeRef.current();
escStack.push(handler);
return () => {
const i = escStack.indexOf(handler);
if (i >= 0) escStack.splice(i, 1);
};
}, []);
}
// Einfache Fokus-Falle: beim Öffnen den ersten fokussierbaren Inhalt fokussieren,
// Tab bleibt innerhalb der Schicht, beim Schliessen kehrt der Fokus zurück.
function useFocusTrap(ref: React.RefObject<HTMLElement | null>) {
useEffect(() => {
const el = ref.current;
if (!el) return;
const previous = document.activeElement as HTMLElement | null;
const focusables = () =>
[...el.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
)].filter((x) => !x.hasAttribute("disabled"));
(focusables()[0] ?? el).focus({ preventScroll: true });
function onKey(e: KeyboardEvent) {
if (e.key !== "Tab") return;
const f = focusables();
if (f.length === 0) return;
const first = f[0];
const last = f[f.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
el.addEventListener("keydown", onKey);
return () => {
el.removeEventListener("keydown", onKey);
previous?.focus?.({ preventScroll: true });
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}
// --- Button ------------------------------------------------------------------------------
type ButtonVariant = "primary" | "secondary" | "danger" | "ghost";
const BUTTON_CLASSES: Record<ButtonVariant, string> = {
primary:
"bg-accent text-accent-fg shadow-sm hover:bg-accent-hover active:scale-[0.98]",
secondary:
"border border-border text-muted hover:bg-surface-2 hover:text-fg active:scale-[0.98]",
danger:
"border border-border text-muted hover:border-danger hover:bg-danger-soft hover:text-danger active:scale-[0.98]",
ghost: "text-muted hover:bg-surface-2 hover:text-fg",
};
export function Button({
variant = "primary",
size = "md",
className = "",
children,
...rest
}: React.ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: ButtonVariant;
size?: "sm" | "md";
}) {
const sizeClass = size === "sm" ? "px-2.5 py-1 text-xs" : "px-4 py-2 text-sm";
return (
<button
type="button"
{...rest}
className={`inline-flex items-center justify-center gap-1.5 rounded-lg font-medium transition-all duration-150 disabled:pointer-events-none disabled:opacity-50 ${BUTTON_CLASSES[variant]} ${sizeClass} ${className}`}
>
{children}
</button>
);
}
// --- Modal -------------------------------------------------------------------------------
export function Modal({
title,
subtitle,
onClose,
children,
wide,
}: {
title: string;
subtitle?: string;
onClose: () => void;
children: React.ReactNode;
wide?: boolean;
}) {
const panelRef = useRef<HTMLDivElement | null>(null);
useEscClose(onClose);
useFocusTrap(panelRef);
return (
<div
className="ui-fade fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/40 px-4 py-8"
onClick={onClose}
>
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-label={title}
onClick={(e) => e.stopPropagation()}
className={`ui-pop flex w-full ${wide ? "max-w-2xl" : "max-w-md"} flex-col gap-3 rounded-2xl border border-border bg-surface p-6 shadow-xl`}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h2 className="text-base font-semibold text-fg">{title}</h2>
{subtitle && <p className="mt-0.5 text-xs text-muted">{subtitle}</p>}
</div>
<button
type="button"
onClick={onClose}
aria-label="Schliessen"
className="rounded-md p-1 text-faint transition-colors hover:bg-surface-2 hover:text-fg"
>
<X className="h-4 w-4" />
</button>
</div>
{children}
</div>
</div>
);
}
// --- Inspector ---------------------------------------------------------------------------
// Rechtes Seitenpanel für Einzel-Bearbeitungen (Zellen, Übergänge, Phase, Profil).
// Bewusst OHNE Backdrop: Die Matrix bleibt sichtbar und klickbar -- ein Klick auf eine
// andere Zelle wechselt einfach den Panel-Inhalt. Das hält den Kontext, den Modals nehmen.
// Auf schmalen Screens deckt das Panel die volle Breite ab.
export function InspectorShell({
title,
subtitle,
onClose,
children,
}: {
title: string;
subtitle?: string;
onClose: () => void;
children: React.ReactNode;
}) {
const panelRef = useRef<HTMLDivElement | null>(null);
useEscClose(onClose);
return (
<aside
ref={panelRef}
role="complementary"
aria-label={title}
className="ui-slide-in fixed inset-y-0 right-0 z-30 flex w-full flex-col border-l border-border bg-surface shadow-2xl sm:w-[26rem]"
>
<div className="flex items-start justify-between gap-3 border-b border-border px-5 py-4">
<div className="min-w-0">
<h2 className="truncate text-base font-semibold text-fg">{title}</h2>
{subtitle && <p className="mt-0.5 text-xs text-muted">{subtitle}</p>}
</div>
<button
type="button"
onClick={onClose}
aria-label="Panel schliessen"
className="rounded-md p-1 text-faint transition-colors hover:bg-surface-2 hover:text-fg"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="flex-1 overflow-y-auto px-5 py-4">{children}</div>
</aside>
);
}
// --- Bestätigungs-Dialog (ersetzt window.confirm) ---------------------------------------
interface ConfirmOptions {
title: string;
message: string;
confirmLabel?: string;
danger?: boolean;
}
const ConfirmContext = createContext<((opts: ConfirmOptions) => Promise<boolean>) | null>(null);
export function useConfirm() {
const fn = useContext(ConfirmContext);
if (!fn) throw new Error("useConfirm ausserhalb des ConfirmProviders");
return fn;
}
export function ConfirmProvider({ children }: { children: React.ReactNode }) {
const [pending, setPending] = useState<{
opts: ConfirmOptions;
resolve: (ok: boolean) => void;
} | null>(null);
const confirm = useCallback(
(opts: ConfirmOptions) =>
new Promise<boolean>((resolve) => setPending({ opts, resolve })),
[]
);
function settle(ok: boolean) {
pending?.resolve(ok);
setPending(null);
}
return (
<ConfirmContext.Provider value={confirm}>
{children}
{pending && (
<Modal title={pending.opts.title} onClose={() => settle(false)}>
<p className="text-sm text-muted">{pending.opts.message}</p>
<div className="flex gap-2 pt-1">
<Button
variant={pending.opts.danger ? "danger" : "primary"}
autoFocus
onClick={() => settle(true)}
className={pending.opts.danger ? "border-danger bg-danger-soft text-danger" : ""}
>
{pending.opts.confirmLabel ?? "Bestätigen"}
</Button>
<Button variant="secondary" onClick={() => settle(false)}>
Abbrechen
</Button>
</div>
</Modal>
)}
</ConfirmContext.Provider>
);
}
// --- Toasts (ersetzt alert und stumme Erfolge) -------------------------------------------
interface ToastItem {
id: number;
kind: "success" | "error";
text: string;
}
const ToastContext = createContext<((kind: ToastItem["kind"], text: string) => void) | null>(null);
export function useToast() {
const fn = useContext(ToastContext);
if (!fn) throw new Error("useToast ausserhalb des ToastProviders");
return fn;
}
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = useState<ToastItem[]>([]);
const nextId = useRef(1);
const push = useCallback((kind: ToastItem["kind"], text: string) => {
const id = nextId.current++;
setToasts((prev) => [...prev, { id, kind, text }]);
// Fehler bleiben länger stehen als Erfolge.
setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), kind === "error" ? 6000 : 3200);
}, []);
return (
<ToastContext.Provider value={push}>
{children}
<div className="pointer-events-none fixed bottom-4 right-4 z-50 flex w-80 flex-col gap-2">
{toasts.map((t) => (
<div
key={t.id}
className={`ui-pop pointer-events-auto flex items-start gap-2 rounded-xl border px-3.5 py-2.5 text-sm shadow-lg ${
t.kind === "error"
? "border-danger bg-danger-soft text-danger"
: "border-border bg-surface text-fg"
}`}
>
{t.kind === "error" ? (
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
) : (
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-success" />
)}
<span className="min-w-0 flex-1">{t.text}</span>
<button
type="button"
aria-label="Meldung schliessen"
onClick={() => setToasts((prev) => prev.filter((x) => x.id !== t.id))}
className="rounded p-0.5 text-faint hover:text-fg"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
</ToastContext.Provider>
);
}
// --- Ladezustände und leere Zustände ---------------------------------------------------
export function Skeleton({ className = "" }: { className?: string }) {
return <div className={`animate-pulse rounded-lg bg-surface-2 ${className}`} />;
}
// Platzhalter während die Planansicht lädt -- deutet die Struktur an, statt nur
// "Lädt…" zu schreiben (wahrgenommene Geschwindigkeit).
export function PlanSkeleton() {
return (
<div className="flex flex-col gap-5" aria-busy="true" aria-label="Lädt">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-24 w-full" />
<Skeleton className="h-12 w-full" />
<Skeleton className="h-72 w-full" />
</div>
);
}
export function EmptyState({
icon,
title,
text,
children,
}: {
icon: React.ReactNode;
title: string;
text: string;
children?: React.ReactNode;
}) {
return (
<div className="flex flex-col items-center gap-3 rounded-xl border border-dashed border-border bg-surface p-10 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-accent-soft text-accent-soft-fg">
{icon}
</div>
<div>
<h3 className="text-sm font-semibold text-fg">{title}</h3>
<p className="mx-auto mt-1 max-w-md text-sm text-muted">{text}</p>
</div>
{children && <div className="mt-1 flex flex-wrap justify-center gap-2">{children}</div>}
</div>
);
}