Thinner glass everywhere, chat answers as bubbles in Aero

Two asks. "Slightly more transparent": the veils drop another notch
(recipe 0.15 -> 0.12, tokens ~0.03 less, tab bar / wizard bar / gloss
likewise). "Some buttons aren't themed": the recipe now catches the
flows' hand-rolled frost by attribute selectors - the study activities'
bg-white/xx chips (multiple choice, matching, ordering, fill blank),
the exam rows that used bg-[var(--surface)] (an opaque white in Aero),
and everything on the surface tokens (chat chips, homework chips,
inputs, dropzones). Inside sheets the same shapes stay flat; the
dropzones lost their press-scale (a transform re-anchors the fixed
backdrop) and shade inward instead.

And the chat: the assistant answers are bubble-less by design and the
user bubble's thin tint washed out over the bright wallpaper, so the
longest text in the app sat straight on the photo. In Aero both roles
get a real pane now - chat-answer wraps the markdown in a bubble, the
user turn carries chat-bubble, with a stronger veil than the shared
glass for comfortable reading. Light and dark keep the bubble-less
assistant design (styled only under .aero).

Contrast: 0 below-minimum findings, 0 axe violations. Chat 101/101,
mobile 123/123, regression 44/44, i18n 13/13, native 33/33.
Version 0.3.5, SW -> freezmo-v65.
This commit is contained in:
Martin Dvořák 2026-10-13 12:43:46 +00:00
commit 5eec310e16
5 changed files with 102 additions and 25 deletions

View file

@ -1411,6 +1411,26 @@ Things I chose while building, with the reasoning:
pre-blurred wallpaper — the scene is clearly visible through the glass, and the pre-blurred wallpaper — the scene is clearly visible through the glass, and the
pixel-contrast scan still passes. pixel-contrast scan still passes.
70. **Chat answers are bubbles in Aero too.** The assistant answers are deliberately bubble-less
(full-width text on the flat themes) and the user bubble's thin tint washed out over the bright
parts of the wallpaper, so the longest text in the app ended up sitting straight on the photo.
In Aero both get a real pane: `.chat-answer` wraps the markdown in a bubble (plus a
`chat-bubble` class on the user turn), with a stronger veil than the shared glass so a wall of
text still reads comfortably over the same blurred scene, and the bright rim. Light and dark
keep the bubble-less assistant design — these classes are styled only under `.aero`.
69. **The glass got thinner and reaches the whole app.** Two asks in one pass: "slightly more
transparent" and "some buttons aren't themed" while doing decks/homework. The veils drop
another notch (recipe 0.15 -> 0.12, the tokens ~0.03 less, the tab bar / wizard bar / gloss
likewise), and the recipe now catches the flows' hand-rolled frost: the study activities'
`bg-white/xx` chips (multiple-choice options, matching terms, ordering cards, fill-in-blank
chips), the exam deck rows and pickers that used `bg-[var(--surface)]` — an *opaque* white in
Aero — and everything using the surface tokens (chat action chips, homework chips, inputs,
dropzones); attribute selectors match them, so no component needed a new class. The same shapes
inside sheets stay flat (the exclusion list covers the new selectors — there the sheet itself is
the glass), and the dropzones lost their press-scale (a transform on a fixed backdrop
re-anchors it) and shade inward instead. `tests/contrast.mjs`: 0 findings.
68. **The Ask pill joins the glass, and Aero's hovers light the pane instead of moving it.** Two 68. **The Ask pill joins the glass, and Aero's hovers light the pane instead of moving it.** Two
leftovers from the previous pass. The Ask button (and the two phone icon pills that share its leftovers from the previous pass. The Ask button (and the two phone icon pills that share its
styling) was a hand-styled `bg-[var(--surface-strong)]` pill that never picked up the frosted styling) was a hand-styled `bg-[var(--surface-strong)]` pill that never picked up the frosted

View file

