<script lang="ts">
import "#lib/components/landing/landing.css";
import "#lib/components/landing/material.css";
import { resolve } from "$app/paths";
import EmbossFilter from "#lib/components/landing/EmbossFilter.svelte";
import Navbar from "#lib/components/landing/shell/Navbar.svelte";
import ArrowRight from "$icon/arrow-right";
import Container from "#lib/components/landing/ui/Container.svelte";
/**
* The 404 page, drawn with the landing's tokens: the side grids, the
* navbar, and two cut ends of the thread, one trailing in from the top left and one out to
* the bottom right, around the copy. Signed-in visitors keep the app's shell, so the navbar
* is left out there (`navbar`).
*
* On arrival the copy rises in and the two ends draw in from the page's corners, fraying
* where they meet the gap. Hovering the way home nudges them towards each other.
*
* Every class carries `nf-`: the landing's section styles (features.css) are global under
* `.landing`, and preloading `/` from any link here brings them in.
*/
interface Props {
/** Draw the landing navbar (signed-out visitors, where the page owns the shell). */
navbar?: boolean;
}
let { navbar = true }: Props = $props();
/*
* The strands, as placed in the 1440px frame: each vector's centre (x, y), its size
* unrotated (w, h), its turn and which end of the thread it belongs to (`a` the top left,
* `b` the bottom right). The frayed ends share one vector. Both lines run from their
* vector's bottom edge to the fray, so one sweep draws either in.
*/
const STRANDS = [
{ part: "line", end: "a", x: 227.09, y: 205.5, w: 108.95, h: 283.19, turn: 120 },
{ part: "fray", end: "a", x: 316.94, y: 319.28, w: 12.46, h: 15.11, turn: 120 },
{ part: "fray", end: "b", x: 824.3, y: 469.77, w: 12.46, h: 15.11, turn: -60 },
{ part: "line", end: "b", x: 1061.45, y: 675.77, w: 189.01, h: 621.08, turn: -60 }
] as const;
</script>
<div class={["landing", "nf", !navbar && "nf-embedded"]}>
<div class="page-grid l" aria-hidden="true"></div>
<div class="page-grid r" aria-hidden="true"></div>
<EmbossFilter />
{#if navbar}
<Navbar />
{/if}
<main class="nf-stage">
<div class="nf-art" aria-hidden="true">
{#each STRANDS as strand, i (i)}
<i
class={["nf-strand", `nf-${strand.part}`, `nf-${strand.end}`]}
style:--x="{strand.x}px"
style:--y="{strand.y}px"
style:--w="{strand.w}px"
style:--h="{strand.h}px"
style:--turn="{strand.turn}deg"
></i>
{/each}
</div>
<Container class="nf-copy">
<div class="nf-text">
<h1
data-testid="errorTitle"
data-status="404"
class="l-heading l-heading-section l-heading-break"
>
<span class="solid nf-rise" style:--delay="0.1s">404</span>
<span class="dim nf-rise" style:--delay="0.2s">This page doesn’t exist.</span>
</h1>
<p data-testid="errorMessage" class="l-body l-trim nf-rise" style:--delay="0.3s">
It may have moved, or the link may be wrong.
</p>
</div>
<div class="nf-action nf-rise" style:--delay="0.4s">
<a
href={resolve("/")}
class="inline-flex w-fit items-center gap-2xs border-b border-border-strong pb-0.5 text-foreground-default hover:border-foreground-default"
>
Go to the homepage
<ArrowRight class="size-4" aria-hidden="true" />
</a>
</div>
</Container>
</main>
</div>
<style>
.nf {
display: flex;
flex-direction: column;
}
.nf-embedded {
flex: 1;
min-height: 0;
}
.nf-stage {
position: relative;
display: flex;
flex: 1;
flex-direction: column;
justify-content: center;
overflow: clip;
padding-block: 96px;
}
/* The art's frame, 1440 × 828, centred on the page, with the copy's middle on the stage's. */
.nf-art {
position: absolute;
top: calc(50% - 423.5px);
left: calc(50% - 720px);
width: 1440px;
height: 828px;
pointer-events: none;
@media (width < 64rem) {
display: none;
}
}
.nf-strand {
position: absolute;
top: calc(var(--y) - var(--h) / 2);
left: calc(var(--x) - var(--w) / 2);
width: var(--w);
height: var(--h);
background: var(--thread);
mask: var(--part) center / 100% 100% no-repeat;
rotate: var(--turn);
transition: translate 0.6s var(--ease-out);
}
.nf-strand.nf-line.nf-a {
--part: url("./assets/not-found/strand-top.svg");
}
.nf-strand.nf-line.nf-b {
--part: url("./assets/not-found/strand-bottom.svg");
}
.nf-strand.nf-fray {
--part: url("./assets/not-found/strand-end.svg");
}
/* Arriving: each line sweeps in from the corner, then its frayed end springs out. */
.nf-line {
animation: nf-draw 1.2s var(--ease-in-out) 0.2s both;
}
.nf-fray {
animation: nf-fray 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) 1.25s both;
}
/* Heading home: the two ends lean in towards each other. */
.nf-stage:has(.nf-action :global(a:is(:hover, :focus-visible))) {
.nf-a {
translate: 10px 8px;
}
.nf-b {
translate: -10px -8px;
}
}
.nf-stage :global(.nf-copy) {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 48px;
@media (width >= 64rem) {
padding-inline-start: 188px;
}
}
.nf-text {
display: flex;
flex-direction: column;
gap: 20px;
}
.nf-text p {
max-width: 420px;
}
.nf-rise {
animation: nf-rise 0.7s var(--ease-out) var(--delay, 0s) both;
}
@keyframes nf-rise {
from {
opacity: 0;
translate: 0 10px;
}
}
@keyframes nf-draw {
from {
clip-path: inset(100% 0 0 0);
}
to {
clip-path: inset(0);
}
}
@keyframes nf-fray {
from {
opacity: 0;
scale: 0.3;
}
}
</style>