Frutiger Aero theme, pixel-measured contrast fixes, sheet surfaces

Frutiger Aero (Settings -> Theme): a third theme, not a dark/light variant.
A sky gradient with a strip of grass, the glacier blobs become clouds, the
orbs soap bubbles, glass gets the classic horizon gloss and the buttons a
glossy aqua. The light/dark toggles (header, rail, chat header) hide while it
is on and the picker says why; the pre-paint script applies the class before
first paint and the PWA status bar follows (#3fa3e0). Its muted/frost text is
darker than Light's because those labels sit directly on the sky.

Contrast, measured on real pixels (tests/contrast.mjs, now covering aero):
- The light settings sheet was translucent over a dark scrim, so its muted
  text measured ~2.8:1. Sheets and dialogs now use --glass-sheet-bg (a more
  opaque surface), and Light's --text-muted is a touch darker.
- Result: 0 below-minimum findings and 0 axe violations across every screen,
  all three themes, phone and desktop.

Also: the suites now seed the cookie notice as already dismissed (they run as
a returning user); the regression suite opts out to test the notice itself —
the previous deploy failed its gate because the notice blocked a chat chip
click. The notice also hides during immersive sessions (overlay-open), so it
can never cover a study feed.

Version 0.1.8, SW -> freezmo-v54.
This commit is contained in:
Martin Dvořák 2026-10-12 22:10:16 +00:00
commit e56653dae1
21 changed files with 329 additions and 57 deletions

View file

@ -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 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). 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 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 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 moves — the whole card and rail area was re-blurring the scene ~60 times a second. Measured on

View file

@ -94,7 +94,7 @@
--hairline-soft: rgb(12 32 62 / 0.08); --hairline-soft: rgb(12 32 62 / 0.08);
--glass-tint: rgb(255 255 255 / 0.55); --glass-tint: rgb(255 255 255 / 0.55);
--text: #0b1830; /* 16.6:1 on #eef5fd */ --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 */ --text-subtle: #566a84; /* 5.6:1 on light glass */
--hover: rgb(12 32 62 / 0.05); --hover: rgb(12 32 62 / 0.05);
--focus: #0ea5e9; --focus: #0ea5e9;
@ -107,6 +107,9 @@
--glass-highlight: rgb(255 255 255 / 0.85); --glass-highlight: rgb(255 255 255 / 0.85);
--glass-shadow: 0 20px 50px -20px rgb(30 64 175 / 0.35); --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 */ --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 */ --glass-fallback: rgb(247 251 255 / 0.97); /* no backdrop-filter */
--nav-active-bg: rgb(255 255 255 / 0.75); --nav-active-bg: rgb(255 255 255 / 0.75);
--nav-active-border: rgb(56 189 248 / 0.5); --nav-active-border: rgb(56 189 248 / 0.5);
@ -153,6 +156,7 @@
--glass-highlight: rgb(255 255 255 / 0.25); --glass-highlight: rgb(255 255 255 / 0.25);
--glass-shadow: 0 20px 50px -20px rgb(30 64 175 / 0.35); --glass-shadow: 0 20px 50px -20px rgb(30 64 175 / 0.35);
--glass-solid-bg: rgb(255 255 255 / 0.1); --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); --glass-fallback: rgb(16 26 45 / 0.97);
--nav-active-bg: rgb(56 189 248 / 0.14); --nav-active-bg: rgb(56 189 248 / 0.14);
--nav-active-border: rgb(56 189 248 / 0.4); --nav-active-border: rgb(56 189 248 / 0.4);
@ -192,6 +196,122 @@
--ice-grain-opacity: 0.045; --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, html,
body { body {
height: 100%; height: 100%;
@ -224,7 +344,7 @@ body {
transparent on purpose so the fixed ice scene stays visible — which also 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. means the app chrome of the page behind it would ghost through the glass.
While one is open, that chrome is hidden instead. */ 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; visibility: hidden;
} }
@ -428,7 +548,7 @@ body:has(.overlay-open) :is(.app-header, .segmented, .tabbar) {
.tabbar, .tabbar,
.glass, .glass,
.gate-card { .gate-card {
background: var(--glass-bg); background-color: var(--glass-bg);
box-shadow: var(--glass-shadow), inset 0 1px 0 var(--glass-highlight); 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); 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 { .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 { .glass-chip:hover {
background: var(--hover); background: var(--hover);
@ -496,7 +623,7 @@ body:has(.overlay-open) :is(.app-header, .segmented, .tabbar) {
.study-chrome, .study-chrome,
.tabbar, .tabbar,
.glass { .glass {
background: var(--glass-fallback); background-color: var(--glass-fallback);
} }
} }

View file

@ -82,7 +82,7 @@ export const viewport: Viewport = {
* `theme-color` meta in step with the *app* theme (the SSR metas follow the * `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). * 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) * Captures Chrome's `beforeinstallprompt` (it can fire before React hydrates)

View file

@ -379,7 +379,7 @@ function HelpDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
exit={reduceMotion ? { opacity: 0 } : { y: 24, opacity: 0 }} exit={reduceMotion ? { opacity: 0 } : { y: 24, opacity: 0 }}
transition={{ type: "spring", stiffness: 340, damping: 32 }} transition={{ type: "spring", stiffness: 340, damping: 32 }}
onClick={(event) => event.stopPropagation()} 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"
> >
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<h2 <h2

View file

@ -37,7 +37,7 @@ export function CookieConsent() {
}; };
return ( return (
<div className="pointer-events-none fixed inset-x-0 bottom-[calc(env(safe-area-inset-bottom,0px)+5.25rem)] z-[44] flex justify-center px-4 sm:bottom-6"> <div className="cookie-notice pointer-events-none fixed inset-x-0 bottom-[calc(env(safe-area-inset-bottom,0px)+5.25rem)] z-[44] flex justify-center px-4 sm:bottom-6">
<div <div
role="region" role="region"
aria-label={t("consent.title")} aria-label={t("consent.title")}

View file

@ -251,7 +251,7 @@ export function InstallSheet() {
animate={reduceMotion ? { opacity: 1 } : { y: 0 }} animate={reduceMotion ? { opacity: 1 } : { y: 0 }}
exit={reduceMotion ? { opacity: 0 } : { y: "100%" }} exit={reduceMotion ? { opacity: 0 } : { y: "100%" }}
transition={{ duration: 0.34, ease: [0.22, 1, 0.36, 1] }} transition={{ duration: 0.34, ease: [0.22, 1, 0.36, 1] }}
className="gate-card absolute inset-x-0 bottom-0 mx-auto w-full rounded-b-none border-b-0 sm:max-w-xl" className="gate-card glass-sheet absolute inset-x-0 bottom-0 mx-auto w-full rounded-b-none border-b-0 sm:max-w-xl"
> >
<div className="thin-scroll max-h-[90dvh] overflow-y-auto px-5 pt-2 pb-[calc(24px+env(safe-area-inset-bottom))]"> <div className="thin-scroll max-h-[90dvh] overflow-y-auto px-5 pt-2 pb-[calc(24px+env(safe-area-inset-bottom))]">
<div <div

View file

@ -578,7 +578,7 @@ export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" |
animate={{ y: 0 }} animate={{ y: 0 }}
exit={{ y: 300 }} exit={{ y: 300 }}
transition={springSheet} transition={springSheet}
className="glass-panel relative z-10 rounded-t-3xl px-4 pb-safe pt-3" className="glass-panel glass-sheet relative z-10 rounded-t-3xl px-4 pb-safe pt-3"
> >
<div className="sheet-grabber" aria-hidden /> <div className="sheet-grabber" aria-hidden />
<h2 className="mb-3 text-[15px] font-semibold text-[var(--text)]">{t("library.moreTitle")}</h2> <h2 className="mb-3 text-[15px] font-semibold text-[var(--text)]">{t("library.moreTitle")}</h2>

View file

@ -12,6 +12,7 @@ import { springSheet } from "@/lib/motion";
import { ModelErrorActions } from "@/components/ModelErrorActions"; import { ModelErrorActions } from "@/components/ModelErrorActions";
import { DataSection } from "@/components/DataSection"; import { DataSection } from "@/components/DataSection";
import { LearningSection } from "@/components/LearningSection"; import { LearningSection } from "@/components/LearningSection";
import { ThemeSection } from "@/components/ThemeSection";
import { SyncSection } from "@/components/SyncSection"; import { SyncSection } from "@/components/SyncSection";
import { clearApiKey, saveSettings, settingsStore } from "@/lib/settings"; import { clearApiKey, saveSettings, settingsStore } from "@/lib/settings";
import { useBackClose } from "@/lib/use-back-close"; 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 }} exit={reduceMotion ? { opacity: 0 } : phone ? { y: "100%", opacity: 0 } : { y: 32, opacity: 0 }}
transition={phone ? springSheet : { type: "spring", stiffness: 340, damping: 32 }} transition={phone ? springSheet : { type: "spring", stiffness: 340, damping: 32 }}
onClick={(event) => event.stopPropagation()} 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. */} {/* handle + header: the drag strip. Fixed, so the body scrolls. */}
<div <div
@ -407,8 +408,13 @@ export function SettingsDialog({
/> />
</section> </section>
{/* ------------------------------------------- data & sync */} {/* ------------------------------------------- theme */}
<ThemeSection />
{/* ----------------------------------------- learning */}
<LearningSection level={3} /> <LearningSection level={3} />
{/* ------------------------------------------- data & sync */}
<DataSection /> <DataSection />
<SyncSection /> <SyncSection />
@ -764,8 +770,13 @@ export function SettingsDialog({
/> />
</section> </section>
{/* ------------------------------------------- data & sync */} {/* ------------------------------------------- theme */}
<ThemeSection />
{/* ----------------------------------------- learning */}
<LearningSection level={3} /> <LearningSection level={3} />
{/* ------------------------------------------- data & sync */}
<DataSection /> <DataSection />
<SyncSection /> <SyncSection />

View file

@ -279,6 +279,9 @@ function NavContent({ collapsed = false }: { collapsed?: boolean }) {
<GearIcon className="size-4 shrink-0 opacity-70" /> <GearIcon className="size-4 shrink-0 opacity-70" />
{!collapsed ? <span className="flex-1 truncate text-left">{t("common.settings")}</span> : null} {!collapsed ? <span className="flex-1 truncate text-left">{t("common.settings")}</span> : null}
</button> </button>
{/* 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 : (
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
@ -300,6 +303,7 @@ function NavContent({ collapsed = false }: { collapsed?: boolean }) {
</span> </span>
) : null} ) : null}
</button> </button>
)}
</div> </div>
</div> </div>
); );

View file

@ -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 (
<section className="mb-4 space-y-3 rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] p-3">
<h3 className="text-sm font-semibold text-frost-950 dark:text-frost-50">{t("settings.theme")}</h3>
<div className="segmented" role="group" aria-label={t("settings.theme")}>
{OPTIONS.map((option) => (
<button
key={option.id}
type="button"
aria-pressed={theme === option.id}
onClick={() => {
haptic("toggle");
setTheme(option.id);
}}
>
<span className="seg-label">{t(option.labelKey)}</span>
</button>
))}
</div>
{theme === "aero" ? (
<p className="text-[11px] leading-snug text-[var(--text-subtle)]">{t("theme.aeroNote")}</p>
) : null}
</section>
);
}

View file

@ -9,14 +9,18 @@ import { MoonIcon, SunIcon } from "@/components/icons";
const STORAGE_KEY = "freezmo.theme"; 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 * 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 * in-app theme. The SSR metas follow the OS preference; this one always wins
* because it is media-less and first in the head. * because it is media-less and first in the head.
*/ */
export function syncThemeColor(theme: "light" | "dark") { export function syncThemeColor(theme: Theme) {
try { try {
let meta = document.querySelector<HTMLMetaElement>('meta[name="theme-color"][data-app]'); let meta = document.querySelector<HTMLMetaElement>('meta[name="theme-color"][data-app]');
if (!meta) { 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("dark", next === "dark");
document.documentElement.classList.toggle("aero", next === "aero");
syncThemeColor(next); syncThemeColor(next);
try { try {
window.localStorage.setItem(STORAGE_KEY, next); window.localStorage.setItem(STORAGE_KEY, next);
@ -42,23 +47,33 @@ export function applyTheme(next: "light" | "dark") {
} }
export function useTheme() { export function useTheme() {
const [theme, setTheme] = useState<"light" | "dark">("light"); const [theme, setTheme] = useState<Theme>("light");
useEffect(() => { useEffect(() => {
const stored = window.localStorage.getItem(STORAGE_KEY); const stored = window.localStorage.getItem(STORAGE_KEY);
const isDark = stored const next: Theme =
? stored === "dark" stored === "dark" || stored === "aero"
: window.matchMedia("(prefers-color-scheme: dark)").matches; ? stored
setTheme(isDark ? "dark" : "light"); : stored === "light"
syncThemeColor(isDark ? "dark" : "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); setTheme(next);
applyTheme(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 }) { 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 { 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;
return ( return (
<button <button
type="button" type="button"

View file

@ -206,7 +206,7 @@ export function ContextPicker({
animate={reduceMotion ? { opacity: 1 } : { y: 0 }} animate={reduceMotion ? { opacity: 1 } : { y: 0 }}
exit={reduceMotion ? { opacity: 0 } : { y: "100%" }} exit={reduceMotion ? { opacity: 0 } : { y: "100%" }}
transition={reduceMotion ? { duration: 0 } : springSheet} transition={reduceMotion ? { duration: 0 } : springSheet}
className="glass-panel absolute inset-x-0 bottom-0 max-h-[85dvh] overflow-y-auto rounded-b-none px-4 pb-safe pt-2" className="glass-panel glass-sheet absolute inset-x-0 bottom-0 max-h-[85dvh] overflow-y-auto rounded-b-none px-4 pb-safe pt-2"
> >
<div className="sheet-grabber mx-auto" aria-hidden /> <div className="sheet-grabber mx-auto" aria-hidden />
<h2 className="px-1 pb-3 pt-2 text-[15px] font-semibold text-[var(--text)]">{t("chat.scope.title")}</h2> <h2 className="px-1 pb-3 pt-2 text-[15px] font-semibold text-[var(--text)]">{t("chat.scope.title")}</h2>

View file

@ -192,7 +192,7 @@ export function HistorySheet({ open, onClose, ...props }: HistoryProps & { open:
animate={reduceMotion ? { opacity: 1 } : { y: 0 }} animate={reduceMotion ? { opacity: 1 } : { y: 0 }}
exit={reduceMotion ? { opacity: 0 } : { y: "100%" }} exit={reduceMotion ? { opacity: 0 } : { y: "100%" }}
transition={reduceMotion ? { duration: 0 } : springSheet} transition={reduceMotion ? { duration: 0 } : springSheet}
className="glass-panel absolute inset-x-0 bottom-0 flex max-h-[80dvh] flex-col rounded-b-none px-3 pb-safe pt-2" className="glass-panel glass-sheet absolute inset-x-0 bottom-0 flex max-h-[80dvh] flex-col rounded-b-none px-3 pb-safe pt-2"
> >
<div className="sheet-grabber mx-auto" aria-hidden /> <div className="sheet-grabber mx-auto" aria-hidden />
<h2 className="px-1 pb-2 pt-2 text-[15px] font-semibold text-[var(--text)]">{t("chat.history")}</h2> <h2 className="px-1 pb-2 pt-2 text-[15px] font-semibold text-[var(--text)]">{t("chat.history")}</h2>

View file

@ -122,7 +122,7 @@ export function ModelPicker({
animate={reduceMotion ? { opacity: 1 } : { y: 0 }} animate={reduceMotion ? { opacity: 1 } : { y: 0 }}
exit={reduceMotion ? { opacity: 0 } : { y: "100%" }} exit={reduceMotion ? { opacity: 0 } : { y: "100%" }}
transition={reduceMotion ? { duration: 0 } : springSheet} transition={reduceMotion ? { duration: 0 } : springSheet}
className="glass-panel absolute inset-x-0 bottom-0 max-h-[80dvh] rounded-b-none px-4 pb-safe pt-2" className="glass-panel glass-sheet absolute inset-x-0 bottom-0 max-h-[80dvh] rounded-b-none px-4 pb-safe pt-2"
> >
<div className="sheet-grabber mx-auto" aria-hidden /> <div className="sheet-grabber mx-auto" aria-hidden />
<h2 className="px-1 pb-3 pt-2 text-[15px] font-semibold text-[var(--text)]">{t("chat.modelTitle")}</h2> <h2 className="px-1 pb-3 pt-2 text-[15px] font-semibold text-[var(--text)]">{t("chat.modelTitle")}</h2>

View file

@ -1001,6 +1001,9 @@ const en: Record<string, string> = {
"legal.privacy": "Privacy policy", "legal.privacy": "Privacy policy",
"legal.terms": "Terms of service", "legal.terms": "Terms of service",
"legal.cookies": "Cookie policy", "legal.cookies": "Cookie policy",
"settings.theme": "Theme",
"theme.aero": "Frutiger Aero",
"theme.aeroNote": "Aero has no light/dark switch — pick Light or Dark to get it back.",
"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.",
@ -1953,6 +1956,9 @@ const cs: Record<string, string> = {
"legal.privacy": "Zásady soukromí", "legal.privacy": "Zásady soukromí",
"legal.terms": "Podmínky použití", "legal.terms": "Podmínky použití",
"legal.cookies": "Zásady cookies", "legal.cookies": "Zásady cookies",
"settings.theme": "Téma",
"theme.aero": "Frutiger Aero",
"theme.aeroNote": "Aero nemá přepínač světlý/tmavý — vyber Světlý nebo Tmavý, pokud ho chceš zpět.",
"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.",

View file

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

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-v53"; const CACHE_VERSION = "freezmo-v54";
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

@ -48,7 +48,7 @@ const screens = [
for (const [label, path, mode] of screens) { for (const [label, path, mode] of screens) {
if (filter && !label.includes(filter)) continue; if (filter && !label.includes(filter)) continue;
for (const theme of ["dark", "light"]) { for (const theme of ["dark", "light", "aero"]) {
const { context, page } = await makePage(browser, { width: 390, height: 844, deviceScaleFactor: 1 }); const { context, page } = await makePage(browser, { width: 390, height: 844, deviceScaleFactor: 1 });
await seed(page, { decks: [DECK], theme }); await seed(page, { decks: [DECK], theme });
await page.goto(`${BASE}${path}`, { waitUntil: "networkidle" }); await page.goto(`${BASE}${path}`, { waitUntil: "networkidle" });

View file

@ -96,15 +96,19 @@ export async function seed(
key = "test-key", key = "test-key",
extra = {}, extra = {},
onlyIfMissing = false, onlyIfMissing = false,
/** Suites run as a returning user: the cookie notice is already dismissed.
* The regression suite passes false to test the notice itself. */
consent = true,
} = {}, } = {},
) { ) {
await page.addInitScript( await page.addInitScript(
({ locale, theme, decks, folders, homework, key, extra, onlyIfMissing }) => { ({ locale, theme, decks, folders, homework, key, extra, onlyIfMissing, consent }) => {
const put = (name, value) => { const put = (name, value) => {
if (onlyIfMissing && localStorage.getItem(name) !== null) return; if (onlyIfMissing && localStorage.getItem(name) !== null) return;
localStorage.setItem(name, value); localStorage.setItem(name, value);
}; };
put("freezmo.install.installed", "1"); put("freezmo.install.installed", "1");
if (consent) put("freezmo.consent.v1", JSON.stringify({ at: 1 }));
put("freezmo.locale.v1", locale); put("freezmo.locale.v1", locale);
put("freezmo.theme", theme); put("freezmo.theme", theme);
if (key) put("freezmo.settings.v1", JSON.stringify({ apiKey: key })); if (key) put("freezmo.settings.v1", JSON.stringify({ apiKey: key }));
@ -113,7 +117,7 @@ export async function seed(
put("freezmo.homework.v1", JSON.stringify(homework)); put("freezmo.homework.v1", JSON.stringify(homework));
for (const [name, value] of Object.entries(extra)) put(name, value); for (const [name, value] of Object.entries(extra)) put(name, value);
}, },
{ locale, theme, decks, folders, homework, key, extra, onlyIfMissing }, { locale, theme, decks, folders, homework, key, extra, onlyIfMissing, consent },
); );
} }

View file

@ -223,6 +223,41 @@ for (const locale of ["en", "cs"]) {
/* 5. axe-core contrast audit (360/390/desktop × dark/light × en/cs) -- */ /* 5. axe-core contrast audit (360/390/desktop × dark/light × en/cs) -- */
const AXE = axeSource(); const AXE = axeSource();
/* 6. frutiger aero: a third theme, no light/dark switch --------------- */
{
const { context, page } = await makePage(browser);
await seed(page, { decks: [DECK], theme: "aero" });
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForTimeout(900);
const state = await page.evaluate(() => ({
aero: document.documentElement.classList.contains("aero"),
dark: document.documentElement.classList.contains("dark"),
sky: getComputedStyle(document.documentElement).getPropertyValue("--ice-base-top").trim(),
themeColor: document.querySelector('meta[name="theme-color"][data-app]')?.getAttribute("content"),
}));
check("aero: applied without the dark class", state.aero && !state.dark, JSON.stringify(state));
check(
"aero: the scene becomes a sky",
state.sky.length > 0 && state.sky !== "#f4f9ff" && state.sky !== "#070b16",
state.sky,
);
check("aero: the status bar follows the sky", state.themeColor === "#3fa3e0", String(state.themeColor));
check(
"aero: the light/dark toggle is gone",
(await page.getByRole("button", { name: /Switch to (light|dark) mode/ }).count()) === 0,
);
await page.locator("nav.tabbar").getByRole("button").click();
await page.getByRole("dialog").waitFor({ timeout: 6000 });
await page.waitForTimeout(600);
const picker = page.getByRole("group", { name: "Theme" });
check(
"aero: settings offers light, dark and aero",
(await picker.count()) === 1 &&
(await picker.getByRole("button", { name: "Frutiger Aero" }).getAttribute("aria-pressed")) === "true",
);
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] });
@ -247,13 +282,13 @@ async function audit(label, { width = 390, locale, theme, openSettingsNow }) {
await context.close(); await context.close();
} }
for (const width of [360, 390, 1280]) { for (const width of [360, 390, 1280]) {
for (const theme of ["dark", "light"]) { for (const theme of ["dark", "light", "aero"]) {
for (const locale of ["en", "cs"]) { for (const locale of ["en", "cs"]) {
await audit(`${width} ${theme}/${locale} library`, { width, locale, theme }); await audit(`${width} ${theme}/${locale} library`, { width, locale, theme });
} }
} }
} }
for (const theme of ["dark", "light"]) { for (const theme of ["dark", "light", "aero"]) {
for (const locale of ["en", "cs"]) { for (const locale of ["en", "cs"]) {
await audit(`390 ${theme}/${locale} settings`, { width: 390, locale, theme, openSettingsNow: true }); await audit(`390 ${theme}/${locale} settings`, { width: 390, locale, theme, openSettingsNow: true });
} }

View file

@ -114,7 +114,8 @@ const browser = await launchBrowser();
/* 5. legal pages + the cookie notice --------------------------------- */ /* 5. legal pages + the cookie notice --------------------------------- */
{ {
const { context, page } = await makePage(browser); const { context, page } = await makePage(browser);
await seed(page, { decks: [] }); // consent: false — this block is the one place the notice must be visible.
await seed(page, { decks: [], consent: false });
for (const [path, heading] of [ for (const [path, heading] of [
["/privacy", "Privacy policy"], ["/privacy", "Privacy policy"],
["/terms", "Terms of service"], ["/terms", "Terms of service"],