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
82 changes: 82 additions & 0 deletions frontend/src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -211,3 +211,85 @@ body {
0%, 100% { opacity: 0.6; }
50% { opacity: 1; }
}

/* Bootstrap failure state — must remain usable before the App module loads. */
.startup-error-screen {
min-height: 100%;
width: 100%;
display: grid;
place-items: center;
padding: 24px;
background:
radial-gradient(circle at 50% 0%, rgba(227, 38, 54, 0.18), transparent 42%),
linear-gradient(160deg, #0d0e14 0%, #050505 62%, #16080b 100%);
}

.startup-error-card {
width: min(100%, 440px);
border: 1px solid rgba(255, 91, 91, 0.48);
border-radius: 16px;
padding: 32px 28px;
background: rgba(10, 10, 14, 0.95);
box-shadow: 0 20px 70px rgba(0, 0, 0, 0.54), 0 0 32px rgba(255, 44, 44, 0.12);
text-align: center;
}

.startup-error-eyebrow {
display: block;
margin-bottom: 12px;
color: #ff7777;
font-family: 'Oswald', sans-serif;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.24em;
}

.startup-error-card h1 {
margin: 0;
color: #ffffff;
font-family: 'Bebas Neue', 'Oswald', sans-serif;
font-size: clamp(30px, 8vw, 44px);
font-weight: 400;
letter-spacing: 0.055em;
line-height: 1;
}

.startup-error-card p {
margin: 20px auto 0;
max-width: 34ch;
color: rgba(255, 255, 255, 0.78);
font-size: 16px;
line-height: 1.5;
}

.startup-error-retry {
margin-top: 28px;
min-height: 44px;
border: 1px solid #ff6565;
border-radius: 8px;
padding: 0 20px;
background: rgba(222, 44, 44, 0.16);
color: #ffffff;
cursor: pointer;
font-family: 'Oswald', sans-serif;
font-size: 14px;
font-weight: 700;
letter-spacing: 0.12em;
}

.startup-error-retry:hover,
.startup-error-retry:focus-visible {
outline: none;
background: rgba(222, 44, 44, 0.35);
box-shadow: 0 0 0 3px rgba(255, 111, 111, 0.25);
}

@media (max-width: 360px) {
.startup-error-card {
padding: 28px 20px;
}

.startup-error-card p {
font-size: 15px;
}
}
44 changes: 44 additions & 0 deletions frontend/src/components/system/StartupErrorBoundary.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import StartupErrorBoundary from './StartupErrorBoundary';

function ThrowError({ message }: { message: string }): never {
throw new Error(message);
}

describe('StartupErrorBoundary', () => {
let consoleError: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
});

afterEach(() => {
consoleError.mockRestore();
});

it('explains missing Supabase configuration instead of leaving a blank application frame', () => {
render(
<StartupErrorBoundary>
<ThrowError message="Missing Supabase environment variables. Check your .env file." />
</StartupErrorBoundary>
);

expect(screen.getByRole('alert')).toBeInTheDocument();
expect(screen.getByRole('heading', { name: /system configuration required/i })).toBeInTheDocument();
expect(screen.getByText(/missing a required service configuration/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /retry startup/i })).toBeInTheDocument();
});

it('shows a neutral recovery state for unexpected bootstrap failures', () => {
render(
<StartupErrorBoundary>
<ThrowError message="Unexpected lazy module failure" />
</StartupErrorBoundary>
);

expect(screen.getByRole('heading', { name: /system unavailable/i })).toBeInTheDocument();
expect(screen.getByText(/could not finish starting/i)).toBeInTheDocument();
});
});
66 changes: 66 additions & 0 deletions frontend/src/components/system/StartupErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import React from 'react';

type StartupErrorBoundaryProps = {
children: React.ReactNode;
};

type StartupErrorBoundaryState = {
error: Error | null;
};

function isConfigurationError(error: Error): boolean {
return /missing supabase environment variables/i.test(error.message);
}

/**
* Protects the React bootstrap from module-load and render failures.
*
* App is intentionally lazy loaded from `main.tsx`. This boundary therefore
* remains available when App's Supabase dependency rejects during import,
* which would otherwise leave the player on an empty frame.
*/
export class StartupErrorBoundary extends React.Component<
StartupErrorBoundaryProps,
StartupErrorBoundaryState
> {
state: StartupErrorBoundaryState = { error: null };

static getDerivedStateFromError(error: Error): StartupErrorBoundaryState {
return { error };
}

private retry = () => {
window.location.reload();
};

render() {
const { error } = this.state;

if (!error) {
return this.props.children;
}

const configurationError = isConfigurationError(error);

return (
<main className="startup-error-screen" role="alert" aria-live="assertive">
<section className="startup-error-card" aria-labelledby="startup-error-title">
<span className="startup-error-eyebrow">DEALT / SLIDE</span>
<h1 id="startup-error-title">
{configurationError ? 'SYSTEM CONFIGURATION REQUIRED' : 'SYSTEM UNAVAILABLE'}
</h1>
<p>
{configurationError
? 'This game environment is missing a required service configuration. Please contact the deployment owner or try again after the environment is configured.'
: 'SLIDE could not finish starting. Please try again. If the problem continues, contact the deployment owner.'}
</p>
<button type="button" className="startup-error-retry" onClick={this.retry}>
RETRY STARTUP
</button>
</section>
</main>
);
}
}

export default StartupErrorBoundary;
22 changes: 13 additions & 9 deletions frontend/src/main.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import StartupErrorBoundary from './components/system/StartupErrorBoundary';

const App = React.lazy(() => import('./App'));
const ShooterGraphicsLab = React.lazy(() => import('./components/dev/ShooterGraphicsLab'));

Expand All @@ -12,14 +14,16 @@ import './styles/mobile.css';

ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
{graphicsLabEnabled ? (
<React.Suspense fallback={<div style={{ background: '#070a12', minHeight: '100vh' }} />}>
<ShooterGraphicsLab />
</React.Suspense>
) : (
<React.Suspense fallback={<div style={{ background: '#070a12', minHeight: '100vh' }} />}>
<App />
</React.Suspense>
)}
<StartupErrorBoundary>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Record this startup change in the project log

This introduces meaningful production startup and recovery behavior, but the commit does not append a dated entry to docs/PROJECT_LOG.md; that leaves the repository's required decision and rollback record incomplete. Add an entry describing the new error boundary, recovery behavior, and validation performed.

AGENTS.md reference: AGENTS.md:L3-L5

Useful? React with 👍 / 👎.

{graphicsLabEnabled ? (
<React.Suspense fallback={<div style={{ background: '#070a12', minHeight: '100vh' }} />}>
<ShooterGraphicsLab />
</React.Suspense>
) : (
<React.Suspense fallback={<div style={{ background: '#070a12', minHeight: '100vh' }} />}>
<App />
</React.Suspense>
)}
</StartupErrorBoundary>
</React.StrictMode>
);
Loading