Phone Aero glass + a timed run you can see

The phone (touch) chat chrome carried a 55% flat white veil while every other
Aero surface uses the thin 32% Ask-pill tint, so the header, welcome card and
composer read as plain white on a bright sky. They now use the same flat glass
as the cards, the answer/user bubbles keep 42% for long reading, and the
composer's field is a lighter 42% tile. The shared Aero wash block moved above
the touch media query so its background-image can no longer resurrect the bake
on phones.

The timed run worked but was invisible: the only cue was a small chip among the
streak/XP counters. The chrome pill now draws the countdown along its lower
edge, the chip is bordered (a control, not a stat), turns rose with the bar in
the last ten seconds, and fires one soft haptic tick at ten seconds.

Contrast scan clean on the phone chat in all three themes; activities suite
pins the countdown bar (25 checks). Version 0.6.2, SW v84.
This commit is contained in:
Martin Dvořák 2026-10-15 08:11:52 +00:00
commit a8b3dfd0e3
6 changed files with 129 additions and 75 deletions

View file

@ -1434,6 +1434,25 @@ Things I chose while building, with the reasoning:
(measured: 268..1280 at a 1280 window) — a flat pane floating 40px inside the scene read as (measured: 268..1280 at a 1280 window) — a flat pane floating 40px inside the scene read as
"cut off next to the sidebar". "cut off next to the sidebar".
93. **Phone Aero glass, and a timed run you can actually see.** Two follow-ups from a phone.
- *The chat read as a white pane.* On touch devices the chat header, welcome card and composer
carried a 55% flat white veil while every other surface in the app used the thin 32%
Ask-pill tint — over a bright sky that is "fully white with no transparency", nothing like
the rest of the theme. They now use the same flat glass as the cards (the baked wallpaper
layer stays desktop-only, exactly as on every other surface), the answer and user bubbles
keep a touch more body for long reading (42%), and the composer's field is a lighter 42%
tile of the same family. Ordering matters and is now pinned by a comment: the shared wash
block sits *before* the touch media query, otherwise its later `background-image` wins and
the bake (and its late repaint) comes back on phones. The chat screen's pixel-contrast scan
is clean in all three themes at 390px — the one earlier finding was the Next.js dev-tools
badge sitting over the disclaimer, not the panel.
- *The timer looked dead.* It worked (30 → 0, marked as a miss, toast, next card) but the only
cue was a small chip among the streak/XP counters, so turning it on seemed to do nothing.
The chrome pill now draws the countdown along its lower edge (`[data-timed-bar]`), the chip
has a border so it reads as a control, it turns rose together with the bar in the last ten
seconds, and a soft haptic tick fires at ten seconds. The activities suite pins the bar as
well as the countdown.
92. **Study cards: where the answer came from, read aloud, and timed runs.** Three additions to 92. **Study cards: where the answer came from, read aloud, and timed runs.** Three additions to
the study feed, all local and dependency-free. the study feed, all local and dependency-free.
- **From your notes.** The generator now returns `source` with every activity: the verbatim - **From your notes.** The generator now returns `source` with every activity: the verbatim

View file

