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
|
(measured: 268..1280 at a 1280 window) — a flat pane floating 40px inside the scene read as
|
||||||
"cut off next to the sidebar".
|
"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
|
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,
|
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
|
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; }
|
.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 {
|
@layer components {
|
||||||
/* Frosted ice glass ------------------------------------------------------
|
/* Frosted ice glass ------------------------------------------------------
|
||||||
Two recipes now:
|
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":
|
"offline.body":
|
||||||
"Freezmo needs a connection for generating decks and grading answers. Your saved decks are still in this browser — reconnect and reload.",
|
"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 -------------------------------------------------- */
|
/* activity types -------------------------------------------------- */
|
||||||
"type.flashcard": "Flashcard",
|
"type.flashcard": "Flashcard",
|
||||||
"type.multipleChoice": "Multiple choice",
|
"type.multipleChoice": "Multiple choice",
|
||||||
|
|
@ -1614,6 +1621,13 @@ const cs: Record<string, string> = {
|
||||||
"offline.body":
|
"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.",
|
"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 -------------------------------------------------- */
|
/* activity types -------------------------------------------------- */
|
||||||
"type.flashcard": "Kartička",
|
"type.flashcard": "Kartička",
|
||||||
"type.multipleChoice": "Výběr z možností",
|
"type.multipleChoice": "Výběr z možností",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "freezmo",
|
"name": "freezmo",
|
||||||
"version": "0.5.5",
|
"version": "0.5.6",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "GPL-3.0-only",
|
"license": "GPL-3.0-only",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
11
public/sw.js
11
public/sw.js
|
|
@ -9,7 +9,7 @@
|
||||||
*
|
*
|
||||||
* Bumping CACHE_VERSION is how a new deploy invalidates old assets.
|
* 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 SHELL_CACHE = `${CACHE_VERSION}-shell`;
|
||||||
const ASSET_CACHE = `${CACHE_VERSION}-assets`;
|
const ASSET_CACHE = `${CACHE_VERSION}-assets`;
|
||||||
|
|
||||||
|
|
@ -104,8 +104,13 @@ self.addEventListener("fetch", (event) => {
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(request);
|
const response = await fetch(request);
|
||||||
const cache = await caches.open(SHELL_CACHE);
|
// Only a real page becomes the offline shell: caching a 404 (a typo
|
||||||
cache.put("/", response.clone()).catch(() => {});
|
// 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;
|
return response;
|
||||||
} catch {
|
} catch {
|
||||||
const cache = await caches.open(SHELL_CACHE);
|
const cache = await caches.open(SHELL_CACHE);
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ const screens = [
|
||||||
["stats", "/stats", null],
|
["stats", "/stats", null],
|
||||||
["exam", "/exam", null],
|
["exam", "/exam", null],
|
||||||
["settings", "/", "settings"],
|
["settings", "/", "settings"],
|
||||||
|
["notfound", "/no-such-page", null],
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const [label, path, mode] of screens) {
|
for (const [label, path, mode] of screens) {
|
||||||
|
|
|
||||||
|
|
@ -139,5 +139,26 @@ const browser = await launchBrowser();
|
||||||
await context.close();
|
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();
|
await browser.close();
|
||||||
finish();
|
finish();
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue