Custom Frutiger Aero 404

app/not-found.tsx renders a standalone screen: the root layout's ice
scene is the backdrop, the glass card/rim/bubbles come from the shared
recipes (full Aero with that theme on, frosted glass in light/dark),
and the mascot is the scene's glossy ice orb at full strength with a
snowflake and a "404" chip. Two ways out - back to the library and
into the chat - both 44px targets, en + cs.

The regression suite pins the 404 status, both links and the Czech
copy; the pixel-contrast scan covers the screen in all three themes.
The service worker no longer caches non-OK navigations as the offline
shell (a typo'd URL used to write the error page into the shell
cache). Version 0.5.6, SW -> freezmo-v78.
This commit is contained in:
Martin Dvořák 2026-10-14 15:27:47 +00:00
commit 7e42b03f7e
9 changed files with 137 additions and 4 deletions

View file

@ -1423,6 +1423,18 @@ Things I chose while building, with the reasoning:
(measured: 268..1280 at a 1280 window) — a flat pane floating 40px inside the scene read as
"cut off next to the sidebar".
88. **A custom 404, Frutiger Aero style.** Unknown URLs used to fall through to Next's default
"This page could not be found". `app/not-found.tsx` now renders a standalone screen — the root
layout's ice scene is the backdrop, and the glass card, the rim and the rising bubbles come from
the shared recipes, so it is full Frutiger Aero while that theme is on and stays in the
frosted-glass language in light and dark. It wears the scene's glossy ice orb
(`.not-found-orb`, at full strength so the snowflake reads) with a "404" chip, "This page melted
away", and two ways out: back to the library and into the chat (44px targets, en + cs). The
regression suite pins the 404 status, both links and the Czech copy; the pixel-contrast scan now
covers the screen in all three themes. The service worker also stopped caching non-OK
navigations as the offline shell — a typo'd URL used to write the error page into the shell
cache.
87. **Chat's chrome docks into the rail (the sidebar seam).** The chat header and composer are
full-bleed bands inside the main pane; at desktop widths that pane starts right at the sidebar,
so both bands ended in a hard vertical edge beside the floating rail card — the 12px gutter

View file

