fix(ui): unify branded error pages (#7057)

* fix(ui): unify branded error pages

* fix(desktop): match canonical chip chrome

* test(ui): update error chip mock

* fix(desktop): package offline font reliably
This commit is contained in:
Waleed
2026-08-24 20:42:41 -07:00
committed by GitHub
parent b7b0be8eef
commit 49a3399dd2
18 changed files with 299 additions and 239 deletions
+13
View File
@@ -110,5 +110,18 @@ test.describe('desktop shell smoke', () => {
const window = await app.firstWindow()
await window.waitForSelector('#retry', { timeout: 30_000 })
expect(window.url().startsWith('file:')).toBe(true)
await expect(window.locator('.wordmark')).toBeVisible()
await expect(window.locator('.wordmark')).toHaveAttribute('aria-label', 'Sim')
await expect(window.locator('#title')).toHaveText('Cant connect to Sim')
await expect(window.locator('#status')).toHaveText('Check status')
await expect
.poll(() => window.evaluate(() => document.fonts.check('16px "Season Sans"')))
.toBe(true)
await expect(window.locator('#retry')).toHaveCSS('height', '30px')
await expect(window.locator('#retry')).toHaveCSS('border-radius', '8px')
await expect(window.locator('#retry')).toHaveCSS('padding-left', '8px')
await expect(window.locator('#retry')).toHaveCSS('font-size', '14px')
await expect(window.locator('#retry')).toHaveCSS('line-height', '20px')
await expect(window.locator('#retry')).toHaveCSS('text-align', 'left')
})
})
+4
View File
@@ -10,6 +10,10 @@ files:
- dist/**
- static/**
- package.json
- from: ../sim/public/brand/fonts
to: static
filter:
- SeasonSansUprightsVF.woff2
asar: true
+142 -81
View File
@@ -4,26 +4,27 @@
<meta charset="utf-8" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'"
content="default-src 'none'; font-src 'self'; style-src 'unsafe-inline'; script-src 'unsafe-inline'"
/>
<title>Sim Cant connect</title>
<title>Sim - Cant connect</title>
<style>
:root {
color-scheme: light dark;
--bg: #ffffff;
--fg: #0c0c0c;
--muted: #6b6b6b;
--border: #e4e4e4;
--accent: #701ffc;
--accent-fg: #ffffff;
@font-face {
font-family: 'Season Sans';
src:
url('../../sim/public/brand/fonts/SeasonSansUprightsVF.woff2') format('woff2'),
url('./SeasonSansUprightsVF.woff2') format('woff2');
font-style: normal;
font-weight: 300 800;
font-display: block;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0c0c0c;
--fg: #f4f4f4;
--muted: #9a9a9a;
--border: #262626;
}
:root {
color-scheme: light;
--bg: #fefefe;
--text-primary: #1a1a1a;
--text-body: #434343;
--text-muted: #7a7a7a;
--text-inverse: #ffffff;
--surface-hover: #f2f2f2;
}
* {
box-sizing: border-box;
@@ -35,111 +36,171 @@
}
body {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
min-width: 320px;
padding-top: max(env(titlebar-area-height, 38px), 38px);
background: var(--bg);
color: var(--fg);
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
color: var(--text-primary);
font-family: 'Season Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
-webkit-font-smoothing: antialiased;
user-select: none;
}
main {
max-width: 420px;
padding: 32px;
text-align: center;
header {
flex: none;
width: 100%;
max-width: 1460px;
margin: 0 auto;
padding: 16px 80px;
-webkit-app-region: drag;
}
.mark {
width: 56px;
height: 56px;
margin: 0 auto 20px;
border-radius: 14px;
background: var(--accent);
.wordmark {
display: block;
width: 37px;
height: 18px;
margin: 6px 0;
color: var(--text-body);
}
main {
display: flex;
flex: 1;
width: 100%;
align-items: center;
justify-content: center;
color: var(--accent-fg);
font-size: 26px;
font-weight: 600;
letter-spacing: -0.02em;
padding: 0 16px 64px;
}
.content {
display: flex;
width: 100%;
max-width: 410px;
flex-direction: column;
align-items: center;
gap: 12px;
text-align: center;
}
h1 {
font-size: 17px;
font-weight: 600;
margin: 0 0 8px;
max-width: 100%;
margin: 0;
font-size: 40px;
font-weight: 400;
letter-spacing: -0.02em;
line-height: 1.1;
text-wrap: balance;
}
p {
font-size: 13px;
line-height: 1.5;
color: var(--muted);
margin: 0 0 24px;
max-width: 100%;
margin: 0;
color: var(--text-muted);
font-size: 18px;
line-height: 28px;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: center;
margin-top: 12px;
}
button {
display: inline-flex;
height: 30px;
align-items: center;
gap: 6px;
appearance: none;
border: 1px solid var(--border);
border: 0;
background: transparent;
color: var(--fg);
color: var(--text-body);
border-radius: 8px;
padding: 7px 14px;
font-size: 13px;
font-weight: 500;
padding: 0 8px;
font: inherit;
font-size: 14px;
line-height: 20px;
text-align: left;
cursor: pointer;
outline: none;
transition:
color 150ms cubic-bezier(0.4, 0, 0.2, 1),
background-color 150ms cubic-bezier(0.4, 0, 0.2, 1),
border-color 150ms cubic-bezier(0.4, 0, 0.2, 1),
text-decoration-color 150ms cubic-bezier(0.4, 0, 0.2, 1),
fill 150ms cubic-bezier(0.4, 0, 0.2, 1),
stroke 150ms cubic-bezier(0.4, 0, 0.2, 1);
-webkit-app-region: no-drag;
}
button .label {
min-width: 0;
flex: 1;
overflow: hidden;
color: currentColor;
text-overflow: ellipsis;
white-space: nowrap;
}
button.primary {
background: var(--accent);
border-color: var(--accent);
color: var(--accent-fg);
background: var(--text-primary);
color: var(--text-inverse);
}
button:active {
opacity: 0.8;
}
.links {
margin-top: 20px;
font-size: 12px;
}
.links a {
color: var(--muted);
text-decoration: none;
margin: 0 8px;
cursor: pointer;
}
.links a:hover {
color: var(--fg);
@media (hover: hover) and (pointer: fine) {
button.primary:hover {
background: var(--text-body);
}
button.secondary:hover {
background: var(--surface-hover);
}
}
#detail {
font-size: 11px;
color: var(--muted);
margin-top: 16px;
min-height: 17px;
margin-top: 4px;
color: var(--text-muted);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 11px;
line-height: 1.5;
}
@media (max-width: 720px) {
header {
padding-right: 32px;
padding-left: 32px;
}
}
@media (max-width: 480px) {
header {
padding-right: 20px;
padding-left: 20px;
}
h1 {
font-size: 34px;
}
}
</style>
</head>
<body>
<header aria-label="Sim">
<svg class="wordmark" viewBox="0 0 441 212" role="img" aria-label="Sim">
<g fill="currentColor">
<path d="M0 160.9H29.51C29.51 169.08 32.46 175.61 38.37 180.48C44.27 185.12 52.25 187.44 62.31 187.44C73.24 187.44 81.65 185.34 87.56 181.14C93.46 176.71 96.41 170.85 96.41 163.55C96.41 158.24 94.77 153.82 91.49 150.28C88.43 146.74 82.75 143.86 74.44 141.65L46.24 135.01C32.03 131.47 21.42 126.05 14.43 118.75C7.65 111.45 4.26 101.83 4.26 89.88C4.26 79.93 6.78 71.3 11.81 64C17.05 56.7 24.16 51.06 33.12 47.08C42.3 43.09 52.8 41.1 64.6 41.1C76.41 41.1 86.57 43.2 95.1 47.41C103.84 51.61 110.62 57.47 115.43 64.99C120.46 72.52 123.08 81.48 123.3 91.87H93.79C93.57 83.47 90.84 76.94 85.59 72.3C80.34 67.65 73.02 65.33 63.62 65.33C54 65.33 46.57 67.43 41.32 71.63C36.07 75.83 33.45 81.59 33.45 88.89C33.45 99.73 41.32 107.14 57.06 111.12L85.26 118.09C98.81 121.19 108.98 126.28 115.76 133.35C122.53 140.21 125.92 149.61 125.92 161.56C125.92 171.74 123.19 180.7 117.73 188.44C112.26 195.96 104.72 201.82 95.1 206.03C85.7 210.01 74.55 212 61.65 212C42.85 212 27.87 207.35 16.72 198.06C5.57 188.77 0 176.38 0 160.9Z" />
<path d="M232.8 212H202.13L202.13 49.76H229.54V77.39C232.8 68.34 239.11 60.66 247.81 54.7C256.73 48.52 267.5 45.43 280.12 45.43C294.26 45.43 306.01 49.29 315.36 57.02C324.72 64.75 330.81 75.01 333.64 87.82H328.09C330.27 75.01 336.25 64.75 346.04 57.02C355.83 49.29 367.9 45.43 382.26 45.43C400.54 45.43 414.89 50.84 425.34 61.66C435.78 72.47 441 87.26 441 106.03V212H410.98V113.65C410.98 100.84 407.71 91.02 401.19 84.17C394.88 77.11 386.29 73.58 375.41 73.58C367.79 73.58 361.05 75.34 355.17 78.88C349.52 82.19 345.06 87.04 341.8 93.45C338.53 99.85 336.9 107.36 336.9 115.97V212H306.55V113.32C306.55 100.51 303.4 90.8 297.09 84.17C290.78 77.33 282.19 73.91 271.31 73.91C263.69 73.91 256.95 75.67 251.08 79.21C245.42 82.52 240.96 87.38 237.7 93.78C234.43 99.96 232.8 107.36 232.8 115.97V212Z" />
<path d="M184.83 20.55C184.83 31.9 175.64 41.1 164.29 41.1C152.95 41.1 143.76 31.9 143.76 20.55C143.76 9.2 152.95 0 164.29 0C175.64 0 184.83 9.2 184.83 20.55Z" />
<path d="M179.43 212H149.16V49.76C153.76 51.91 158.88 53.12 164.29 53.12C169.7 53.12 174.83 51.91 179.43 49.76V212Z" />
</g>
</svg>
</header>
<main>
<div class="mark">S</div>
<h1 id="title">Cant connect to Sim</h1>
<p id="message">
Sim couldnt reach the server. Check your internet connection, then try again.
</p>
<div class="actions">
<button class="primary" id="retry">Retry</button>
<div class="content">
<h1 id="title">Cant connect to Sim</h1>
<p id="message">
Sim couldnt reach the server. Check your internet connection, then try again.
</p>
<div class="actions">
<button class="primary" id="retry"><span class="label">Retry</span></button>
<button class="secondary" id="status"><span class="label">Check status</span></button>
</div>
<div id="detail" role="status"></div>
</div>
<div class="links">
<a id="status">Check status</a>
</div>
<div id="detail"></div>
</main>
<script>
const params = new URLSearchParams(location.search)
const copy = {
offline: {
title: 'Youre offline',
message: 'Sim needs an internet connection. Reconnect, then try again — well also retry automatically.',
message: 'Sim needs an internet connection. Reconnect, then try again. Well also retry automatically.',
},
dns: {
title: 'Cant find the server',
+14 -11
View File
@@ -8,17 +8,20 @@ export const metadata = {
export default function NotFound() {
return (
<DocsPage>
<div className='flex min-h-[70vh] flex-col items-center justify-center gap-4 text-center'>
<h1 className='bg-gradient-to-b from-[var(--brand-accent)] to-[var(--brand-accent-hover)] bg-clip-text font-semibold text-8xl text-transparent'>
404
</h1>
<h2 className='font-semibold text-2xl text-[var(--text-primary)]'>Page Not Found</h2>
<p className='text-[var(--text-muted)]'>
The page you're looking for doesn't exist or has been moved.
</p>
<ChipLink href='/' variant='primary'>
Go home
</ChipLink>
<div className='flex min-h-[60vh] flex-col items-center justify-center px-4 py-24 text-center'>
<div className='flex w-full max-w-[410px] flex-col items-center gap-3'>
<h1 className='text-balance text-[40px] text-[var(--text-primary)] leading-[110%] tracking-[-0.02em]'>
Page not found
</h1>
<p className='text-[var(--text-muted)] text-lg'>
The page you&apos;re looking for doesn&apos;t exist or has been moved.
</p>
<div className='mt-3 flex flex-wrap items-center justify-center gap-2'>
<ChipLink href='/' variant='primary'>
Return home
</ChipLink>
</div>
</div>
</div>
</DocsPage>
)
@@ -1,5 +1,6 @@
import { ChipLink } from '@sim/emcn'
import type { Metadata } from 'next'
import { StatusPage } from '@/components/status-page'
export const metadata: Metadata = {
title: 'Page Not Found',
@@ -8,19 +9,13 @@ export const metadata: Metadata = {
export default function BlogAuthorNotFound() {
return (
<main
id='main-content'
className='mx-auto flex min-h-[60vh] w-full max-w-[1460px] flex-col items-center justify-center gap-3 px-20 py-24 text-center max-sm:px-5 max-lg:px-8'
<StatusPage
title='Author not found'
description="The author you're looking for doesn't exist or has been moved."
>
<h1 className='text-balance text-[40px] text-[var(--text-primary)] leading-[110%] tracking-[-0.02em]'>
Author not found
</h1>
<p className='text-[var(--text-muted)] text-lg'>
The author you&apos;re looking for doesn&apos;t exist or has been moved.
</p>
<ChipLink variant='primary' href='/blog' className='mt-3'>
<ChipLink variant='primary' href='/blog'>
Browse blog
</ChipLink>
</main>
</StatusPage>
)
}
+6 -11
View File
@@ -1,5 +1,6 @@
import { ChipLink } from '@sim/emcn'
import type { Metadata } from 'next'
import { StatusPage } from '@/components/status-page'
export const metadata: Metadata = {
title: 'Page Not Found',
@@ -8,19 +9,13 @@ export const metadata: Metadata = {
export default function BlogNotFound() {
return (
<main
id='main-content'
className='mx-auto flex min-h-[60vh] w-full max-w-[1460px] flex-col items-center justify-center gap-3 px-20 py-24 text-center max-sm:px-5 max-lg:px-8'
<StatusPage
title='Post not found'
description="The post you're looking for doesn't exist or has been moved."
>
<h1 className='text-balance text-[40px] text-[var(--text-primary)] leading-[110%] tracking-[-0.02em]'>
Post not found
</h1>
<p className='text-[var(--text-muted)] text-lg'>
The post you&apos;re looking for doesn&apos;t exist or has been moved.
</p>
<ChipLink variant='primary' href='/blog' className='mt-3'>
<ChipLink variant='primary' href='/blog'>
Browse blog
</ChipLink>
</main>
</StatusPage>
)
}
@@ -1,5 +1,6 @@
import { ChipLink } from '@sim/emcn'
import type { Metadata } from 'next'
import { StatusPage } from '@/components/status-page'
export const metadata: Metadata = {
title: 'Page Not Found',
@@ -8,19 +9,13 @@ export const metadata: Metadata = {
export default function ComparisonNotFound() {
return (
<main
id='main-content'
className='mx-auto flex min-h-[60vh] w-full max-w-[1446px] flex-col items-center justify-center gap-3 px-12 py-24 text-center max-sm:px-5 max-lg:px-8'
<StatusPage
title='Comparison not found'
description="The comparison you're looking for doesn't exist or has been moved."
>
<h1 className='text-balance text-[40px] text-[var(--text-primary)] leading-[110%] tracking-[-0.02em]'>
Comparison not found
</h1>
<p className='text-[var(--text-muted)] text-lg'>
The comparison you&apos;re looking for doesn&apos;t exist or has been moved.
</p>
<ChipLink variant='primary' href='/comparisons' className='mt-3'>
<ChipLink variant='primary' href='/comparisons'>
Browse comparisons
</ChipLink>
</main>
</StatusPage>
)
}
@@ -1,5 +1,6 @@
import { ChipLink } from '@sim/emcn'
import type { Metadata } from 'next'
import { StatusPage } from '@/components/status-page'
export const metadata: Metadata = {
title: 'Page Not Found',
@@ -8,19 +9,13 @@ export const metadata: Metadata = {
export default function IntegrationsNotFound() {
return (
<main
id='main-content'
className='mx-auto flex min-h-[60vh] w-full max-w-[1460px] flex-col items-center justify-center gap-3 px-20 py-24 text-center max-sm:px-5 max-lg:px-8'
<StatusPage
title='Integration not found'
description="The integration you're looking for doesn't exist or has been moved."
>
<h1 className='text-balance text-[40px] text-[var(--text-primary)] leading-[110%] tracking-[-0.02em]'>
Integration not found
</h1>
<p className='text-[var(--text-muted)] text-lg'>
The integration you&apos;re looking for doesn&apos;t exist or has been moved.
</p>
<ChipLink variant='primary' href='/integrations' className='mt-3'>
<ChipLink variant='primary' href='/integrations'>
Browse integrations
</ChipLink>
</main>
</StatusPage>
)
}
@@ -1,5 +1,6 @@
import { ChipLink } from '@sim/emcn'
import type { Metadata } from 'next'
import { StatusPage } from '@/components/status-page'
export const metadata: Metadata = {
title: 'Page Not Found',
@@ -8,19 +9,13 @@ export const metadata: Metadata = {
export default function LibraryAuthorNotFound() {
return (
<main
id='main-content'
className='mx-auto flex min-h-[60vh] w-full max-w-[1460px] flex-col items-center justify-center gap-3 px-20 py-24 text-center max-sm:px-5 max-lg:px-8'
<StatusPage
title='Author not found'
description="The author you're looking for doesn't exist or has been moved."
>
<h1 className='text-balance text-[40px] text-[var(--text-primary)] leading-[110%] tracking-[-0.02em]'>
Author not found
</h1>
<p className='text-[var(--text-muted)] text-lg'>
The author you&apos;re looking for doesn&apos;t exist or has been moved.
</p>
<ChipLink variant='primary' href='/library' className='mt-3'>
<ChipLink variant='primary' href='/library'>
Browse library
</ChipLink>
</main>
</StatusPage>
)
}
+6 -11
View File
@@ -1,5 +1,6 @@
import { ChipLink } from '@sim/emcn'
import type { Metadata } from 'next'
import { StatusPage } from '@/components/status-page'
export const metadata: Metadata = {
title: 'Page Not Found',
@@ -8,19 +9,13 @@ export const metadata: Metadata = {
export default function LibraryNotFound() {
return (
<main
id='main-content'
className='mx-auto flex min-h-[60vh] w-full max-w-[1460px] flex-col items-center justify-center gap-3 px-20 py-24 text-center max-sm:px-5 max-lg:px-8'
<StatusPage
title='Post not found'
description="The post you're looking for doesn't exist or has been moved."
>
<h1 className='text-balance text-[40px] text-[var(--text-primary)] leading-[110%] tracking-[-0.02em]'>
Post not found
</h1>
<p className='text-[var(--text-muted)] text-lg'>
The post you&apos;re looking for doesn&apos;t exist or has been moved.
</p>
<ChipLink variant='primary' href='/library' className='mt-3'>
<ChipLink variant='primary' href='/library'>
Browse library
</ChipLink>
</main>
</StatusPage>
)
}
+6 -11
View File
@@ -1,5 +1,6 @@
import { ChipLink } from '@sim/emcn'
import type { Metadata } from 'next'
import { StatusPage } from '@/components/status-page'
export const metadata: Metadata = {
title: 'Page Not Found',
@@ -8,19 +9,13 @@ export const metadata: Metadata = {
export default function ModelsNotFound() {
return (
<main
id='main-content'
className='mx-auto flex min-h-[60vh] w-full max-w-[1460px] flex-col items-center justify-center gap-3 px-20 py-24 text-center max-sm:px-5 max-lg:px-8'
<StatusPage
title='Model not found'
description="The model you're looking for doesn't exist or has been moved."
>
<h1 className='text-balance text-[40px] text-[var(--text-primary)] leading-[110%] tracking-[-0.02em]'>
Model not found
</h1>
<p className='text-[var(--text-muted)] text-lg'>
The model you&apos;re looking for doesn&apos;t exist or has been moved.
</p>
<ChipLink variant='primary' href='/models' className='mt-3'>
<ChipLink variant='primary' href='/models'>
Browse models
</ChipLink>
</main>
</StatusPage>
)
}
+23 -9
View File
@@ -1,16 +1,30 @@
'use client'
import NextError from 'next/error'
import { Chip } from '@sim/emcn'
import { StatusPageContent } from '@/components/status-page'
import { season } from '@/app/_styles/fonts/season/season'
import { LogoShell } from '@/app/(landing)/components'
import '@/app/_styles/globals.css'
export default function GlobalError({ error }: { error: Error & { digest?: string } }) {
interface GlobalErrorProps {
error: Error & { digest?: string }
reset: () => void
}
export default function GlobalError({ reset }: GlobalErrorProps) {
return (
<html lang='en'>
<body>
{/* `NextError` is the default Next.js error page component. Its type
definition requires a `statusCode` prop. However, since the App Router
does not expose status codes for errors, we simply pass 0 to render a
generic error message. */}
<NextError statusCode={0} />
<html lang='en' className='light'>
<body className={`${season.variable} font-season`}>
<LogoShell center>
<StatusPageContent
title='Something went wrong'
description='An unexpected error occurred. Please try again.'
>
<Chip variant='primary' onClick={reset}>
Try again
</Chip>
</StatusPageContent>
</LogoShell>
</body>
</html>
)
+7 -9
View File
@@ -1,5 +1,6 @@
import { ChipLink } from '@sim/emcn'
import type { Metadata } from 'next'
import { StatusPageContent } from '@/components/status-page'
import { LogoShell } from '@/app/(landing)/components'
export const metadata: Metadata = {
@@ -10,17 +11,14 @@ export const metadata: Metadata = {
export default function NotFound() {
return (
<LogoShell center>
<div className='flex w-full max-w-[410px] flex-col items-center gap-3 text-center'>
<h1 className='text-balance text-[40px] text-[var(--text-primary)] leading-[110%] tracking-[-0.02em]'>
Page not found
</h1>
<p className='text-[var(--text-muted)] text-lg'>
The page you&apos;re looking for doesn&apos;t exist or has been moved.
</p>
<ChipLink variant='primary' href='/' className='mt-3'>
<StatusPageContent
title='Page not found'
description="The page you're looking for doesn't exist or has been moved."
>
<ChipLink variant='primary' href='/'>
Return home
</ChipLink>
</div>
</StatusPageContent>
</LogoShell>
)
}
@@ -1,7 +1,7 @@
'use client'
import { type ReactNode, useEffect } from 'react'
import { Button } from '@sim/emcn'
import { Chip } from '@sim/emcn'
import { TriangleAlert } from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
@@ -30,7 +30,7 @@ interface ErrorShellProps {
/**
* Centered layout shared by the workspace error boundary and not-found page.
* Renders a framed glyph, serif headline, supporting paragraph, and a row of
* Renders a framed glyph, brand headline, supporting paragraph, and a row of
* action buttons.
*/
export function ErrorShell({ title, description, icon, children }: ErrorShellProps) {
@@ -80,9 +80,9 @@ export function ErrorState({
return (
<ErrorShell title={title} description={description} icon={icon}>
{children}
<Button variant='primary' size='md' onClick={reset}>
Refresh
</Button>
<Chip variant='primary' onClick={reset}>
Try again
</Chip>
</ErrorShell>
)
}
@@ -6,7 +6,7 @@ import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@sim/emcn', () => ({
Button: ({ children, ...props }: { children: ReactNode } & Record<string, unknown>) => (
Chip: ({ children, ...props }: { children: ReactNode } & Record<string, unknown>) => (
<button {...props}>{children}</button>
),
}))
@@ -58,7 +58,7 @@ describe('LogsError boundary', () => {
)
})
it('calls reset when the refresh action is clicked', () => {
it('calls reset when the retry action is clicked', () => {
const reset = vi.fn()
const error = Object.assign(new Error('boom'), { digest: 'abc123' })
@@ -67,7 +67,7 @@ describe('LogsError boundary', () => {
})
act(() => {
findButtonByText('Refresh').click()
findButtonByText('Try again').click()
})
expect(reset).toHaveBeenCalledTimes(1)
@@ -1,16 +1,11 @@
'use client'
import { Component, type ErrorInfo, type ReactNode } from 'react'
import { Button } from '@sim/emcn'
import { RefreshCw } from '@sim/emcn/icons'
import { Chip } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { truncate } from '@sim/utils/string'
import { ReactFlowProvider } from 'reactflow'
import { captureClientEvent, captureClientException } from '@/lib/posthog/client'
import { Panel } from '@/app/workspace/[workspaceId]/w/[workflowId]/components'
import { usePreventZoom } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import { Sidebar } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar'
import { readCollapsedCookie } from '@/stores/sidebar/store'
import { ErrorShell } from '@/app/workspace/[workspaceId]/components'
const logger = createLogger('ErrorBoundary')
@@ -24,51 +19,19 @@ interface ErrorUIProps {
title?: string
message?: string
onReset?: () => void
fullScreen?: boolean
}
export function ErrorUI({
title = 'Something went wrong',
message = 'An unexpected error occurred. Please try again or refresh the page.',
onReset,
fullScreen = false,
}: ErrorUIProps) {
const preventZoomRef = usePreventZoom()
if (!fullScreen) {
return (
<div className='flex h-full flex-1 items-center justify-center'>
<div className='flex flex-col items-center gap-4 text-center'>
<div className='flex flex-col gap-2'>
<h2 className='text-[var(--text-primary)] text-md'>{title}</h2>
<p className='max-w-[300px] text-[var(--text-tertiary)] text-small'>{message}</p>
</div>
<Button variant='default' size='sm' onClick={onReset ?? (() => window.location.reload())}>
<RefreshCw className='mr-1.5 size-[14px]' />
Try again
</Button>
</div>
</div>
)
}
return (
<div ref={preventZoomRef} className='flex h-screen w-full flex-col bg-[var(--surface-1)]'>
<Sidebar isCollapsed={readCollapsedCookie()} />
<div className='relative flex flex-1'>
<div className='pointer-events-none absolute inset-0 flex items-center justify-center'>
<div className='pointer-events-none flex flex-col items-center gap-4'>
<h3 className='text-[var(--text-primary)] text-md'>{title}</h3>
<p className='max-w-sm text-center text-[var(--text-tertiary)] text-sm'>{message}</p>
</div>
</div>
<ReactFlowProvider>
<Panel />
</ReactFlowProvider>
</div>
</div>
<ErrorShell title={title} description={message}>
<Chip variant='primary' onClick={onReset ?? (() => window.location.reload())}>
Try again
</Chip>
</ErrorShell>
)
}
+1
View File
@@ -0,0 +1 @@
export { StatusPage, StatusPageContent } from './status-page'
@@ -0,0 +1,38 @@
import type { ReactNode } from 'react'
interface StatusPageContentProps {
title: string
description: string
children: ReactNode
}
/** Shared centered content for full-page not-found and fatal-error states. */
export function StatusPageContent({ title, description, children }: StatusPageContentProps) {
return (
<div className='flex w-full max-w-[410px] flex-col items-center gap-3 text-center'>
<h1 className='text-balance text-[40px] text-[var(--text-primary)] leading-[110%] tracking-[-0.02em]'>
{title}
</h1>
<p className='text-[var(--text-muted)] text-lg'>{description}</p>
<div className='mt-3 flex flex-wrap items-center justify-center gap-2'>{children}</div>
</div>
)
}
interface StatusPageProps extends StatusPageContentProps {
id?: string
}
/** Shared landing-shell page frame for route-specific not-found states. */
export function StatusPage({ title, description, children, id = 'main-content' }: StatusPageProps) {
return (
<main
id={id}
className='mx-auto flex min-h-[60vh] w-full max-w-[1460px] flex-col items-center justify-center px-20 py-24 max-sm:px-5 max-lg:px-8'
>
<StatusPageContent title={title} description={description}>
{children}
</StatusPageContent>
</main>
)
}