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

@ -48,7 +48,7 @@ const screens = [
for (const [label, path, mode] of screens) {
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 });
await seed(page, { decks: [DECK], theme });
await page.goto(`${BASE}${path}`, { waitUntil: "networkidle" });

View file

@ -96,15 +96,19 @@ export async function seed(
key = "test-key",
extra = {},
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(
({ locale, theme, decks, folders, homework, key, extra, onlyIfMissing }) => {
({ locale, theme, decks, folders, homework, key, extra, onlyIfMissing, consent }) => {
const put = (name, value) => {
if (onlyIfMissing && localStorage.getItem(name) !== null) return;
localStorage.setItem(name, value);
};
put("freezmo.install.installed", "1");
if (consent) put("freezmo.consent.v1", JSON.stringify({ at: 1 }));
put("freezmo.locale.v1", locale);
put("freezmo.theme", theme);
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));
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) -- */
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 }) {
const { context, page } = await makePage(browser, { width, height: width >= 1280 ? 900 : 800, deviceScaleFactor: width >= 1280 ? 1 : 2 });
await seed(page, { locale, theme, decks: [DECK] });
@ -247,13 +282,13 @@ async function audit(label, { width = 390, locale, theme, openSettingsNow }) {
await context.close();
}
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"]) {
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"]) {
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 --------------------------------- */
{
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 [
["/privacy", "Privacy policy"],
["/terms", "Terms of service"],