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:
parent
a5d30a171c
commit
7e42b03f7e
9 changed files with 137 additions and 4 deletions
12
README.md
12
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
|
||||
|
|
|
|||
|
|
@ -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
12
app/not-found.tsx
Normal 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 />;
|
||||
}
|
||||
50
components/NotFoundScreen.tsx
Normal file
50
components/NotFoundScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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í",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "freezmo",
|
||||
"version": "0.5.5",
|
||||
"version": "0.5.6",
|
||||
"private": true,
|
||||
"license": "GPL-3.0-only",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue