From 7e42b03f7e790e2a322b80199e576a98d828a2fe Mon Sep 17 00:00:00 2001 From: Martin Dvorak Date: Wed, 14 Oct 2026 15:27:47 +0000 Subject: [PATCH] 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. --- README.md | 12 +++++++++ app/globals.css | 18 +++++++++++++ app/not-found.tsx | 12 +++++++++ components/NotFoundScreen.tsx | 50 +++++++++++++++++++++++++++++++++++ lib/i18n-dict.ts | 14 ++++++++++ package.json | 2 +- public/sw.js | 11 +++++--- tests/contrast.mjs | 1 + tests/regression.mjs | 21 +++++++++++++++ 9 files changed, 137 insertions(+), 4 deletions(-) create mode 100644 app/not-found.tsx create mode 100644 components/NotFoundScreen.tsx diff --git a/README.md b/README.md index 5191581..e05e0fe 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/globals.css b/app/globals.css index f3b6b74..b6c3a47 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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: diff --git a/app/not-found.tsx b/app/not-found.tsx new file mode 100644 index 0000000..1c7c3a6 --- /dev/null +++ b/app/not-found.tsx @@ -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 ; +} diff --git a/components/NotFoundScreen.tsx b/components/NotFoundScreen.tsx new file mode 100644 index 0000000..4f79e8f --- /dev/null +++ b/components/NotFoundScreen.tsx @@ -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 ( +
+
+
+ +
+

+ + 404 + +

+

{t("notFound.title")}

+

{t("notFound.hint")}

+
+ + + {t("notFound.home")} + + + + {t("notFound.chat")} + +
+
+
+ ); +} diff --git a/lib/i18n-dict.ts b/lib/i18n-dict.ts index 1994f2a..b21053a 100644 --- a/lib/i18n-dict.ts +++ b/lib/i18n-dict.ts @@ -630,6 +630,13 @@ const en: Record = { "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 = { "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í", diff --git a/package.json b/package.json index 7cb5621..a3a7213 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "freezmo", - "version": "0.5.5", + "version": "0.5.6", "private": true, "license": "GPL-3.0-only", "scripts": { diff --git a/public/sw.js b/public/sw.js index 77e5bc4..67ea82f 100644 --- a/public/sw.js +++ b/public/sw.js @@ -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); - const cache = await caches.open(SHELL_CACHE); - cache.put("/", response.clone()).catch(() => {}); + // 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); diff --git a/tests/contrast.mjs b/tests/contrast.mjs index 7ad1d2b..1e4b604 100644 --- a/tests/contrast.mjs +++ b/tests/contrast.mjs @@ -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) { diff --git a/tests/regression.mjs b/tests/regression.mjs index b2be9bf..8f57969 100644 --- a/tests/regression.mjs +++ b/tests/regression.mjs @@ -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();