@ -295,10 +295,10 @@
.aero .glass { .aero .glass {
background-image: linear-gradient( background-image: linear-gradient(
180deg, 180deg,
rgb(255 255 255 / 0.32) 0%, rgb(255 255 255 / 0.28) 0%,
rgb(255 255 255 / 0.1) 46%, rgb(255 255 255 / 0.08) 46%,
rgb(255 255 255 / 0.03) 47%, rgb(255 255 255 / 0.03) 47%,
rgb(255 255 255 / 0.1) 100% rgb(255 255 255 / 0.08) 100%
); );
} }
@ -386,12 +386,12 @@
stay thin too. Contrast is pixel-scanned in all three themes, so the tints stay thin too. Contrast is pixel-scanned in all three themes, so the tints
cannot drop below what the text needs. */ cannot drop below what the text needs. */
.aero { .aero {
--glass-bg: rgb(228 246 255 / 0.26); --glass-bg: rgb(228 246 255 / 0.22);
--glass-solid-bg: rgb(232 248 255 / 0.34); --glass-solid-bg: rgb(232 248 255 / 0.3);
--glass-sheet-bg: rgb(232 248 255 / 0.68); --glass-sheet-bg: rgb(232 248 255 / 0.64);
--surface-strong: rgb(255 255 255 / 0.26); --surface-strong: rgb(255 255 255 / 0.23);
--surface-sunken: rgb(255 255 255 / 0.2); --surface-sunken: rgb(255 255 255 / 0.17);
--nav-active-bg: rgb(255 255 255 / 0.3); --nav-active-bg: rgb(255 255 255 / 0.27);
} }
/* Liquid glass — one recipe for every surface, so the rail, the cards, the /* Liquid glass — one recipe for every surface, so the rail, the cards, the
@ -418,16 +418,29 @@
.aero .grad-ice, .aero .grad-ice,
.aero .segmented, .aero .segmented,
.aero .segmented > button[aria-pressed="true"], .aero .segmented > button[aria-pressed="true"],
.aero .segmented > a[aria-current="page"] { .aero .segmented > a[aria-current="page"],
/* Hand-rolled frost fills in the study, exam, chat and homework flows:
white/xx utilities and the surface tokens — without this they render as
flat white chips next to the glass. */
.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-frost-50"] {
background-image: background-image:
linear-gradient( linear-gradient(
180deg, 180deg,
rgb(255 255 255 / 0.16) 0%, rgb(255 255 255 / 0.13) 0%,
rgb(255 255 255 / 0.05) 46%, rgb(255 255 255 / 0.04) 46%,
rgb(255 255 255 / 0.04) 53%, rgb(255 255 255 / 0.03) 53%,
rgb(255 255 255 / 0.09) 100% rgb(255 255 255 / 0.07) 100%
), ),
linear-gradient(rgb(255 255 255 / 0.15), rgb(255 255 255 / 0.15)), linear-gradient(rgb(255 255 255 / 0.12), rgb(255 255 255 / 0.12)),
url("/aero/background-blur.webp?v=3"); url("/aero/background-blur.webp?v=3");
background-size: 100% 100%, 100% 100%, cover; background-size: 100% 100%, 100% 100%, cover;
background-position: 0 0, 0 0, center 42%; background-position: 0 0, 0 0, center 42%;
@ -439,7 +452,7 @@
blue, which read as a heavy slab over the photo — give it the same thin blue, which read as a heavy slab over the photo — give it the same thin
glass as everything else (it already has a real backdrop blur). */ glass as everything else (it already has a real backdrop blur). */
.aero [data-wizard-bar] { .aero [data-wizard-bar] {
background: rgb(255 255 255 / 0.24); background: rgb(255 255 255 / 0.2);
border-top-color: rgb(255 255 255 / 0.6); border-top-color: rgb(255 255 255 / 0.6);
box-shadow: 0 -14px 34px -22px rgb(2 62 110 / 0.5); box-shadow: 0 -14px 34px -22px rgb(2 62 110 / 0.5);
} }
@ -484,6 +497,7 @@
} }
.aero .card-hover:active, .aero .card-hover:active,
.aero .card-hover:hover, .aero .card-hover:hover,
.aero .press:active,
.aero .btn-primary:active, .aero .btn-primary:active,
.aero .segmented > button:active, .aero .segmented > button:active,
.aero .segmented > a:active, .aero .segmented > a:active,
@ -494,6 +508,7 @@
filter: none; filter: none;
} }
.aero .card-hover:active, .aero .card-hover:active,
.aero .press:active,
.aero .btn-primary:active, .aero .btn-primary:active,
.aero .nav-action:active, .aero .nav-action:active,
.aero .glass-pill:active, .aero .glass-pill:active,
@ -501,16 +516,46 @@
box-shadow: inset 0 2px 8px rgb(2 62 110 / 0.2), inset 0 1px 0 rgb(255 255 255 / 0.8); box-shadow: inset 0 2px 8px rgb(2 62 110 / 0.2), inset 0 1px 0 rgb(255 255 255 / 0.8);
} }
/* Chat: the longest text in the app sits straight on the wallpaper. The
assistant answers are deliberately bubble-less in the flat themes, and the
user bubble's thin tint washes out over the bright parts of the scene — in
Aero both get a real pane (a stronger veil than the shared glass, so an
answer reads as its own bubble). */
.aero .chat-bubble,
.aero .chat-answer {
border-color: rgb(255 255 255 / 0.7);
background-image:
linear-gradient(
180deg,
rgb(255 255 255 / 0.6) 0%,
rgb(255 255 255 / 0.44) 46%,
rgb(255 255 255 / 0.4) 53%,
rgb(255 255 255 / 0.52) 100%
),
url("/aero/background-blur.webp?v=3");
background-size: 100% 100%, cover;
background-position: 0 0, center 42%;
background-attachment: scroll, fixed;
background-repeat: no-repeat, no-repeat;
box-shadow: 0 16px 34px -22px rgb(2 62 110 / 0.45), inset 0 1px 0 rgb(255 255 255 / 0.9);
}
.aero .chat-answer {
border: 1px solid rgb(255 255 255 / 0.7);
border-radius: 1.25rem;
border-bottom-left-radius: 0.6rem;
padding: 0.7rem 0.95rem;
}
/* The phone tab bar carries its labels right at the bottom of the water, so /* The phone tab bar carries its labels right at the bottom of the water, so
it keeps a little more veil than the cards. */ it keeps a little more veil than the cards. */
.aero .tabbar { .aero .tabbar {
background-image: background-image:
linear-gradient( linear-gradient(
180deg, 180deg,
rgb(255 255 255 / 0.26) 0%, rgb(255 255 255 / 0.22) 0%,
rgb(255 255 255 / 0.12) 46%, rgb(255 255 255 / 0.1) 46%,
rgb(255 255 255 / 0.1) 53%, rgb(255 255 255 / 0.08) 53%,
rgb(255 255 255 / 0.16) 100% rgb(255 255 255 / 0.13) 100%
), ),
url("/aero/background-blur.webp?v=3"); url("/aero/background-blur.webp?v=3");
background-size: 100% 100%, cover; background-size: 100% 100%, cover;
@ -533,7 +578,17 @@
.aero .glass-sheet .segmented > a[aria-current="page"], .aero .glass-sheet .segmented > a[aria-current="page"],
.aero .glass-sheet .btn-secondary, .aero .glass-sheet .btn-secondary,
.aero .glass-sheet .btn-primary, .aero .glass-sheet .btn-primary,
.aero .glass-sheet .grad-ice { .aero .glass-sheet .grad-ice,
.aero .glass-sheet [class*="bg-white/35"],
.aero .glass-sheet [class*="bg-white/40"],
.aero .glass-sheet [class*="bg-white/45"],
.aero .glass-sheet [class*="bg-white/50"],
.aero .glass-sheet [class*="bg-white/55"],
.aero .glass-sheet [class*="bg-white/60"],
.aero .glass-sheet [class*="bg-white/70"],
.aero .glass-sheet [class*="bg-[var(--surface)]"],
.aero .glass-sheet [class*="bg-[var(--surface-strong)]"],
.aero .glass-sheet [class*="bg-frost-50"] {
background-image: none; background-image: none;
} }

View file

@ -43,7 +43,7 @@ export function MessageRow({
if (message.role === "user") { if (message.role === "user") {
return ( return (
<div className="flex flex-col items-end gap-1"> <div className="flex flex-col items-end gap-1">
<div className="max-w-[88%] rounded-3xl rounded-br-lg border border-[var(--glass-border)] bg-[var(--surface-strong)] px-3.5 py-2.5"> <div className="chat-bubble max-w-[88%] rounded-3xl rounded-br-lg border border-[var(--glass-border)] bg-[var(--surface-strong)] px-3.5 py-2.5">
<p className="whitespace-pre-wrap break-words text-[15px] leading-relaxed text-[var(--text)]">{message.content}</p> <p className="whitespace-pre-wrap break-words text-[15px] leading-relaxed text-[var(--text)]">{message.content}</p>
{message.attachments && message.attachments.length > 0 ? ( {message.attachments && message.attachments.length > 0 ? (
<p className="mt-1.5 text-[11.5px] text-[var(--text-subtle)]"> <p className="mt-1.5 text-[11.5px] text-[var(--text-subtle)]">
@ -69,7 +69,9 @@ export function MessageRow({
<div className="space-y-2"> <div className="space-y-2">
{message.thinking ? <ThinkingRow thinking={message.thinking} streaming={streaming && !message.content} /> : null} {message.thinking ? <ThinkingRow thinking={message.thinking} streaming={streaming && !message.content} /> : null}
{message.content ? ( {message.content ? (
<Markdown text={message.content} /> <div className="chat-answer">
<Markdown text={message.content} />
</div>
) : streaming ? ( ) : streaming ? (
<span className="flex items-center gap-1 py-1" role="status" aria-label={t("chat.thinking")}> <span className="flex items-center gap-1 py-1" role="status" aria-label={t("chat.thinking")}>
{[0, 1, 2].map((dot) => ( {[0, 1, 2].map((dot) => (

View file

@ -1,6 +1,6 @@
{ {
"name": "freezmo", "name": "freezmo",
"version": "0.3.3", "version": "0.3.5",
"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-v64"; const CACHE_VERSION = "freezmo-v65";
const SHELL_CACHE = `${CACHE_VERSION}-shell`; const SHELL_CACHE = `${CACHE_VERSION}-shell`;
const ASSET_CACHE = `${CACHE_VERSION}-assets`; const ASSET_CACHE = `${CACHE_VERSION}-assets`;