@ -1045,6 +1045,24 @@ body:has(.overlay-open) :is(.app-header, .segmented, .tabbar, .cookie-notice) {
.ice-orb-3 { display: none; }
}
/* The 404 mascot: the scene's glossy ice orb, at full strength so it reads on
the card glass in every theme (the scene's own orbs run at 0.22–0.6). */
.not-found-orb {
border-radius: 50%;
background: radial-gradient(
circle at 32% 26%,
rgb(255 255 255 / 0.98) 0%,
rgb(219 242 255 / 0.92) 26%,
rgb(125 200 245 / 0.95) 58%,
rgb(38 121 208 / 1) 100%
);
box-shadow:
inset -10px -14px 24px -12px rgb(3 74 124 / 0.6),
inset 8px 12px 20px -10px rgb(255 255 255 / 0.95),
inset 0 0 0 1px rgb(255 255 255 / 0.5),
0 18px 36px -18px rgb(2 62 110 / 0.6);
}
@layer components {
/* Frosted ice glass ------------------------------------------------------
Two recipes now:

12
app/not-found.tsx Normal file
View file

@ -0,0 +1,12 @@
import type { Metadata } from "next";
import { NotFoundScreen } from "@/components/NotFoundScreen";
export const metadata: Metadata = {
title: "Page not found",
};
/** Custom 404 for unknown URLs (and notFound() calls). */
export default function NotFound() {
return <NotFoundScreen />;
}

View file

@ -0,0 +1,50 @@
"use client";
import Link from "next/link";
import { ChatIcon, HomeIcon, SnowflakeIcon } from "@/components/icons";
import { useI18n } from "@/lib/i18n";
/**
* The custom 404. The root layout already paints the ice scene behind it, so
* this is just the glass card: in Frutiger Aero the wallpaper bake, the bright
* rim and the rising bubbles all come from the shared recipes, and the glossy
* orb is the scene's own ice sphere at full strength. Standalone (no rail, no
* tab bar), like the offline screen.
*/
export function NotFoundScreen() {
const { t } = useI18n();
return (
<main className="frost-bg grid min-h-dvh place-items-center px-4 py-10">
<div className="card w-full max-w-md space-y-4 p-6 text-center sm:p-8">
<div className="not-found-orb mx-auto grid size-24 place-items-center">
<SnowflakeIcon className="size-10 text-white drop-shadow-[0_2px_6px_rgb(3_74_124/0.6)]" />
</div>
<p className="pt-1">
<span className="glass-chip inline-flex items-center rounded-full px-3 py-1 text-[12px] font-semibold tracking-[0.18em] text-[var(--text-subtle)]">
404
</span>
</p>
<h1 className="text-xl font-semibold tracking-[-0.01em] text-[var(--text)]">{t("notFound.title")}</h1>
<p className="mx-auto max-w-sm text-sm leading-relaxed text-[var(--text-muted)]">{t("notFound.hint")}</p>
<div className="flex flex-wrap justify-center gap-2 pt-2">
<Link
href="/"
className="btn-primary inline-flex h-11 items-center justify-center gap-2 rounded-[14px] px-5 text-[15px] font-medium"
>
<HomeIcon className="size-4" />
{t("notFound.home")}
</Link>
<Link
href="/chat"
className="btn-secondary inline-flex h-11 items-center justify-center gap-2 rounded-[14px] px-5 text-[15px] font-medium"
>
<ChatIcon className="size-4" />
{t("notFound.chat")}
</Link>
</div>
</div>
</main>
);
}

View file

@ -630,6 +630,13 @@ const en: Record<string, string> = {
"offline.body":
"Freezmo needs a connection for generating decks and grading answers. Your saved decks are still in this browser — reconnect and reload.",
/* not found ------------------------------------------------------- */
"notFound.title": "This page melted away",
"notFound.hint":
"The link may be old, or the address has a typo. Your decks are safe in this browser — head back home.",
"notFound.home": "Back to library",
"notFound.chat": "Ask the chat instead",
/* activity types -------------------------------------------------- */
"type.flashcard": "Flashcard",
"type.multipleChoice": "Multiple choice",
@ -1614,6 +1621,13 @@ const cs: Record<string, string> = {
"offline.body":
"Freezmo potřebuje připojení k vytváření sad a známkování odpovědí. Uložené sady máš pořád v tomto prohlížeči — připoj se a načti stránku znovu.",
/* not found ------------------------------------------------------- */
"notFound.title": "Tahle stránka se rozpustila",
"notFound.hint":
"Odkaz může být zastaralý nebo je v adrese překlep. Tvoje sady jsou v tomto prohlížeči v pořádku — vrať se domů.",
"notFound.home": "Zpět do knihovny",
"notFound.chat": "Zeptat se chatu",
/* activity types -------------------------------------------------- */
"type.flashcard": "Kartička",
"type.multipleChoice": "Výběr z možností",

View file

@ -1,6 +1,6 @@
{
"name": "freezmo",
"version": "0.5.5",
"version": "0.5.6",
"private": true,
"license": "GPL-3.0-only",
"scripts": {

View file

@ -9,7 +9,7 @@
*
* Bumping CACHE_VERSION is how a new deploy invalidates old assets.
*/
const CACHE_VERSION = "freezmo-v77";
const CACHE_VERSION = "freezmo-v78";
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
const ASSET_CACHE = `${CACHE_VERSION}-assets`;
@ -104,8 +104,13 @@ self.addEventListener("fetch", (event) => {
(async () => {
try {
const response = await fetch(request);
// Only a real page becomes the offline shell: caching a 404 (a typo
// in the URL, a dead link) would serve the error page to every
// later offline navigation.
if (response.ok) {
const cache = await caches.open(SHELL_CACHE);
cache.put("/", response.clone()).catch(() => {});
}
return response;
} catch {
const cache = await caches.open(SHELL_CACHE);

View file

@ -44,6 +44,7 @@ const screens = [
["stats", "/stats", null],
["exam", "/exam", null],
["settings", "/", "settings"],
["notfound", "/no-such-page", null],
];
for (const [label, path, mode] of screens) {

View file

@ -139,5 +139,26 @@ const browser = await launchBrowser();
await context.close();
}
/* 6. custom 404 ------------------------------------------------------ */
{
const { context, page } = await makePage(browser, { width: 390, height: 844, deviceScaleFactor: 1 });
await seed(page, { decks: [DECK] });
const response = await page.goto(`${BASE}/no-such-page`, { waitUntil: "networkidle" });
await page.waitForTimeout(800);
check("404: an unknown URL answers 404", response !== null && response.status() === 404);
check("404: the custom screen renders", await page.getByRole("heading", { name: "This page melted away" }).isVisible());
check("404: it offers the way home", await page.getByRole("link", { name: "Back to library" }).isVisible());
check("404: the chat escape hatch is there", await page.getByRole("link", { name: "Ask the chat instead" }).isVisible());
check("404: it rides on the glass card", (await page.locator("main .card").count()) === 1);
await context.close();
const cs = await makePage(browser, { width: 390, height: 844, deviceScaleFactor: 1 });
await seed(cs.page, { decks: [DECK], locale: "cs" });
await cs.page.goto(`${BASE}/no-such-page`, { waitUntil: "networkidle" });
await cs.page.waitForTimeout(800);
check("404: Czech copy renders", await cs.page.getByRole("heading", { name: "Tahle stránka se rozpustila" }).isVisible());
await cs.context.close();
}
await browser.close();
finish();