Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions src/components/BootstrapScreen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,21 @@ import { describe, expect, it, vi } from "vitest";
import BootstrapScreen from "./BootstrapScreen";

describe("BootstrapScreen", () => {
it("uses design controls without classic classes across loading and retry", () => {
const onRetry = vi.fn();
const view = render(<BootstrapScreen error={false} onRetry={onRetry} />);
expect(screen.getByRole("heading", { name: "ProjectA" })).toBeVisible();
expect(view.container.querySelector('[class*="bootstrap-"], .btn, .field')).toBeNull();
view.rerender(<BootstrapScreen error onRetry={onRetry} />);
const retry = screen.getByRole("button", { name: "Erneut versuchen" });
expect(retry).toHaveClass("g-btn", "g-btn--primary");
expect(retry).toHaveFocus();
expect(screen.getByRole("heading", { name: "Arbeitsbereich konnte nicht geladen werden" })).toBeVisible();
expect(view.container.querySelector('[class*="bootstrap-"], .btn, .field')).toBeNull();
fireEvent.click(retry);
expect(onRetry).toHaveBeenCalledOnce();
});

it("announces real loading without exposing a retry action", () => {
render(<BootstrapScreen error={false} onRetry={vi.fn()} />);

Expand Down
34 changes: 20 additions & 14 deletions src/components/BootstrapScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
import { Button } from "../design/controls/Button";
import { t } from "../i18n/useT";
import "../design/tokens.css";
import "../design/controls/controls.css";
import styles from "./RecoveryScreen.module.css";

interface BootstrapScreenProps {
/** The initial project read failed; retry is safe because it only reads state. */
error: boolean;
Expand All @@ -11,30 +17,30 @@ interface BootstrapScreenProps {
export default function BootstrapScreen({ error, onRetry }: BootstrapScreenProps) {
if (error) {
return (
<main className="bootstrap-screen">
<section className="bootstrap-card" role="alert" aria-labelledby="bootstrap-title">
<span className="bootstrap-mark" aria-hidden="true">
<main className={styles.screen}>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Lass den Bootstrap-Bildschirm beide Grid-Spalten überspannen

Solange bootstrapReady falsch ist, ist dieses <main> direktes Kind von .app, deren Grid weiterhin aus der 248-px-Seitenleiste und der Inhaltsfläche besteht. Durch den Austausch von .bootstrap-screen entfällt dessen grid-column: 1 / -1; der Lade- beziehungsweise Fehlerbildschirm wird daher automatisch nur in der ersten Spalte platziert, statt das Fenster auszufüllen. Verwende für den Bootstrap-Fall zusätzlich eine modulare Klasse, die beide Spalten überspannt.

Useful? React with 👍 / 👎.

<section className={styles.card} role="alert" aria-labelledby="bootstrap-title">
<span className={styles.mark} aria-hidden="true">
◆
</span>
<h1 id="bootstrap-title">Arbeitsbereich konnte nicht geladen werden</h1>
<p>Prüfe die Verbindung zur App und versuche es erneut.</p>
<button type="button" className="bootstrap-retry" autoFocus onClick={onRetry}>
Erneut versuchen
</button>
<h1 className={styles.title} id="bootstrap-title">{t("bootstrap.failed")}</h1>
<p className={styles.hint}>{t("bootstrap.retryHint")}</p>
<Button variant="primary" autoFocus onClick={onRetry}>
{t("bootstrap.retry")}
</Button>
</section>
</main>
);
}

return (
<main className="bootstrap-screen" aria-busy="true">
<section className="bootstrap-card" role="status" aria-live="polite" aria-labelledby="bootstrap-title">
<span className="bootstrap-mark" aria-hidden="true">
<main className={styles.screen} aria-busy="true">
<section className={styles.card} role="status" aria-live="polite" aria-labelledby="bootstrap-title">
<span className={styles.mark} aria-hidden="true">
◆
</span>
<div className="bootstrap-spinner" aria-hidden="true" />
<h1 id="bootstrap-title">ProjectA</h1>
<p>Arbeitsbereich wird geladen …</p>
<div className={styles.spinner} aria-hidden="true" />
<h1 className={styles.title} id="bootstrap-title">{t("shell.brand")}</h1>
<p className={styles.hint}>{t("bootstrap.loading")}</p>
</section>
</main>
);
Expand Down
22 changes: 21 additions & 1 deletion src/components/ErrorBoundary.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { render, screen } from "@testing-library/react";
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";

import ErrorBoundary from "./ErrorBoundary";
Expand All @@ -8,6 +8,26 @@ function Bomb(): never {
}

describe("ErrorBoundary", () => {
it("uses a focused design reload control and preserves crash details", () => {
const noise = vi.spyOn(console, "error").mockImplementation(() => undefined);
const reload = vi.fn();
try {
const view = render(<ErrorBoundary label="Die Testansicht"><Bomb /></ErrorBoundary>);
const button = screen.getByRole("button", { name: "Neu laden" });
expect(button).toHaveClass("g-btn", "g-btn--primary");
expect(button).toHaveFocus();
expect(screen.getByRole("heading", { name: "Die Testansicht ist abgestürzt." })).toBeVisible();
expect(screen.getByRole("alert")).toHaveTextContent("Test-Boom");
expect(view.container.querySelector('[class*="error-boundary"], .empty-action, .btn, .field')).toBeNull();
vi.stubGlobal("window", Object.create(window, { location: { value: { reload } } }));
fireEvent.click(button);
expect(reload).toHaveBeenCalledOnce();
} finally {
vi.unstubAllGlobals();
noise.mockRestore();
}
});

it("fängt einen Render-Fehler und zeigt die Fehlerfläche statt eines weißen Fensters", () => {
// React logs a caught error to the console as well — noise, not a failure.
const noise = vi.spyOn(console, "error").mockImplementation(() => undefined);
Expand Down
35 changes: 21 additions & 14 deletions src/components/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { Component } from "react";
import type { ErrorInfo, ReactNode } from "react";
import { Button } from "../design/controls/Button";
import { t } from "../i18n/useT";
import "../design/tokens.css";
import "../design/controls/controls.css";
import styles from "./RecoveryScreen.module.css";

interface ErrorBoundaryProps {
children: ReactNode;
Expand Down Expand Up @@ -38,20 +43,22 @@ export default class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBo
const { error } = this.state;
if (error === null) return this.props.children;
return (
<div className="error-boundary" role="alert">
<p className="error-boundary-title">
{this.props.label === undefined
? "Die Oberfläche ist abgestürzt."
: `${this.props.label} ist abgestürzt.`}
</p>
<p className="error-boundary-detail">{error.message}</p>
<button
type="button"
className="empty-action"
onClick={() => window.location.reload()}
>
Neu laden
</button>
<div className={styles.screen} role="alert">
<section className={styles.card}>
<h1 className={styles.title}>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Verwende für eingebettete Abstürze keine zweite H1

Bei jeder mit label versehenen View-Boundary bleibt die Seitenleiste samt bestehender Seitenüberschrift <h1 className="sidebar-brand">ProjectA</h1> sichtbar. Der neue Fallback fügt in diesem Fall eine zweite H1 für die abgestürzte Teilansicht ein und verletzt damit die bereits getestete Überschriftenhierarchie der klassischen Oberfläche; Screenreader erhalten zwei konkurrierende Seitenüberschriften. Rendere für gelabelte, eingebettete Boundaries eine H2 und behalte H1 nur für den app-weiten Fallback ohne label.

Useful? React with 👍 / 👎.

{this.props.label === undefined
? t("recovery.crashed")
: t("recovery.viewCrashed").replace("{label}", () => this.props.label ?? "")}
</h1>
<p className={styles.detail}>{error.message}</p>
<Button
variant="primary"
autoFocus
onClick={() => window.location.reload()}
>
{t("recovery.reload")}
</Button>
</section>
</div>
);
}
Expand Down
40 changes: 40 additions & 0 deletions src/components/RecoveryScreen.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
.screen {
box-sizing: border-box;
display: grid;
place-items: center;
flex: 1;
width: 100%;
min-width: 0;
min-height: 100%;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Begrenze die eingebettete Fehlerfläche auf den Restplatz

Wenn eine der ansichtsspezifischen Error Boundaries in App.tsx auslöst, ist diese Fläche ein Flex-Element unter der mindestens 38 px hohen .viewbar. min-height: 100% zwingt sie trotzdem auf die volle Höhe von .main, sodass sie zusammen mit der Viewbar über das Fenster hinausragt; da body zugleich overflow: hidden verwendet, werden der untere Teil und bei kleinen Fenstern beziehungsweise langen Fehlermeldungen auch der Scrollbereich abgeschnitten. Für eingebettete Boundaries sollte die Fläche mit min-height: 0 nur den verbleibenden Flex-Platz belegen; ein eigener Fullscreen-Modifikator kann den Bootstrap-Fall abdecken.

Useful? React with 👍 / 👎.

padding: var(--g-pad);
overflow: auto;
background: var(--g-ground);
color: var(--g-ink);
font: 400 14px/1.6 var(--g-font-sans);
}
.card {
box-sizing: border-box;
display: grid;
justify-items: center;
gap: 16px;
width: min(100%, 560px);
padding: calc(var(--g-pad) * 2);
border: 1px solid var(--g-line);
border-radius: var(--g-r-panel);
background: var(--g-content-bg);
text-align: center;
}
.title { margin: 0; font-size: 22px; line-height: 1.3; font-weight: 600; overflow-wrap: anywhere; }
.hint { margin: 0; color: var(--g-muted); }
.detail { margin: 0; max-width: 100%; overflow-wrap: anywhere; white-space: pre-wrap; font-family: var(--g-font-mono); }
.mark { color: var(--g-accent); font-size: 24px; }
.spinner {
width: 24px;
height: 24px;
border: 2px solid var(--g-line);
border-top-color: var(--g-accent);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .spinner { animation: none; } }
7 changes: 7 additions & 0 deletions src/i18n/de.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
/** German UI copy for Glass UI v2. English locale is parked; keep Messages. */

export const de = {
"recovery.crashed": "Die Oberfläche ist abgestürzt.",
"recovery.viewCrashed": "{label} ist abgestürzt.",
"recovery.reload": "Neu laden",
"bootstrap.failed": "Arbeitsbereich konnte nicht geladen werden",
"bootstrap.retryHint": "Prüfe die Verbindung zur App und versuche es erneut.",
"bootstrap.retry": "Erneut versuchen",
"bootstrap.loading": "Arbeitsbereich wird geladen …",
"terminal.title": "Terminal",
"terminal.sessions": "Terminal-Sitzungen",
"terminal.empty": "Keine Terminal-Sitzung geöffnet.",
Expand Down
Loading