refactor(errors): redesign error pages in editorial style

Replace the emoji/gradient card with an editorial layout: small lucide
glyph, oversized error code, hairline-divided serif bible quote.

Extract shared ErrorView + SectionError components and a bilingual
string helper. Add +error.svelte at each section root (faith, recipes,
fitness, tasks, cospend) so errors render inside the correct layout and
inherit the section-specific header/nav. Catch-all [...rest]/+page.ts
stubs route unmatched URLs through the section layout so the right
error page catches them.
This commit is contained in:
2026-04-20 19:53:50 +02:00
parent 97e8734709
commit fbd09fbdae
16 changed files with 505 additions and 788 deletions
+222
View File
@@ -0,0 +1,222 @@
<script lang="ts">
import type { Snippet, Component } from 'svelte';
import { Lock, Ban, SearchX, TriangleAlert, CircleAlert } from '@lucide/svelte';
interface BibleQuote {
text: string;
reference: string;
}
interface Props {
status: number;
title: string;
description: string;
details?: string;
bibleQuote?: BibleQuote | null;
isEnglish?: boolean;
icon?: Component;
actions?: Snippet;
}
let {
status,
title,
description,
details,
bibleQuote,
isEnglish = true,
icon,
actions
}: Props = $props();
function defaultIcon(status: number): Component {
switch (status) {
case 401: return Lock;
case 403: return Ban;
case 404: return SearchX;
case 500: return TriangleAlert;
default: return CircleAlert;
}
}
let Icon = $derived(icon ?? defaultIcon(status));
let openQuote = $derived(isEnglish ? '\u201C' : '\u201E');
let closeQuote = $derived(isEnglish ? '\u201D' : '\u201C');
</script>
<main class="error-page">
<article class="error-article">
<header class="eyebrow">
<Icon size={14} strokeWidth={1.5} aria-hidden="true" />
<span class="eyebrow-label">
{isEnglish ? 'Error' : 'Fehler'}
</span>
</header>
<div class="code" aria-hidden="true">{status}</div>
<h1 class="title">{title}</h1>
<p class="description">{description}</p>
{#if details}
<p class="details">{details}</p>
{/if}
{#if actions}
<nav class="actions">
{@render actions()}
</nav>
{/if}
{#if bibleQuote}
<hr class="rule" />
<figure class="quote">
<blockquote class="quote-text">
{openQuote}{bibleQuote.text}{closeQuote}
</blockquote>
<figcaption class="quote-reference">{bibleQuote.reference}</figcaption>
</figure>
{/if}
</article>
</main>
<style>
.error-page {
min-height: calc(100vh - 6rem);
display: flex;
align-items: flex-start;
justify-content: center;
padding: clamp(3rem, 10vh, 8rem) 1.5rem 4rem;
background: var(--color-bg-primary);
}
.error-article {
width: 100%;
max-width: 640px;
}
.eyebrow {
display: inline-flex;
align-items: center;
gap: 0.5rem;
color: var(--color-text-tertiary);
margin-bottom: 1rem;
}
.eyebrow-label {
font-size: 0.75rem;
font-weight: 500;
letter-spacing: 0.18em;
text-transform: uppercase;
}
.code {
font-size: clamp(7rem, 22vw, 14rem);
font-weight: 200;
line-height: 0.9;
letter-spacing: -0.05em;
color: var(--color-text-primary);
margin: 0 0 1.5rem;
font-variant-numeric: lining-nums tabular-nums;
}
.title {
font-size: clamp(1.5rem, 3vw, 2rem);
font-weight: 500;
letter-spacing: -0.01em;
color: var(--color-text-primary);
margin: 0 0 0.5rem;
}
.description {
font-size: 1.0625rem;
line-height: 1.55;
color: var(--color-text-secondary);
margin: 0;
max-width: 44ch;
}
.details {
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
font-size: 0.8125rem;
color: var(--color-text-tertiary);
margin: 1.25rem 0 0;
padding-left: 0.875rem;
border-left: 1px solid var(--color-border);
max-width: 44ch;
}
.actions {
display: flex;
gap: 1.75rem;
margin: 2.25rem 0 0;
flex-wrap: wrap;
}
:global(.error-article .link) {
background: none;
border: none;
padding: 0;
font: inherit;
font-size: 0.95rem;
cursor: pointer;
color: var(--color-text-secondary);
position: relative;
transition: color var(--transition-normal, 200ms ease);
}
:global(.error-article .link::after) {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: -2px;
height: 1px;
background: currentColor;
opacity: 0.35;
transition: opacity var(--transition-normal, 200ms ease);
}
:global(.error-article .link:hover) { color: var(--color-text-primary); }
:global(.error-article .link:hover::after) { opacity: 1; }
:global(.error-article .link-primary) { color: var(--color-primary); }
:global(.error-article .link-primary:hover) {
color: var(--color-primary-hover, var(--color-primary));
}
.rule {
border: none;
border-top: 1px solid var(--color-border);
margin: 4rem 0 2.5rem;
width: 3rem;
}
.quote {
margin: 0;
}
.quote-text {
font-family: Georgia, "Times New Roman", Cambria, serif;
font-style: italic;
font-size: clamp(1.25rem, 2.2vw, 1.625rem);
line-height: 1.5;
color: var(--color-text-primary);
margin: 0 0 1rem;
text-wrap: balance;
hyphens: auto;
}
.quote-reference {
font-size: 0.75rem;
font-weight: 500;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--color-text-tertiary);
}
@media (max-width: 560px) {
.actions { gap: 1.25rem; }
.rule { margin: 3rem 0 2rem; }
}
</style>
+59
View File
@@ -0,0 +1,59 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import ErrorView from './ErrorView.svelte';
import { getErrorTitle, getErrorDescription, errorLabels, pick } from '$lib/js/errorStrings';
import type { Snippet } from 'svelte';
interface Props {
/** Destination of the section's primary "home" link, e.g. "/rezepte" */
sectionHref: string;
/** Label for that link in both languages */
sectionLabel: { en: string; de: string };
/** Override language detection (defaults to error.lang from handleError) */
isEnglish?: boolean;
/** Extra action buttons rendered before the defaults */
extraActions?: Snippet;
}
let { sectionHref, sectionLabel, isEnglish: isEnglishProp, extraActions }: Props = $props();
let status = $derived($page.status);
let error = $derived($page.error as any);
let bibleQuote = $derived(error?.bibleQuote);
let detectedEnglish = $derived(error?.lang === 'en');
let isEnglish = $derived(isEnglishProp ?? detectedEnglish);
let details = $derived(error?.details);
let label = $derived(pick(sectionLabel, isEnglish));
function goBack() {
if (window.history.length > 1) window.history.back();
else goto(sectionHref);
}
function login() { goto('/login'); }
</script>
<ErrorView
{status}
title={getErrorTitle(status, isEnglish)}
description={getErrorDescription(status, isEnglish)}
{details}
{bibleQuote}
{isEnglish}
>
{#snippet actions()}
{#if extraActions}{@render extraActions()}{/if}
{#if status === 401}
<button class="link link-primary" onclick={login}>{pick(errorLabels.login, isEnglish)}</button>
<a class="link" href={sectionHref}>{label}</a>
{:else if status === 500}
<button class="link link-primary" onclick={goBack}>{pick(errorLabels.tryAgain, isEnglish)}</button>
<a class="link" href={sectionHref}>{label}</a>
{:else}
<a class="link link-primary" href={sectionHref}>{label}</a>
<button class="link" onclick={goBack}>{pick(errorLabels.goBack, isEnglish)}</button>
{/if}
{/snippet}
</ErrorView>