diff --git a/README.md b/README.md index bd969c5..3937d57 100644 --- a/README.md +++ b/README.md @@ -1398,6 +1398,20 @@ 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). +66. **Aero's glass is thin, and the OS dark preference can't leak into it.** Two fixes from one + report ("the white UI should be blurry and transparent; some text is hard to read on first + load, until the theme is toggled"). The text bug was at boot: the inline script's dark test + was `s && !a ? s === 'dark' : matchMedia(...)`, which for a stored `aero` fell through to the + OS preference — so on a dark-preferring machine the page loaded with **both** `dark` and + `aero` classes and every `.dark` rule (starting with `body`'s colour and the segment labels) + painted the dark palette over the bright wallpaper, until a theme toggle re-applied the + classes. The stored value now decides alone and the class is toggled explicitly; + `tests/mobile-test.mjs` pins it with a dark-`colorScheme` browser context. And the surfaces + drop their thick white veils (a card top measured ~71% white) for a 14% base veil plus the + gloss over the pre-blurred wallpaper — the scene is clearly visible through the glass, small + chrome (rows, chips, segments, buttons) keeps slightly more veil, and the pixel-contrast scan + that re-runs in the deploy e2e gate still passes. + 65. **Slide content never flex-shrinks, and the AI note appears once.** A long typed answer in a short-answer activity shrank the textarea inside the slide's flex column (58px instead of its ~82px), clipping the user's text mid-line on a phone; every direct child of a study/review/guide @@ -1415,14 +1429,14 @@ Things I chose while building, with the reasoning: 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 a - Frutiger Aero scene made with Gemini (re-encoded to WebP in `public/aero/`), with ten drawn + Frutiger Aero scene made with Gemini (latest 1280x714 revision re-encoded to WebP in `public/aero/`), 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 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 viewport with `background-attachment: fixed`) instead of blurring the live scene: the bubbles move behind every surface, so a real `backdrop-filter` re-runs ~60 times a second (measured - 66ms/frame at 2x throttle vs 17ms with nothing blurring). The tint and the gloss ride on top of + 66ms/frame at 2x throttle vs 17ms with nothing blurring). A thin veil and the gloss ride on top of that layer, so the look is identical at zero per-frame cost. The helper lines that sit straight on the wallpaper (the keyboard tip, the AI note) get a small glass chip — dark text on grass measured 2.5:1. The light/dark diff --git a/app/globals.css b/app/globals.css index de6e249..3c1f0da 100644 --- a/app/globals.css +++ b/app/globals.css @@ -287,17 +287,18 @@ } /* The Aero gloss for the floating surfaces (sheets, dropdowns and the sticky - chrome keep a real backdrop-filter — they sit over scrolling content). */ + chrome keep a real backdrop-filter — they sit over scrolling content). + Thin like the card glass: the old 75% top read as a white lid. */ .aero .glass-panel, .aero .study-chrome, .aero .tabbar, .aero .glass { background-image: linear-gradient( 180deg, - rgb(255 255 255 / 0.75) 0%, - rgb(255 255 255 / 0.3) 46%, - rgb(255 255 255 / 0.06) 47%, - rgb(255 255 255 / 0.24) 100% + rgb(255 255 255 / 0.5) 0%, + rgb(255 255 255 / 0.18) 46%, + rgb(255 255 255 / 0.05) 47%, + rgb(255 255 255 / 0.14) 100% ); } @@ -364,15 +365,6 @@ 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); @@ -387,20 +379,21 @@ border: 1px solid rgb(255 255 255 / 0.6); color: #04223c; box-shadow: 0 12px 26px -16px rgb(2 62 110 / 0.55), inset 0 1px 0 rgb(255 255 255 / 0.9); - /* The glass itself comes from the pre-blurred wallpaper rule above. */ + /* The glass itself comes from the frosted recipes below. */ } -/* Liquid glass: far more transparent than the other themes, with a strong - backdrop blur doing the legibility work — the wallpaper stays visible - through every surface. Contrast is pixel-scanned in all three themes, so - the tint cannot drop below what the text needs. */ +/* Liquid glass: the surfaces themselves are the frosted wallpaper recipes + below. These tokens are the fills for the layers that keep a real backdrop + blur (sheets, dropdowns, the sticky chrome) and for nested chips, so they + stay thin too. Contrast is pixel-scanned in all three themes, so the tints + cannot drop below what the text needs. */ .aero { - --glass-bg: rgb(228 246 255 / 0.34); - --glass-solid-bg: rgb(232 248 255 / 0.46); - --glass-sheet-bg: rgb(232 248 255 / 0.8); - --surface-strong: rgb(255 255 255 / 0.34); - --surface-sunken: rgb(255 255 255 / 0.26); - --nav-active-bg: rgb(255 255 255 / 0.4); + --glass-bg: rgb(228 246 255 / 0.3); + --glass-solid-bg: rgb(232 248 255 / 0.42); + --glass-sheet-bg: rgb(232 248 255 / 0.74); + --surface-strong: rgb(255 255 255 / 0.32); + --surface-sunken: rgb(255 255 255 / 0.24); + --nav-active-bg: rgb(255 255 255 / 0.38); } /* Liquid glass without the per-frame cost: every surface shows a pre-blurred @@ -413,18 +406,16 @@ .aero .glass-solid, .aero .sidebar-surface, .aero .gate-card, -.aero .btn-primary, -.aero .grad-ice, -.aero .hint-line { +.aero .list-group { background-image: linear-gradient( 180deg, - rgb(255 255 255 / 0.5) 0%, - rgb(255 255 255 / 0.2) 47%, - rgb(255 255 255 / 0.07) 53%, - rgb(255 255 255 / 0.24) 100% + rgb(255 255 255 / 0.3) 0%, + rgb(255 255 255 / 0.1) 46%, + rgb(255 255 255 / 0.04) 53%, + rgb(255 255 255 / 0.14) 100% ), - linear-gradient(rgb(230 247 255 / 0.42), rgb(230 247 255 / 0.42)), + linear-gradient(rgb(255 255 255 / 0.14), rgb(255 255 255 / 0.14)), url("/aero/background-blur.webp"); background-size: 100% 100%, 100% 100%, cover; background-position: 0 0, 0 0, center 42%; @@ -432,6 +423,65 @@ background-repeat: no-repeat, no-repeat, no-repeat; } +/* The small chrome — rail rows, the active nav item, chips, segments and the + buttons — gets the same frosted glass with a touch more veil, so its small + text pops over the busy wallpaper. */ +.aero .nav-action, +.aero .nav-row[aria-current="page"], +.aero .nav-row[data-active="true"], +.aero .glass-chip, +.aero .btn-secondary, +.aero .btn-primary, +.aero .grad-ice, +.aero .segmented, +.aero .segmented > button[aria-pressed="true"], +.aero .segmented > a[aria-current="page"] { + background-image: + linear-gradient( + 180deg, + rgb(255 255 255 / 0.42) 0%, + rgb(255 255 255 / 0.16) 46%, + rgb(255 255 255 / 0.08) 53%, + rgb(255 255 255 / 0.2) 100% + ), + linear-gradient(rgb(255 255 255 / 0.12), rgb(255 255 255 / 0.12)), + url("/aero/background-blur.webp"); + background-size: 100% 100%, 100% 100%, cover; + background-position: 0 0, 0 0, center 42%; + background-attachment: scroll, scroll, fixed; + background-repeat: no-repeat, no-repeat, no-repeat; +} + +/* The phone tab bar carries its labels right at the bottom of the water, so + it keeps a little more veil than the cards. */ +.aero .tabbar { + background-image: + linear-gradient(rgb(255 255 255 / 0.48), rgb(255 255 255 / 0.48)), + url("/aero/background-blur.webp"); + background-size: 100% 100%, cover; + background-position: 0 0, center 42%; + background-attachment: scroll, fixed; + background-repeat: no-repeat, no-repeat; +} + +/* Inside a sheet or dialog the backdrop is not the raw scene — a scrim and a + more opaque sheet glass sit in between — so the viewport-aligned frosted + layer would not match what is actually behind the element (it showed the + dark water under the settings theme labels: 1.5:1). Nested chrome falls + back to the flat fills there; the sheet itself is genuinely blurred. */ +.aero .glass-sheet .card, +.aero .glass-sheet .glass-solid, +.aero .glass-sheet .list-group, +.aero .glass-sheet .glass-chip, +.aero .glass-sheet .segmented, +.aero .glass-sheet .segmented > button[aria-pressed="true"], +.aero .glass-sheet .segmented > a[aria-current="page"], +.aero .glass-sheet .btn-secondary, +.aero .glass-sheet .btn-primary, +.aero .glass-sheet .grad-ice { + background-image: none; +} + html, body { height: 100%; diff --git a/app/layout.tsx b/app/layout.tsx index cfaba70..a25f8ef 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -81,8 +81,12 @@ export const viewport: Viewport = { * Applies the saved theme before paint to avoid a flash, and keeps 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). + * The stored theme decides alone: aero is not dark, and the old ternary added + * `dark` alongside `aero` whenever the OS preferred dark — the dark palette + * on the bright wallpaper is what made the first load look washed out until + * a theme toggle re-applied the classes. */ -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?'#2fa7e6':(d?'#0e1424':'#f4f7fd')));}catch(e){}})();`; +const themeScript = `(function(){try{var DEV=${devInstanceJs};var s=localStorage.getItem('freezmo.theme');var a=s==='aero';var d=a?false:(s?s==='dark':window.matchMedia('(prefers-color-scheme: dark)').matches);document.documentElement.classList.toggle('dark',!!d);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?'#2fa7e6':(d?'#0e1424':'#f4f7fd')));}catch(e){}})();`; /** * Captures Chrome's `beforeinstallprompt` (it can fire before React hydrates) diff --git a/package.json b/package.json index ccbf510..3493a3b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "freezmo", - "version": "0.2.9", + "version": "0.3.1", "private": true, "license": "GPL-3.0-only", "scripts": { diff --git a/public/aero/background-blur.webp b/public/aero/background-blur.webp index 1dd3124..5d004fa 100644 Binary files a/public/aero/background-blur.webp and b/public/aero/background-blur.webp differ diff --git a/public/aero/background.webp b/public/aero/background.webp index 5cb4282..69ec728 100644 Binary files a/public/aero/background.webp and b/public/aero/background.webp differ diff --git a/public/sw.js b/public/sw.js index 99a7789..d7867f6 100644 --- a/public/sw.js +++ b/public/sw.js @@ -9,7 +9,7 @@ * * Bumping CACHE_VERSION is how a new deploy invalidates old assets. */ -const CACHE_VERSION = "freezmo-v61"; +const CACHE_VERSION = "freezmo-v62"; const SHELL_CACHE = `${CACHE_VERSION}-shell`; const ASSET_CACHE = `${CACHE_VERSION}-assets`; diff --git a/tests/lib.mjs b/tests/lib.mjs index dfc73bd..d09ab95 100644 --- a/tests/lib.mjs +++ b/tests/lib.mjs @@ -121,8 +121,8 @@ export async function seed( ); } -export async function makePage(browser, { width = 390, height = 844, deviceScaleFactor = 2, reducedMotion } = {}) { - const context = await browser.newContext({ viewport: { width, height }, deviceScaleFactor, reducedMotion }); +export async function makePage(browser, { width = 390, height = 844, deviceScaleFactor = 2, reducedMotion, colorScheme } = {}) { + const context = await browser.newContext({ viewport: { width, height }, deviceScaleFactor, reducedMotion, colorScheme }); const page = await context.newPage(); return { context, page }; } diff --git a/tests/mobile-test.mjs b/tests/mobile-test.mjs index 8e2d45b..df1eb3b 100644 --- a/tests/mobile-test.mjs +++ b/tests/mobile-test.mjs @@ -287,6 +287,22 @@ const AXE = axeSource(); await context.close(); } +/* 6c. a dark OS preference must not leak the dark palette into Aero ------ */ +{ + const { context, page } = await makePage(browser, { width: 390, height: 844, colorScheme: "dark" }); + await seed(page, { decks: [DECK], theme: "aero" }); + await page.goto(BASE, { waitUntil: "networkidle" }); + await page.waitForTimeout(1000); + const state = await page.evaluate(() => ({ + aero: document.documentElement.classList.contains("aero"), + dark: document.documentElement.classList.contains("dark"), + bodyColor: getComputedStyle(document.body).color, + })); + check("theme: aero on a dark OS keeps the dark class off", state.aero && !state.dark); + check("theme: boot paints the aero body text", state.bodyColor === "rgb(5, 32, 58)", state.bodyColor); + 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] });