diff --git a/README.md b/README.md
index a35175e..9cc47d0 100644
--- a/README.md
+++ b/README.md
@@ -1398,6 +1398,27 @@ Things I chose while building, with the reasoning:
phase-1 suite (43 checks, including the real route mapping against a stub Ollama returning a
monthly-limit body, a two-device sync round-trip, and the generation guards below).
+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 (a sky gradient with a strip of
+ grass, glossy glass with the classic horizon gloss, aqua buttons) and the scene (the glacier
+ blobs become clouds, the orbs soap bubbles) — so the light/dark toggles (header, rail, chat
+ header) hide while it is on and the picker says so. The pre-paint script applies the class
+ before the first paint, the PWA status bar follows it (#3fa3e0), and its muted/frost text is
+ darker than Light's because those labels sit directly on the sky. Covered by the axe and
+ pixel-contrast scans in all three themes.
+
+62. **Accessibility: axe is clean everywhere, and contrast is measured on real pixels.** axe cannot
+ judge text on blurred glass — it reports those nodes as "incomplete" — so `tests/contrast.mjs`
+ samples the rendered pixels (dominant non-glyph colour per element) and compares it with the
+ computed text colour using the WCAG formula, skipping disabled controls (1.4.3 exempts them)
+ and content behind an open sheet. Fixes from that pass: the hidden file inputs are out of the
+ accessibility tree (aria-hidden + tabIndex -1; labelled buttons open them), the heading order is
+ right (deck/homework/smart-review cards are h2; LearningSection takes a level so it is h2 on the
+ library and h3 inside the dialog), the study and review feeds have landmarks, and the light
+ settings sheet — translucent over a dark scrim, where its muted text measured ~2.8:1 — got its
+ own more opaque surface (`--glass-sheet-bg`). Result: 0 axe violations and 0 below-minimum
+ findings across every screen, all three themes, phone and desktop.
+
61. **Glass that sits still doesn't blur.** The MacBook got hot just browsing: the ice scene animates
constantly, and every `backdrop-filter` surface re-runs its blur on every frame while the backdrop
moves — the whole card and rail area was re-blurring the scene ~60 times a second. Measured on
diff --git a/app/globals.css b/app/globals.css
index c5d4249..5b95a1a 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -94,7 +94,7 @@
--hairline-soft: rgb(12 32 62 / 0.08);
--glass-tint: rgb(255 255 255 / 0.55);
--text: #0b1830; /* 16.6:1 on #eef5fd */
- --text-muted: #46586f; /* 6.6:1 on light glass */
+ --text-muted: #40536b; /* 7.2:1 on light glass, 5.9:1 on sheet glass */
--text-subtle: #566a84; /* 5.6:1 on light glass */
--hover: rgb(12 32 62 / 0.05);
--focus: #0ea5e9;
@@ -107,6 +107,9 @@
--glass-highlight: rgb(255 255 255 / 0.85);
--glass-shadow: 0 20px 50px -20px rgb(30 64 175 / 0.35);
--glass-solid-bg: rgb(255 255 255 / 0.8); /* question card: more opaque */
+ /* Sheet panels float over a dark scrim, so they need a more opaque surface
+ than the cards: at 0.55 the muted text under it measured ~2.8:1. */
+ --glass-sheet-bg: rgb(255 255 255 / 0.93);
--glass-fallback: rgb(247 251 255 / 0.97); /* no backdrop-filter */
--nav-active-bg: rgb(255 255 255 / 0.75);
--nav-active-border: rgb(56 189 248 / 0.5);
@@ -153,6 +156,7 @@
--glass-highlight: rgb(255 255 255 / 0.25);
--glass-shadow: 0 20px 50px -20px rgb(30 64 175 / 0.35);
--glass-solid-bg: rgb(255 255 255 / 0.1);
+ --glass-sheet-bg: rgb(17 27 46 / 0.94);
--glass-fallback: rgb(16 26 45 / 0.97);
--nav-active-bg: rgb(56 189 248 / 0.14);
--nav-active-border: rgb(56 189 248 / 0.4);
@@ -192,6 +196,122 @@
--ice-grain-opacity: 0.045;
}
+/* Frutiger Aero — the third theme (Settings → Theme). A bright sky with
+ clouds and a strip of grass, glossy glass with the classic horizon gloss,
+ aqua accents. Token-driven like light/dark; the light/dark toggles hide
+ while it is on (components/ThemeToggle.tsx). */
+.aero {
+ color-scheme: light;
+ --page: #bfe4fb;
+ --sidebar: #e3f4ff;
+ --surface: #ffffff;
+ --surface-raised: #ffffff;
+ --surface-strong: rgb(255 255 255 / 0.72);
+ --card-hover-bg: rgb(255 255 255 / 0.9);
+ --surface-sunken: rgb(255 255 255 / 0.55);
+ --hairline: rgb(255 255 255 / 0.8);
+ --card-border: var(--glass-border);
+ --sidebar-divider: rgb(255 255 255 / 0.7);
+ --hairline-soft: rgb(255 255 255 / 0.6);
+ --glass-tint: rgb(255 255 255 / 0.6);
+ --text: #05203a; /* 12.6:1 on the aero glass */
+ --text-muted: #1b3a54; /* 8.4:1 */
+ --text-subtle: #16324a; /* 7.6:1 on glass, 5.4:1 on the sky */
+ /* A few components use the frost scale directly (hero descriptions, the
+ wizard's step help): darker here, because they sit on the sky. */
+ --color-frost-600: #1d3a55;
+ --color-frost-700: #16324a;
+ --hover: rgb(255 255 255 / 0.5);
+ --focus: #0284c7;
+
+ --glass-bg: rgb(255 255 255 / 0.62);
+ --glass-border: rgb(255 255 255 / 0.9);
+ --glass-highlight: rgb(255 255 255 / 0.98);
+ --glass-shadow: 0 18px 44px -20px rgb(2 90 150 / 0.5);
+ --glass-solid-bg: rgb(255 255 255 / 0.82);
+ --glass-sheet-bg: rgb(255 255 255 / 0.9);
+ --glass-fallback: rgb(238 248 255 / 0.97);
+ --nav-active-bg: rgb(255 255 255 / 0.85);
+ --nav-active-border: rgb(14 165 233 / 0.65);
+ --gate-card: rgb(255 255 255 / 0.66);
+ --gate-callout-bg: rgb(255 255 255 / 0.6);
+ --gate-callout-border: rgb(56 189 248 / 0.4);
+ --field: rgb(255 255 255 / 0.75);
+ --field-border: rgb(255 255 255 / 0.95);
+ --shadow-gate: 0 30px 70px -34px rgb(2 62 110 / 0.45), 0 1px 2px rgb(2 62 110 / 0.08);
+ --install-sheet: rgb(255 255 255 / 0.65);
+ --install-card: rgb(255 255 255 / 0.75);
+ --install-chip: rgb(255 255 255 / 0.6);
+ --install-border: rgb(255 255 255 / 0.85);
+ --shadow-card: 0 2px 4px rgb(2 62 110 / 0.1), 0 12px 28px -14px rgb(2 62 110 / 0.35);
+ --card-shadow: var(--shadow-card);
+ --shadow-card-hover: 0 26px 60px -26px rgb(2 90 150 / 0.55), 0 3px 8px rgb(2 90 150 / 0.2);
+
+ /* The scene: sky, clouds, bubbles, grass. */
+ --ice-base-top: #3fa3e0;
+ --ice-base-bottom: #d9f1ff;
+ --ice-blob-opacity: 0.55;
+ --ice-orb-opacity: 0.55;
+ --ice-grain-opacity: 0.02;
+}
+
+/* The blobs are clouds here, not glacier gradients. */
+.aero .ice-blob-a,
+.aero .ice-blob-b,
+.aero .ice-blob-c,
+.aero .ice-blob-d {
+ background: radial-gradient(circle, rgb(255 255 255 / 0.95) 0%, rgb(255 255 255 / 0.6) 42%, transparent 72%);
+}
+
+/* The orbs are soap bubbles: a glassy rim with a bright specular. */
+.aero .ice-orb {
+ background: radial-gradient(
+ circle at 30% 25%,
+ rgb(255 255 255 / 0.95) 0%,
+ rgb(224 247 255 / 0.45) 20%,
+ rgb(125 211 252 / 0.2) 52%,
+ rgb(56 189 248 / 0.35) 100%
+ );
+ box-shadow: 0 16px 34px -18px rgb(2 90 150 / 0.4), inset 0 -8px 18px -10px rgb(2 90 150 / 0.35);
+}
+
+/* A strip of grass along the bottom, like the wallpapers it comes from. */
+.aero .ice-scene::after {
+ content: "";
+ position: absolute;
+ inset-inline: 0;
+ bottom: 0;
+ height: 18vh;
+ background: linear-gradient(180deg, rgb(134 239 172 / 0) 0%, rgb(74 222 128 / 0.5) 55%, rgb(22 163 74 / 0.6) 100%);
+ pointer-events: none;
+}
+
+/* The Aero gloss: the classic white top half with a horizon line. */
+.aero .card,
+.aero .glass-panel,
+.aero .sidebar-surface,
+.aero .study-chrome,
+.aero .tabbar,
+.aero .glass,
+.aero .gate-card {
+ background-image: linear-gradient(
+ 180deg,
+ rgb(255 255 255 / 0.7) 0%,
+ rgb(255 255 255 / 0.28) 46%,
+ rgb(255 255 255 / 0.04) 47%,
+ rgb(255 255 255 / 0.22) 100%
+ );
+}
+
+/* Glossy aqua primary buttons and segments. */
+.aero .grad-ice,
+.aero .btn-primary,
+.aero .segmented > button[aria-pressed="true"],
+.aero .segmented > a[aria-current="page"] {
+ background-image: linear-gradient(180deg, #a5e4ff 0%, #38bdf8 52%, #0ea5e9 53%, #0284c7 100%);
+ color: #052437;
+}
+
html,
body {
height: 100%;
@@ -224,7 +344,7 @@ body {
transparent on purpose so the fixed ice scene stays visible — which also
means the app chrome of the page behind it would ghost through the glass.
While one is open, that chrome is hidden instead. */
-body:has(.overlay-open) :is(.app-header, .segmented, .tabbar) {
+body:has(.overlay-open) :is(.app-header, .segmented, .tabbar, .cookie-notice) {
visibility: hidden;
}
@@ -428,7 +548,7 @@ body:has(.overlay-open) :is(.app-header, .segmented, .tabbar) {
.tabbar,
.glass,
.gate-card {
- background: var(--glass-bg);
+ background-color: var(--glass-bg);
box-shadow: var(--glass-shadow), inset 0 1px 0 var(--glass-highlight);
}
@@ -451,9 +571,16 @@ body:has(.overlay-open) :is(.app-header, .segmented, .tabbar) {
border: 1px solid var(--glass-border);
}
-/* More opaque glass for the study question card, where text must stay crisp. */
+/* More opaque glass for the study question card, where text must stay crisp.
+ background-color (not the shorthand) so the Aero gloss can layer on top. */
.glass-solid {
- background: var(--glass-solid-bg);
+ background-color: var(--glass-solid-bg);
+}
+
+/* Sheets and dialogs sit over a scrim (and, on phones, over the page), so
+ their text needs a more opaque surface than the cards have. */
+.glass-sheet {
+ background-color: var(--glass-sheet-bg);
}
.glass-chip:hover {
background: var(--hover);
@@ -496,7 +623,7 @@ body:has(.overlay-open) :is(.app-header, .segmented, .tabbar) {
.study-chrome,
.tabbar,
.glass {
- background: var(--glass-fallback);
+ background-color: var(--glass-fallback);
}
}
diff --git a/app/layout.tsx b/app/layout.tsx
index 22ddbec..3098424 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -82,7 +82,7 @@ export const viewport: Viewport = {
* `theme-color` meta in step with the *app* theme (the SSR metas follow the
* OS preference, which is wrong once the user has picked a theme here).
*/
-const themeScript = `(function(){try{var DEV=${devInstanceJs};var s=localStorage.getItem('freezmo.theme');var d=s?s==='dark':window.matchMedia('(prefers-color-scheme: dark)').matches;if(d)document.documentElement.classList.add('dark');var l=localStorage.getItem('freezmo.locale.v1');if(l==='cs'||l==='en')document.documentElement.lang=l;var m=document.querySelector('meta[name="theme-color"][data-app]');if(!m){m=document.createElement('meta');m.setAttribute('name','theme-color');m.setAttribute('data-app','1');document.head.insertBefore(m,document.head.firstChild);}m.setAttribute('content',DEV?'#b45309':(d?'#0e1424':'#f4f7fd'));}catch(e){}})();`;
+const themeScript = `(function(){try{var DEV=${devInstanceJs};var s=localStorage.getItem('freezmo.theme');var a=s==='aero';var d=s&&!a?s==='dark':window.matchMedia('(prefers-color-scheme: dark)').matches;if(d)document.documentElement.classList.add('dark');if(a)document.documentElement.classList.add('aero');var l=localStorage.getItem('freezmo.locale.v1');if(l==='cs'||l==='en')document.documentElement.lang=l;var m=document.querySelector('meta[name="theme-color"][data-app]');if(!m){m=document.createElement('meta');m.setAttribute('name','theme-color');m.setAttribute('data-app','1');document.head.insertBefore(m,document.head.firstChild);}m.setAttribute('content',DEV?'#b45309':(a?'#3fa3e0':(d?'#0e1424':'#f4f7fd')));}catch(e){}})();`;
/**
* Captures Chrome's `beforeinstallprompt` (it can fire before React hydrates)
diff --git a/components/ApiKeyGate.tsx b/components/ApiKeyGate.tsx
index 5643baa..2bd9e4d 100644
--- a/components/ApiKeyGate.tsx
+++ b/components/ApiKeyGate.tsx
@@ -379,7 +379,7 @@ function HelpDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
exit={reduceMotion ? { opacity: 0 } : { y: 24, opacity: 0 }}
transition={{ type: "spring", stiffness: 340, damping: 32 }}
onClick={(event) => event.stopPropagation()}
- className="gate-card thin-scroll max-h-[90dvh] w-full max-w-[480px] overflow-y-auto p-6 pb-safe sm:pb-6"
+ className="gate-card glass-sheet thin-scroll max-h-[90dvh] w-full max-w-[480px] overflow-y-auto p-6 pb-safe sm:pb-6"
>
+
{t("library.moreTitle")}
diff --git a/components/SettingsDialog.tsx b/components/SettingsDialog.tsx
index 6a5ea11..37fab57 100644
--- a/components/SettingsDialog.tsx
+++ b/components/SettingsDialog.tsx
@@ -12,6 +12,7 @@ import { springSheet } from "@/lib/motion";
import { ModelErrorActions } from "@/components/ModelErrorActions";
import { DataSection } from "@/components/DataSection";
import { LearningSection } from "@/components/LearningSection";
+import { ThemeSection } from "@/components/ThemeSection";
import { SyncSection } from "@/components/SyncSection";
import { clearApiKey, saveSettings, settingsStore } from "@/lib/settings";
import { useBackClose } from "@/lib/use-back-close";
@@ -203,7 +204,7 @@ export function SettingsDialog({
exit={reduceMotion ? { opacity: 0 } : phone ? { y: "100%", opacity: 0 } : { y: 32, opacity: 0 }}
transition={phone ? springSheet : { type: "spring", stiffness: 340, damping: 32 }}
onClick={(event) => event.stopPropagation()}
- className="glass-panel relative flex max-h-[90dvh] w-full max-w-lg flex-col overflow-hidden rounded-t-[1.6rem] shadow-2xl sm:rounded-[1.35rem]"
+ className="glass-panel glass-sheet relative flex max-h-[90dvh] w-full max-w-lg flex-col overflow-hidden rounded-t-[1.6rem] shadow-2xl sm:rounded-[1.35rem]"
>
{/* handle + header: the drag strip. Fixed, so the body scrolls. */}
- {/* ------------------------------------------- data & sync */}
+ {/* ------------------------------------------- theme */}
+
+
+ {/* ----------------------------------------- learning */}
+
+ {/* ------------------------------------------- data & sync */}
@@ -764,8 +770,13 @@ export function SettingsDialog({
/>
- {/* ------------------------------------------- data & sync */}
+ {/* ------------------------------------------- theme */}
+
+
+ {/* ----------------------------------------- learning */}
+
+ {/* ------------------------------------------- data & sync */}
diff --git a/components/Sidebar.tsx b/components/Sidebar.tsx
index bba0f29..8ea79ae 100644
--- a/components/Sidebar.tsx
+++ b/components/Sidebar.tsx
@@ -279,27 +279,31 @@ function NavContent({ collapsed = false }: { collapsed?: boolean }) {
{!collapsed ?
{t("common.settings")} : null}
-
+ {/* Frutiger Aero has no light/dark flip (see the theme picker in
+ Settings), so this row goes away while it is on. */}
+ {theme === "aero" ? null : (
+
+ )}
);
diff --git a/components/ThemeSection.tsx b/components/ThemeSection.tsx
new file mode 100644
index 0000000..1d4533e
--- /dev/null
+++ b/components/ThemeSection.tsx
@@ -0,0 +1,45 @@
+"use client";
+
+import { useTheme, type Theme } from "@/components/ThemeToggle";
+import { haptic } from "@/lib/haptics";
+import { useI18n } from "@/lib/i18n";
+
+const OPTIONS: Array<{ id: Theme; labelKey: string }> = [
+ { id: "light", labelKey: "common.lightMode" },
+ { id: "dark", labelKey: "common.darkMode" },
+ { id: "aero", labelKey: "theme.aero" },
+];
+
+/**
+ * The theme picker: light, dark, or Frutiger Aero. Aero is not a dark/light
+ * variant — it swaps the whole palette and scene — which is why the light/dark
+ * toggles (header, rail, chat header) disappear while it is on.
+ */
+export function ThemeSection() {
+ const { theme, setTheme } = useTheme();
+ const { t } = useI18n();
+
+ return (
+
+ {t("settings.theme")}
+
+ {OPTIONS.map((option) => (
+
+ ))}
+
+ {theme === "aero" ? (
+ {t("theme.aeroNote")}
+ ) : null}
+
+ );
+}
diff --git a/components/ThemeToggle.tsx b/components/ThemeToggle.tsx
index 526659d..856ffc9 100644
--- a/components/ThemeToggle.tsx
+++ b/components/ThemeToggle.tsx
@@ -9,14 +9,18 @@ import { MoonIcon, SunIcon } from "@/components/icons";
const STORAGE_KEY = "freezmo.theme";
-const THEME_COLORS = { light: "#f4f7fd", dark: "#0e1424" } as const;
+const THEME_COLORS = { light: "#f4f7fd", dark: "#0e1424", aero: "#3fa3e0" } 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";
/**
* 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: "light" | "dark") {
+export function syncThemeColor(theme: Theme) {
try {
let meta = document.querySelector
('meta[name="theme-color"][data-app]');
if (!meta) {
@@ -31,8 +35,9 @@ export function syncThemeColor(theme: "light" | "dark") {
}
}
-export function applyTheme(next: "light" | "dark") {
+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);
@@ -42,23 +47,33 @@ export function applyTheme(next: "light" | "dark") {
}
export function useTheme() {
- const [theme, setTheme] = useState<"light" | "dark">("light");
+ const [theme, setTheme] = useState("light");
useEffect(() => {
const stored = window.localStorage.getItem(STORAGE_KEY);
- const isDark = stored
- ? stored === "dark"
- : window.matchMedia("(prefers-color-scheme: dark)").matches;
- setTheme(isDark ? "dark" : "light");
- syncThemeColor(isDark ? "dark" : "light");
+ 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: "light" | "dark") => {
+ const apply = (next: Theme) => {
setTheme(next);
applyTheme(next);
};
- return { theme, toggle: () => apply(theme === "dark" ? "light" : "dark") };
+ 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 }) {
@@ -66,6 +81,9 @@ export function ThemeToggle({ className, plain = false }: { className?: string;
const { t } = useI18n();
const dark = theme === "dark";
+ // Frutiger Aero has no light/dark switch (see the theme picker in Settings).
+ if (theme === "aero") return null;
+
return (