New Aero background (Gemini-made), old credit removed

The scene is the user's own Frutiger Aero image now: green hills, a river,
an eco-city, bubbles and daisies. Re-encoded to public/aero/background.webp
(152 kB) with a fresh 3 kB pre-blurred variant for the glass, and the
WallpaperSafari credit line, its i18n keys and the README credit are gone
(the image is made by the user with Gemini).

Contrast: 0 below-minimum findings and 0 axe violations, all three themes.
Version 0.2.8, SW -> freezmo-v60.
This commit is contained in:
Martin Dvořák 2026-10-13 08:10:05 +00:00
commit ff58fdd71d
14 changed files with 151 additions and 102 deletions

View file

@ -1414,10 +1414,9 @@ Things I chose while building, with the reasoning:
usual ceiling. usual ceiling.
63. **Frutiger Aero is the third theme.** Settings → Theme offers Light, Dark and Frutiger Aero. 63. **Frutiger Aero is the third theme.** Settings → Theme offers Light, Dark and Frutiger Aero.
Aero is not a dark/light variant — it replaces the palette and the scene: the wallpaper is Aero is not a dark/light variant — it replaces the palette and the scene: the wallpaper is a
*“The S Aesthetic That You Definitely Recognize Frutiger Aero”* via WallpaperSafari Frutiger Aero scene made with Gemini (re-encoded to WebP in `public/aero/`), with ten drawn
(<https://wallpapersafari.com/w/IlWuz7>, re-encoded to a 237 kB WebP in `public/aero/` and bubbles rising over it (`.ice-bubble` — `display: none`
credited in Settings), with ten drawn bubbles rising over it (`.ice-bubble` — `display: none`
outside aero so they cost nothing elsewhere, still under reduced motion). Buttons and segments outside aero so they cost nothing elsewhere, still under reduced motion). Buttons and segments
are **liquid glass** — transparent, glossed, with a bright rim — and every surface shows a are **liquid glass** — transparent, glossed, with a bright rim — and every surface shows a
**pre-blurred copy of the wallpaper** (`public/aero/background-blur.webp`, 3 kB, glued to the **pre-blurred copy of the wallpaper** (`public/aero/background-blur.webp`, 3 kB, glued to the

View file

@ -257,10 +257,9 @@
--ice-grain-opacity: 0.02; --ice-grain-opacity: 0.02;
} }
/* The scene is the wallpaper itself: Frutiger Aero 4k Wallpaper (1920x1200) /* The scene is the wallpaper itself (a Frutiger Aero scene made with Gemini,
via WallpaperSafari — https://wallpapersafari.com/w/sgI2Dq — credited in in public/aero/). Only the moving bubbles are drawn on top; the image
Settings and the README. Only the moving bubbles are drawn on top; the already has grass, sun, water and clouds. */
photo already has grass, sun, water and clouds. */
.aero .ice-scene { .aero .ice-scene {
background-image: url("/aero/background.webp"); background-image: url("/aero/background.webp");
background-size: cover; background-size: cover;
@ -361,6 +360,24 @@
@apply backdrop-blur-md backdrop-saturate-150; @apply backdrop-blur-md backdrop-saturate-150;
} }
/* The thin glass the Ask / Import JSON buttons use: a 34% white tint over the
sharp wallpaper with a bright rim — no pre-blurred layer, so it reads as
glass rather than as a white chip. Rail rows, the active nav item and the
active segment all match it. */
.aero .nav-action,
.aero .nav-row[aria-current="page"],
.aero .nav-row[data-active="true"],
.aero .segmented > button[aria-pressed="true"],
.aero .segmented > a[aria-current="page"] {
background-image: none;
background-color: rgb(255 255 255 / 0.34);
border: 1px solid rgb(255 255 255 / 0.9);
}
.aero .segmented > button,
.aero .segmented > a {
border-color: rgb(255 255 255 / 0.5);
}
/* Liquid glass buttons: translucent, glossed top half, bright rim — the /* Liquid glass buttons: translucent, glossed top half, bright rim — the
Vista/Aero button, not a solid fill. */ Vista/Aero button, not a solid fill. */
.aero .grad-ice, .aero .grad-ice,
@ -396,11 +413,8 @@
.aero .glass-solid, .aero .glass-solid,
.aero .sidebar-surface, .aero .sidebar-surface,
.aero .gate-card, .aero .gate-card,
.aero .nav-action,
.aero .btn-primary, .aero .btn-primary,
.aero .grad-ice, .aero .grad-ice,
.aero .segmented > button[aria-pressed="true"],
.aero .segmented > a[aria-current="page"],
.aero .hint-line { .aero .hint-line {
background-image: background-image:
linear-gradient( linear-gradient(

View file

@ -3,7 +3,7 @@
import { AnimatePresence, motion, useReducedMotion } from "framer-motion"; import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react"; import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
import { useTheme } from "@/components/ThemeToggle"; import { useTheme } from "@/lib/theme";
import { import {
CheckCircleIcon, CheckCircleIcon,
ExternalLinkIcon, ExternalLinkIcon,

View file

@ -20,7 +20,7 @@ import {
SparklesIcon, SparklesIcon,
SunIcon, SunIcon,
} from "@/components/icons"; } from "@/components/icons";
import { useTheme } from "@/components/ThemeToggle"; import { useTheme } from "@/lib/theme";
import { createFolder, folderStore } from "@/lib/folders"; import { createFolder, folderStore } from "@/lib/folders";
import { haptic } from "@/lib/haptics"; import { haptic } from "@/lib/haptics";
import { IS_DEV_INSTANCE } from "@/lib/dev-mode"; import { IS_DEV_INSTANCE } from "@/lib/dev-mode";

View file

@ -1,6 +1,6 @@
"use client"; "use client";
import { useTheme, type Theme } from "@/components/ThemeToggle"; import { useTheme, type Theme } from "@/lib/theme";
import { haptic } from "@/lib/haptics"; import { haptic } from "@/lib/haptics";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
@ -40,17 +40,7 @@ export function ThemeSection() {
{theme === "aero" ? ( {theme === "aero" ? (
<> <>
<p className="text-[11px] leading-snug text-[var(--text-subtle)]">{t("theme.aeroNote")}</p> <p className="text-[11px] leading-snug text-[var(--text-subtle)]">{t("theme.aeroNote")}</p>
<p className="text-[11px] leading-snug text-[var(--text-subtle)]">
{t("theme.aeroCredit")}{" "}
<a
href="https://wallpapersafari.com/w/IlWuz7"
target="_blank"
rel="noreferrer"
className="underline decoration-dotted"
>
wallpapersafari.com/w/IlWuz7
</a>
</p>
</> </>
) : null} ) : null}
</section> </section>

View file

@ -1,87 +1,22 @@
"use client"; "use client";
import { useEffect, useState } from "react";
import { useI18n } from "@/lib/i18n";
import { haptic } from "@/lib/haptics";
import { cn } from "@/lib/utils";
import { MoonIcon, SunIcon } from "@/components/icons"; import { MoonIcon, SunIcon } from "@/components/icons";
import { haptic } from "@/lib/haptics";
const STORAGE_KEY = "freezmo.theme"; import { useI18n } from "@/lib/i18n";
import { useTheme } from "@/lib/theme";
const THEME_COLORS = { light: "#f4f7fd", dark: "#0e1424", aero: "#2fa7e6" } as const; import { cn } from "@/lib/utils";
/** The three themes. Aero is not a dark/light variant: it replaces the palette
* and the scene, and the light/dark toggles hide while it is on. */
export type Theme = "light" | "dark" | "aero";
/** /**
* Keeps the first `theme-color` meta (the one the app owns) in step with the * The light/dark flip. Frutiger Aero has no such switch (the theme picker in
* in-app theme. The SSR metas follow the OS preference; this one always wins * Settings owns it), so this renders nothing while aero is on — the state
* because it is media-less and first in the head. * comes from the shared store in lib/theme, so a change in Settings hides
* this immediately.
*/ */
export function syncThemeColor(theme: Theme) {
try {
let meta = document.querySelector<HTMLMetaElement>('meta[name="theme-color"][data-app]');
if (!meta) {
meta = document.createElement("meta");
meta.setAttribute("name", "theme-color");
meta.setAttribute("data-app", "1");
document.head.insertBefore(meta, document.head.firstChild);
}
meta.setAttribute("content", THEME_COLORS[theme]);
} catch {
/* private mode / odd embedded browsers */
}
}
export function applyTheme(next: Theme) {
document.documentElement.classList.toggle("dark", next === "dark");
document.documentElement.classList.toggle("aero", next === "aero");
syncThemeColor(next);
try {
window.localStorage.setItem(STORAGE_KEY, next);
} catch {
/* private mode */
}
}
export function useTheme() {
const [theme, setTheme] = useState<Theme>("light");
useEffect(() => {
const stored = window.localStorage.getItem(STORAGE_KEY);
const next: Theme =
stored === "dark" || stored === "aero"
? stored
: stored === "light"
? "light"
: window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
setTheme(next);
syncThemeColor(next);
}, []);
const apply = (next: Theme) => {
setTheme(next);
applyTheme(next);
};
return {
theme,
setTheme: apply,
/** The light/dark flip; aero is chosen in Settings. */
toggle: () => apply(theme === "dark" ? "light" : "dark"),
};
}
export function ThemeToggle({ className, plain = false }: { className?: string; plain?: boolean }) { export function ThemeToggle({ className, plain = false }: { className?: string; plain?: boolean }) {
const { theme, toggle } = useTheme(); const { theme, toggle } = useTheme();
const { t } = useI18n(); const { t } = useI18n();
const dark = theme === "dark"; const dark = theme === "dark";
// Frutiger Aero has no light/dark switch (see the theme picker in Settings).
if (theme === "aero") return null; if (theme === "aero") return null;
return ( return (

View file

@ -11,7 +11,7 @@
import { z } from "zod"; import { z } from "zod";
import { applyTheme } from "@/components/ThemeToggle"; import { applyTheme } from "./theme";
import { chatHistoryStore, ensureChatsHydrated, replaceChats } from "./chat-store"; import { chatHistoryStore, ensureChatsHydrated, replaceChats } from "./chat-store";
import { replaceFolders, folderStore } from "./folders"; import { replaceFolders, folderStore } from "./folders";
import { homeworkStore, replaceHomework } from "./homework"; import { homeworkStore, replaceHomework } from "./homework";

View file

@ -1004,7 +1004,6 @@ const en: Record<string, string> = {
"settings.theme": "Theme", "settings.theme": "Theme",
"theme.aero": "Frutiger Aero", "theme.aero": "Frutiger Aero",
"theme.aeroNote": "Aero has no light/dark switch — pick Light or Dark to get it back.", "theme.aeroNote": "Aero has no light/dark switch — pick Light or Dark to get it back.",
"theme.aeroCredit": "Background: “The S Aesthetic That You Definitely Recognize Frutiger Aero” via WallpaperSafari.",
"settings.legal": "Legal", "settings.legal": "Legal",
"consent.title": "About cookies", "consent.title": "About cookies",
"consent.body": "Freezmo uses no cookies and no trackers — your decks, homework and settings stay in this browser.", "consent.body": "Freezmo uses no cookies and no trackers — your decks, homework and settings stay in this browser.",
@ -1960,7 +1959,6 @@ const cs: Record<string, string> = {
"settings.theme": "Téma", "settings.theme": "Téma",
"theme.aero": "Frutiger Aero", "theme.aero": "Frutiger Aero",
"theme.aeroNote": "Aero nemá přepínač světlý/tmavý — vyber Světlý nebo Tmavý, pokud ho chceš zpět.", "theme.aeroNote": "Aero nemá přepínač světlý/tmavý — vyber Světlý nebo Tmavý, pokud ho chceš zpět.",
"theme.aeroCredit": "Pozadí: „The S Aesthetic That You Definitely Recognize Frutiger Aero“ přes WallpaperSafari.",
"settings.legal": "Právní informace", "settings.legal": "Právní informace",
"consent.title": "O cookies", "consent.title": "O cookies",
"consent.body": "Freezmo nepoužívá cookies ani sledování — sady, úkoly a nastavení zůstávají v tomto prohlížeči.", "consent.body": "Freezmo nepoužívá cookies ani sledování — sady, úkoly a nastavení zůstávají v tomto prohlížeči.",

92
lib/theme.ts Normal file
View file

@ -0,0 +1,92 @@
"use client";
import { useSyncExternalStore } from "react";
/**
* The theme, as a shared store. It used to be component state inside
* `useTheme()`, which meant every caller (header, rail, chat, Settings) had
* its own copy: switching to Aero in Settings left the rail's light/dark row
* on screen until a reload. One store, one snapshot, everyone re-renders.
*/
const STORAGE_KEY = "freezmo.theme";
const THEME_COLORS = { light: "#f4f7fd", dark: "#0e1424", aero: "#2fa7e6" } as const;
/** The three themes. Aero is not a dark/light variant: it replaces the palette
* and the scene, and the light/dark toggles hide while it is on. */
export type Theme = "light" | "dark" | "aero";
let cached: Theme | null = null;
const listeners = new Set<() => void>();
function read(): Theme {
try {
const stored = window.localStorage.getItem(STORAGE_KEY);
if (stored === "light" || stored === "dark" || stored === "aero") return stored;
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
} catch {
/* private mode */
return "light";
}
}
function snapshot(): Theme {
cached ??= read();
return cached;
}
function serverSnapshot(): Theme {
return "light";
}
/**
* Keeps the first `theme-color` meta (the one the app owns) in step with the
* in-app theme. The SSR metas follow the OS preference; this one always wins
* because it is media-less and first in the head.
*/
export function syncThemeColor(theme: Theme) {
try {
let meta = document.querySelector<HTMLMetaElement>('meta[name="theme-color"][data-app]');
if (!meta) {
meta = document.createElement("meta");
meta.setAttribute("name", "theme-color");
meta.setAttribute("data-app", "1");
document.head.insertBefore(meta, document.head.firstChild);
}
meta.setAttribute("content", THEME_COLORS[theme]);
} catch {
/* private mode / odd embedded browsers */
}
}
export function applyTheme(next: Theme) {
document.documentElement.classList.toggle("dark", next === "dark");
document.documentElement.classList.toggle("aero", next === "aero");
syncThemeColor(next);
cached = next;
try {
window.localStorage.setItem(STORAGE_KEY, next);
} catch {
/* private mode */
}
listeners.forEach((listener) => listener());
}
export function useTheme() {
const theme = useSyncExternalStore(
(listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
snapshot,
serverSnapshot,
);
return {
theme,
setTheme: applyTheme,
/** The light/dark flip; aero is chosen in Settings. */
toggle: () => applyTheme(theme === "dark" ? "light" : "dark"),
};
}

View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 232 KiB

After

Width:  |  Height:  |  Size: 148 KiB

Before After
Before After

View file

@ -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-v58"; const CACHE_VERSION = "freezmo-v60";
const SHELL_CACHE = `${CACHE_VERSION}-shell`; const SHELL_CACHE = `${CACHE_VERSION}-shell`;
const ASSET_CACHE = `${CACHE_VERSION}-assets`; const ASSET_CACHE = `${CACHE_VERSION}-assets`;

View file

@ -266,6 +266,27 @@ const AXE = axeSource();
await context.close(); await context.close();
} }
/* 6b. picking Aero hides the light/dark switches immediately ---------- */
{
const { context, page } = await makePage(browser, { width: 1280, height: 900 });
await seed(page, { decks: [DECK], theme: "dark" });
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForTimeout(1000);
const toggleInRail = () =>
page.evaluate(() => [...document.querySelectorAll("aside button")].some((b) => /Light mode|Dark mode/.test(b.textContent ?? "")));
check("theme: the rail has a light/dark row while dark", await toggleInRail());
await page.locator("aside button").filter({ hasText: /Settings|Nastavení/ }).first().click();
await page.getByRole("dialog").waitFor({ timeout: 6000 });
await page.waitForTimeout(500);
await page.getByRole("group", { name: "Theme" }).getByRole("button", { name: "Frutiger Aero" }).click();
await page.waitForTimeout(600);
check(
"theme: picking Aero hides it without a reload",
(await page.evaluate(() => document.documentElement.classList.contains("aero"))) && !(await toggleInRail()),
);
await context.close();
}
async function audit(label, { width = 390, locale, theme, openSettingsNow }) { async function audit(label, { width = 390, locale, theme, openSettingsNow }) {
const { context, page } = await makePage(browser, { width, height: width >= 1280 ? 900 : 800, deviceScaleFactor: width >= 1280 ? 1 : 2 }); const { context, page } = await makePage(browser, { width, height: width >= 1280 ? 900 : 800, deviceScaleFactor: width >= 1280 ? 1 : 2 });
await seed(page, { locale, theme, decks: [DECK] }); await seed(page, { locale, theme, decks: [DECK] });