@ -604,104 +604,6 @@
background-repeat: no-repeat, no-repeat; background-repeat: no-repeat, no-repeat;
} }
/* Touch devices: flat glass, no bake and no live blur. The baked layer is
`background-attachment: fixed`, which mobile engines repaint late (iOS
treats it as scroll) — panes showed a stale crop until a tap forced a
repaint. The honest alternative (a real backdrop-filter) is worse: every
pane re-blurs on every frame while the bubbles animate, and phones choke —
measured as input lag, "buttons take ages to respond". So on touch the
panes keep the thin Ask-pill tint over the sharp scene: transparent glass
at zero per-frame cost. Desktop keeps the baked blur. */
@media (max-width: 639px), (pointer: coarse) {
.aero .card,
.aero .glass-solid,
.aero .sidebar-surface,
.aero .gate-card,
.aero .list-group,
.aero .nav-action,
.aero .nav-row[aria-current="page"],
.aero .nav-row[aria-pressed="true"],
.aero .nav-row[data-active="true"],
.aero .glass-chip,
.aero .glass-pill,
.aero .btn-secondary,
.aero .btn-ghost,
.aero .chat-header,
.aero .chat-empty,
.aero .segmented {
background-image: none;
background-color: rgb(255 255 255 / 0.32);
}
.aero .chat-bubble,
.aero .chat-answer {
background-image: none;
background-color: rgb(255 255 255 / 0.55);
}
/* The chat chrome and the rail keep one even, a little stronger tint on
touch: without the baked blur the flat fill has to carry the surface on
its own, or the photo underneath tints it unevenly again. */
.aero .chat-header,
.aero .chat-empty,
.aero .chat-composer-band {
background-image: none;
background-color: rgb(255 255 255 / 0.55);
}
.aero .tabbar {
background-image: none;
}
/* The hand-rolled frost fills drop the bake too and keep their own tints. */
.aero [class*="bg-white/35"],
.aero [class*="bg-white/40"],
.aero [class*="bg-white/45"],
.aero [class*="bg-white/50"],
.aero [class*="bg-white/55"],
.aero [class*="bg-white/60"],
.aero [class*="bg-white/70"],
.aero [class*="bg-[var(--surface)]"],
.aero [class*="bg-[var(--surface-strong)]"],
.aero [class*="bg-[var(--surface-sunken)]"],
.aero [class*="bg-frost-50"] {
background-image: none;
}
}
/* Buttons need more presence than the cards: a stronger veil so a button reads
as pale glass over grass, sky or water — the "Ask pill" look — instead of
blending into the blurred scene. */
.aero .btn-secondary,
.aero .glass-pill {
background-image:
linear-gradient(
180deg,
rgb(255 255 255 / 0.52) 0%,
rgb(255 255 255 / 0.36) 46%,
rgb(255 255 255 / 0.32) 53%,
rgb(255 255 255 / 0.44) 100%
),
url("/aero/background-blur.webp?v=4");
background-size: 100% 100%, cover;
background-position: 0 0, center 42%;
background-attachment: scroll, fixed;
background-repeat: no-repeat, no-repeat;
}
.aero .btn-ghost {
background-image:
linear-gradient(
180deg,
rgb(255 255 255 / 0.52) 0%,
rgb(255 255 255 / 0.36) 46%,
rgb(255 255 255 / 0.32) 53%,
rgb(255 255 255 / 0.44) 100%
),
url("/aero/background-blur.webp?v=4");
background-size: 100% 100%, cover;
background-position: 0 0, center 42%;
background-attachment: scroll, fixed;
background-repeat: no-repeat, no-repeat;
border: 1px solid rgb(255 255 255 / 0.6);
color: var(--text);
}
/* Chat chrome and the rail: one even frosted recipe. The pre-blurred, /* Chat chrome and the rail: one even frosted recipe. The pre-blurred,
extra-soft wash of the wallpaper (background-glass) sits under a uniform extra-soft wash of the wallpaper (background-glass) sits under a uniform
white veil, so the surface colour cannot drift with the photo behind it — white veil, so the surface colour cannot drift with the photo behind it —
@ -763,6 +665,105 @@
border-color: rgb(255 255 255 / 0.9); border-color: rgb(255 255 255 / 0.9);
} }
/* Touch devices: flat glass, no bake and no live blur. The baked layer is
`background-attachment: fixed`, which mobile engines repaint late (iOS
treats it as scroll) — panes showed a stale crop until a tap forced a
repaint. The honest alternative (a real backdrop-filter) is worse: every
pane re-blurs on every frame while the bubbles animate, and phones choke —
measured as input lag, "buttons take ages to respond". So on touch the
panes keep the thin Ask-pill tint over the sharp scene: transparent glass
at zero per-frame cost. Desktop keeps the baked blur. */
@media (max-width: 639px), (pointer: coarse) {
.aero .card,
.aero .glass-solid,
.aero .sidebar-surface,
.aero .gate-card,
.aero .list-group,
.aero .nav-action,
.aero .nav-row[aria-current="page"],
.aero .nav-row[aria-pressed="true"],
.aero .nav-row[data-active="true"],
.aero .glass-chip,
.aero .glass-pill,
.aero .btn-secondary,
.aero .btn-ghost,
.aero .chat-header,
.aero .chat-empty,
.aero .chat-composer-band,
.aero .segmented {
background-image: none;
background-color: rgb(255 255 255 / 0.32);
}
/* Reading surfaces keep a touch more body than the chrome — long answers
have to stay comfortable over grass and water — but they are still glass,
not white panes. */
.aero .chat-bubble,
.aero .chat-answer {
background-image: none;
background-color: rgb(255 255 255 / 0.42);
}
/* The composer's field: a lighter, even tile inside the panel. */
.aero .chat-composer-band textarea,
.aero .chat-composer-band input[type="search"] {
background-image: none;
background-color: rgb(255 255 255 / 0.42);
}
.aero .tabbar {
background-image: none;
}
/* The hand-rolled frost fills drop the bake too and keep their own tints. */
.aero [class*="bg-white/35"],
.aero [class*="bg-white/40"],
.aero [class*="bg-white/45"],
.aero [class*="bg-white/50"],
.aero [class*="bg-white/55"],
.aero [class*="bg-white/60"],
.aero [class*="bg-white/70"],
.aero [class*="bg-[var(--surface)]"],
.aero [class*="bg-[var(--surface-strong)]"],
.aero [class*="bg-[var(--surface-sunken)]"],
.aero [class*="bg-frost-50"] {
background-image: none;
}
}
/* Buttons need more presence than the cards: a stronger veil so a button reads
as pale glass over grass, sky or water — the "Ask pill" look — instead of
blending into the blurred scene. */
.aero .btn-secondary,
.aero .glass-pill {
background-image:
linear-gradient(
180deg,
rgb(255 255 255 / 0.52) 0%,
rgb(255 255 255 / 0.36) 46%,
rgb(255 255 255 / 0.32) 53%,
rgb(255 255 255 / 0.44) 100%
),
url("/aero/background-blur.webp?v=4");
background-size: 100% 100%, cover;
background-position: 0 0, center 42%;
background-attachment: scroll, fixed;
background-repeat: no-repeat, no-repeat;
}
.aero .btn-ghost {
background-image:
linear-gradient(
180deg,
rgb(255 255 255 / 0.52) 0%,
rgb(255 255 255 / 0.36) 46%,
rgb(255 255 255 / 0.32) 53%,
rgb(255 255 255 / 0.44) 100%
),
url("/aero/background-blur.webp?v=4");
background-size: 100% 100%, cover;
background-position: 0 0, center 42%;
background-attachment: scroll, fixed;
background-repeat: no-repeat, no-repeat;
border: 1px solid rgb(255 255 255 / 0.6);
color: var(--text);
}
/* Page headings that sit straight on the wallpaper. */ /* Page headings that sit straight on the wallpaper. */
.aero .page-heading { .aero .page-heading {
display: inline-block; display: inline-block;

View file

@ -175,6 +175,9 @@ export function StudyFeed({ deck }: { deck: Deck }) {
/* Timed runs ---------------------------------------------------------- */ /* Timed runs ---------------------------------------------------------- */
const timedActivity = index < total ? queue[index] : null; const timedActivity = index < total ? queue[index] : null;
const timedAnswered = timedActivity ? Boolean(results[timedActivity.id]) : true; const timedAnswered = timedActivity ? Boolean(results[timedActivity.id]) : true;
/** Seconds left on the card in view, or null when no countdown runs. */
const timedRemaining = timed && remaining !== null && timedActivity && !timedAnswered ? remaining : null;
const timedDanger = timedRemaining !== null && timedRemaining <= 10;
useEffect(() => { useEffect(() => {
if (!timed || !timedActivity || timedAnswered) { if (!timed || !timedActivity || timedAnswered) {
@ -188,6 +191,15 @@ export function StudyFeed({ deck }: { deck: Deck }) {
return () => window.clearInterval(timer); return () => window.clearInterval(timer);
}, [timed, timedActivity, timedAnswered, runId]); }, [timed, timedActivity, timedAnswered, runId]);
/* Last ten seconds: one soft tick per card, like a phone timer. */
const warnedRef = useRef<string | null>(null);
useEffect(() => {
if (!timed || timedRemaining === null || timedRemaining > 10 || !timedActivity) return;
if (warnedRef.current === timedActivity.id) return;
warnedRef.current = timedActivity.id;
haptic("toggle");
}, [timed, timedRemaining, timedActivity]);
/* Ran out: the card counts as a miss (the same path as a wrong answer, so /* Ran out: the card counts as a miss (the same path as a wrong answer, so
history and the SRS schedule see it), the learner is told, and the feed history and the SRS schedule see it), the learner is told, and the feed
moves on. */ moves on. */
@ -290,7 +302,24 @@ export function StudyFeed({ deck }: { deck: Deck }) {
the streak/xp chips at the other end. Everything sits on the pill's the streak/xp chips at the other end. Everything sits on the pill's
centre line with even padding, so nothing crowds the rounded ends. */} centre line with even padding, so nothing crowds the rounded ends. */}
<header className="pt-safe absolute inset-x-0 top-0 z-30 px-3"> <header className="pt-safe absolute inset-x-0 top-0 z-30 px-3">
<div className="study-chrome mx-auto flex max-w-xl items-center gap-2.5 rounded-full px-2.5 py-2"> <div className="study-chrome relative mx-auto flex max-w-xl items-center gap-2.5 rounded-full px-2.5 py-2">
{/* The countdown, drawn along the pill's lower edge: the chip alone
was easy to miss mid-card. */}
{timedRemaining !== null ? (
<span
aria-hidden
className="pointer-events-none absolute inset-x-4 bottom-[0.3rem] h-[3px] overflow-hidden rounded-full bg-[color-mix(in_oklab,var(--text)_14%,transparent)]"
>
<span
data-timed-bar
className={cn(
"block h-full rounded-full transition-[width] duration-1000 ease-linear motion-reduce:transition-none",
timedDanger ? "bg-rose-500" : "grad-ice",
)}
style={{ width: `${Math.round((timedRemaining / TIMED_CARD_SECONDS) * 100)}%` }}
/>
</span>
) : null}
<Link <Link
href="/" href="/"
aria-label={t("feed.back")} aria-label={t("feed.back")}
@ -328,12 +357,14 @@ export function StudyFeed({ deck }: { deck: Deck }) {
aria-label={timed ? t("feed.timedOn") : t("feed.timed")} aria-label={timed ? t("feed.timedOn") : t("feed.timed")}
title={timed ? t("feed.timedOn") : t("feed.timed")} title={timed ? t("feed.timedOn") : t("feed.timed")}
className={cn( className={cn(
"inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-xs font-extrabold tabular-nums transition", "inline-flex items-center gap-1 rounded-full border px-2.5 py-1 text-xs font-extrabold tabular-nums transition",
timed ? "tint-peach" : "bg-[var(--surface-sunken)] text-[var(--text-muted)] hover:text-[var(--text)]", timed
? cn("border-transparent tint-peach", timedDanger && "tint-rose")
: "border-[var(--glass-border)] bg-[var(--surface-strong)] text-[var(--text-muted)] hover:text-[var(--text)]",
)} )}
> >
<ClockIcon className="size-3.5" /> <ClockIcon className="size-3.5" />
{timed && remaining !== null ? `${remaining}s` : `${TIMED_CARD_SECONDS}s`} {timedRemaining !== null ? `${timedRemaining}s` : `${TIMED_CARD_SECONDS}s`}
</button> </button>
<span <span
className={cn( className={cn(

View file

@ -1,6 +1,6 @@
{ {
"name": "freezmo", "name": "freezmo",
"version": "0.6.1", "version": "0.6.2",
"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-v83"; const CACHE_VERSION = "freezmo-v84";
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

@ -174,6 +174,9 @@ const DECK = {
await page.waitForTimeout(200); await page.waitForTimeout(200);
const afterFive = Number.parseInt((await toggle.textContent()) ?? "", 10); const afterFive = Number.parseInt((await toggle.textContent()) ?? "", 10);
check("timed: the countdown ticks down", afterFive <= 26 && afterFive > 0, String(afterFive)); check("timed: the countdown ticks down", afterFive <= 26 && afterFive > 0, String(afterFive));
const bar = await page.locator("[data-timed-bar]").evaluate((element) => element.getBoundingClientRect().width);
const track = await page.locator("[data-timed-bar]").evaluate((element) => element.parentElement.getBoundingClientRect().width);
check("timed: the countdown is drawn along the chrome", bar > 10 && bar < track, `${Math.round(bar)}/${Math.round(track)}`);
await page.clock.runFor(26_000); await page.clock.runFor(26_000);
await page.waitForTimeout(300); await page.waitForTimeout(300);