Aero goes underwater with rising bubbles; floating cookie notice; Czech title

Frutiger Aero, closer to the mock:
- The scene is water now: sun shafts through a bright surface, caustic
  patches, reeds on the left, a city silhouette on the right, and ten soap
  bubbles that actually rise (own keyframes, varied sizes/speeds/sway; the
  spans are display:none outside aero so they cost nothing elsewhere, and
  they sit still under reduced motion).
- Pale blue glass at 0.8: still reads as glass, but dark navy text clears
  4.5:1 over the water. The phone tab bar mixes the sheet glass instead of
  the page — in aero the page *is* the water, which left its labels at 3.9:1.
- The aero page-level text (muted/subtle) is much darker for the same reason.
- The PWA status bar follows the new water blue (#2fa7e6).

The cookie notice is floating glass now (blurred backdrop, rim, shadow): as a
plain card it read as part of the page background instead of a notice.

Czech: "Tvoje mraznička" -> "Tvůj mrazák" (library title), test updated.

Contrast: 0 below-minimum findings and 0 axe violations across every screen,
all three themes. Version 0.2.1, SW -> freezmo-v55.
This commit is contained in:
Martin Dvořák 2026-10-13 05:46:53 +00:00
commit 67e2cefcfe
11 changed files with 194 additions and 71 deletions

View file

@ -1403,9 +1403,13 @@ Things I chose while building, with the reasoning:
grass, glossy glass with the classic horizon gloss, aqua buttons) and the scene (the glacier 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 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 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 before the first paint and the PWA status bar follows it (#2fa7e6). The scene is an underwater
darker than Light's because those labels sit directly on the sky. Covered by the axe and one: sun shafts, caustic patches, reeds on the left, a city silhouette on the right and ten
pixel-contrast scans in all three themes. rising bubbles (`.ice-bubble` — `display: none` outside aero, so they cost nothing in the other
themes, and they sit still under reduced motion). Its glass is a pale blue at 0.8 opacity:
transparent enough to read as glass, opaque enough that the dark navy text clears 4.5:1 over the
water — and the phone tab bar mixes the sheet glass rather than the page, which in aero *is* the
water. 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 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` judge text on blurred glass — it reports those nodes as "incomplete" — so `tests/contrast.mjs`
@ -1584,8 +1588,9 @@ Freezmo ships its own plain-language documents, in both app languages, rendered
| `/cookies` | No cookies at all (verified: no `Set-Cookie`, no `document.cookie`), what the strictly-necessary local storage holds, no tracking, and how to clear it. | | `/cookies` | No cookies at all (verified: no `Set-Cookie`, no `document.cookie`), what the strictly-necessary local storage holds, no tracking, and how to clear it. |
A cookie notice (`components/CookieConsent.tsx`) tells every new browser the same thing once and A cookie notice (`components/CookieConsent.tsx`) tells every new browser the same thing once and
remembers the dismissal (`freezmo.consent.v1`). It sits below the immersive feeds in the stacking remembers the dismissal (`freezmo.consent.v1`). It is floating glass — blurred backdrop, rim and
order, so it can never cover a study session, and above the phone tab bar. Settings links all three shadow — so it reads as a notice above the page rather than part of it. It sits below the immersive
feeds in the stacking order, so it can never cover a study session, and above the phone tab bar. Settings links all three
documents. The documents describe what the code actually does — keep them in step with it. documents. The documents describe what the code actually does — keep them in step with it.
## License ## License

View file

@ -196,93 +196,138 @@
--ice-grain-opacity: 0.045; --ice-grain-opacity: 0.045;
} }
/* Frutiger Aero — the third theme (Settings → Theme). A bright sky with /* Frutiger Aero — the third theme (Settings → Theme). An underwater scene:
clouds and a strip of grass, glossy glass with the classic horizon gloss, sunlight shafts, caustic patches, reeds on the left, a city silhouette on
aqua accents. Token-driven like light/dark; the light/dark toggles hide the right and bubbles rising through it; glass is a pale blue with the
while it is on (components/ThemeToggle.tsx). */ classic horizon gloss. Token-driven like light/dark; the light/dark toggles
hide while it is on (components/ThemeToggle.tsx). */
.aero { .aero {
color-scheme: light; color-scheme: light;
--page: #bfe4fb; --page: #1f86c9;
--sidebar: #e3f4ff; --sidebar: #d6efff;
--surface: #ffffff; --surface: #ffffff;
--surface-raised: #ffffff; --surface-raised: #ffffff;
--surface-strong: rgb(255 255 255 / 0.72); --surface-strong: rgb(255 255 255 / 0.72);
--card-hover-bg: rgb(255 255 255 / 0.9); --card-hover-bg: rgb(255 255 255 / 0.9);
--surface-sunken: rgb(255 255 255 / 0.55); --surface-sunken: rgb(255 255 255 / 0.5);
--hairline: rgb(255 255 255 / 0.8); --hairline: rgb(255 255 255 / 0.75);
--card-border: var(--glass-border); --card-border: var(--glass-border);
--sidebar-divider: rgb(255 255 255 / 0.7); --sidebar-divider: rgb(255 255 255 / 0.65);
--hairline-soft: rgb(255 255 255 / 0.6); --hairline-soft: rgb(255 255 255 / 0.55);
--glass-tint: rgb(255 255 255 / 0.6); --glass-tint: rgb(255 255 255 / 0.6);
--text: #05203a; /* 12.6:1 on the aero glass */ --text: #05203a; /* 10.9:1 on the aero glass */
--text-muted: #1b3a54; /* 8.4:1 */ --text-muted: #0a2137; /* 11.5:1 on glass, 5.0:1 on the water */
--text-subtle: #16324a; /* 7.6:1 on glass, 5.4:1 on the sky */ --text-subtle: #061726; /* 13.6:1 on glass, 5.5:1 on the water */
/* A few components use the frost scale directly (hero descriptions, the /* A few components use the frost scale directly (hero descriptions, the
wizard's step help): darker here, because they sit on the sky. */ wizard's step help): darker here, because they sit on the water. */
--color-frost-600: #1d3a55; --color-frost-600: #16324a;
--color-frost-700: #16324a; --color-frost-700: #102a41;
--hover: rgb(255 255 255 / 0.5); --hover: rgb(255 255 255 / 0.45);
--focus: #0284c7; --focus: #0369a1;
--glass-bg: rgb(255 255 255 / 0.62); /* Pale blue glass, like the mock: bright enough for dark navy text. */
--glass-bg: rgb(228 246 255 / 0.8);
--glass-border: rgb(255 255 255 / 0.9); --glass-border: rgb(255 255 255 / 0.9);
--glass-highlight: rgb(255 255 255 / 0.98); --glass-highlight: rgb(255 255 255 / 0.98);
--glass-shadow: 0 18px 44px -20px rgb(2 90 150 / 0.5); --glass-shadow: 0 18px 44px -20px rgb(2 62 110 / 0.55);
--glass-solid-bg: rgb(255 255 255 / 0.82); --glass-solid-bg: rgb(232 248 255 / 0.9);
--glass-sheet-bg: rgb(255 255 255 / 0.9); --glass-sheet-bg: rgb(232 246 255 / 0.94);
--glass-fallback: rgb(238 248 255 / 0.97); --glass-fallback: rgb(226 242 253 / 0.97);
--nav-active-bg: rgb(255 255 255 / 0.85); --nav-active-bg: rgb(255 255 255 / 0.85);
--nav-active-border: rgb(14 165 233 / 0.65); --nav-active-border: rgb(14 165 233 / 0.7);
--gate-card: rgb(255 255 255 / 0.66); --gate-card: rgb(226 244 255 / 0.7);
--gate-callout-bg: rgb(255 255 255 / 0.6); --gate-callout-bg: rgb(255 255 255 / 0.6);
--gate-callout-border: rgb(56 189 248 / 0.4); --gate-callout-border: rgb(56 189 248 / 0.45);
--field: rgb(255 255 255 / 0.75); --field: rgb(255 255 255 / 0.78);
--field-border: rgb(255 255 255 / 0.95); --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); --shadow-gate: 0 30px 70px -34px rgb(2 62 110 / 0.5), 0 1px 2px rgb(2 62 110 / 0.08);
--install-sheet: rgb(255 255 255 / 0.65); --install-sheet: rgb(226 244 255 / 0.7);
--install-card: rgb(255 255 255 / 0.75); --install-card: rgb(236 248 255 / 0.78);
--install-chip: rgb(255 255 255 / 0.6); --install-chip: rgb(255 255 255 / 0.62);
--install-border: rgb(255 255 255 / 0.85); --install-border: rgb(255 255 255 / 0.88);
--shadow-card: 0 2px 4px rgb(2 62 110 / 0.1), 0 12px 28px -14px rgb(2 62 110 / 0.35); --shadow-card: 0 2px 4px rgb(2 62 110 / 0.12), 0 14px 30px -16px rgb(2 62 110 / 0.4);
--card-shadow: var(--shadow-card); --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); --shadow-card-hover: 0 26px 60px -26px rgb(2 90 150 / 0.6), 0 3px 8px rgb(2 90 150 / 0.25);
/* The scene: sky, clouds, bubbles, grass. */ /* Scene tokens: the water itself is painted below. */
--ice-base-top: #3fa3e0; --ice-base-top: #2fa7e6;
--ice-base-bottom: #d9f1ff; --ice-base-bottom: #0a5490;
--ice-blob-opacity: 0.55; --ice-blob-opacity: 0.5;
--ice-orb-opacity: 0.55; --ice-orb-opacity: 0.6;
--ice-grain-opacity: 0.02; --ice-grain-opacity: 0.02;
} }
/* The blobs are clouds here, not glacier gradients. */ /* The water: sun shafts through a bright surface, deepening below. */
.aero .ice-scene {
background:
repeating-linear-gradient(
102deg,
rgb(255 255 255 / 0) 0px,
rgb(255 255 255 / 0.13) 46px,
rgb(255 255 255 / 0) 104px
),
linear-gradient(180deg, #45b3ec 0%, #93daf9 26%, #d8f2ff 34%, #58b6e8 37%, #3f9ad4 66%, #2b86c4 100%);
}
/* The blobs are caustic light patches here, not glacier gradients. */
.aero .ice-blob-a, .aero .ice-blob-a,
.aero .ice-blob-b, .aero .ice-blob-b,
.aero .ice-blob-c, .aero .ice-blob-c,
.aero .ice-blob-d { .aero .ice-blob-d {
background: radial-gradient(circle, rgb(255 255 255 / 0.95) 0%, rgb(255 255 255 / 0.6) 42%, transparent 72%); background: radial-gradient(circle, rgb(255 255 255 / 0.85) 0%, rgb(191 240 255 / 0.5) 45%, transparent 74%);
} }
/* The orbs are soap bubbles: a glassy rim with a bright specular. */ /* The orbs are the big soap bubbles: a glassy rim with a bright specular. */
.aero .ice-orb { .aero .ice-orb {
background: radial-gradient( background: radial-gradient(
circle at 30% 25%, circle at 32% 28%,
rgb(255 255 255 / 0.95) 0%, rgb(255 255 255 / 0.95) 0%,
rgb(224 247 255 / 0.45) 20%, rgb(226 248 255 / 0.4) 22%,
rgb(125 211 252 / 0.2) 52%, rgb(125 211 252 / 0.14) 56%,
rgb(56 189 248 / 0.35) 100% rgb(56 189 248 / 0.38) 100%
); );
box-shadow: 0 16px 34px -18px rgb(2 90 150 / 0.4), inset 0 -8px 18px -10px rgb(2 90 150 / 0.35); box-shadow: inset -8px -12px 20px -10px rgb(3 74 124 / 0.5), inset 8px 10px 16px -10px rgb(255 255 255 / 0.95), 0 12px 26px -16px rgb(3 74 124 / 0.55);
} }
/* A strip of grass along the bottom, like the wallpapers it comes from. */ /* Reeds on the left, drawn on the scene's own pseudo-elements (the grain
layer is at 2% opacity, so anything drawn on it is invisible). */
.aero .ice-scene::before {
content: "";
position: absolute;
left: -6vw;
bottom: -4vh;
width: 40vw;
height: 52vh;
opacity: 0.75;
background:
linear-gradient(74deg, transparent 40%, rgb(34 150 84 / 0.85) 40% 41.6%, transparent 41.6%),
linear-gradient(84deg, transparent 58%, rgb(74 222 128 / 0.7) 58% 59.4%, transparent 59.4%),
linear-gradient(64deg, transparent 22%, rgb(20 130 74 / 0.8) 22% 23.6%, transparent 23.6%),
linear-gradient(92deg, transparent 70%, rgb(56 189 116 / 0.6) 70% 71.2%, transparent 71.2%),
linear-gradient(52deg, transparent 8%, rgb(15 110 64 / 0.7) 8% 9.8%, transparent 9.8%);
pointer-events: none;
}
/* A city silhouette on the right: a row of blocks of different heights,
anchored to the water line and fading upward. */
.aero .ice-scene::after { .aero .ice-scene::after {
content: ""; content: "";
position: absolute; position: absolute;
inset-inline: 0; right: 3vw;
bottom: 0; bottom: 33vh;
height: 18vh; width: 36vw;
background: linear-gradient(180deg, rgb(134 239 172 / 0) 0%, rgb(74 222 128 / 0.5) 55%, rgb(22 163 74 / 0.6) 100%); height: 17vh;
opacity: 0.38;
background:
linear-gradient(rgb(7 58 104 / 0.95), rgb(7 58 104 / 0.95)) 0% 100% / 9% 64% no-repeat,
linear-gradient(rgb(7 58 104 / 0.95), rgb(7 58 104 / 0.95)) 12% 100% / 12% 100% no-repeat,
linear-gradient(rgb(7 58 104 / 0.95), rgb(7 58 104 / 0.95)) 27% 100% / 8% 52% no-repeat,
linear-gradient(rgb(7 58 104 / 0.95), rgb(7 58 104 / 0.95)) 38% 100% / 11% 78% no-repeat,
linear-gradient(rgb(7 58 104 / 0.95), rgb(7 58 104 / 0.95)) 52% 100% / 7% 44% no-repeat,
linear-gradient(rgb(7 58 104 / 0.95), rgb(7 58 104 / 0.95)) 62% 100% / 13% 88% no-repeat,
linear-gradient(rgb(7 58 104 / 0.95), rgb(7 58 104 / 0.95)) 78% 100% / 9% 58% no-repeat,
linear-gradient(rgb(7 58 104 / 0.95), rgb(7 58 104 / 0.95)) 90% 100% / 10% 72% no-repeat;
mask-image: linear-gradient(180deg, rgb(0 0 0 / 0.9) 0%, rgb(0 0 0 / 0.45) 75%, transparent 100%);
pointer-events: none; pointer-events: none;
} }
@ -296,13 +341,59 @@
.aero .gate-card { .aero .gate-card {
background-image: linear-gradient( background-image: linear-gradient(
180deg, 180deg,
rgb(255 255 255 / 0.7) 0%, rgb(255 255 255 / 0.75) 0%,
rgb(255 255 255 / 0.28) 46%, rgb(255 255 255 / 0.3) 46%,
rgb(255 255 255 / 0.04) 47%, rgb(255 255 255 / 0.06) 47%,
rgb(255 255 255 / 0.22) 100% rgb(255 255 255 / 0.24) 100%
); );
} }
/* Rising bubbles. Hidden entirely in the other themes, so they cost nothing
there; under reduced motion they sit still instead of rising. */
.ice-bubble {
display: none;
}
.aero .ice-bubble {
display: block;
position: absolute;
bottom: -14vh;
border-radius: 50%;
opacity: 0;
background: radial-gradient(
circle at 32% 28%,
rgb(255 255 255 / 0.95) 0%,
rgb(226 248 255 / 0.38) 24%,
rgb(125 211 252 / 0.12) 58%,
rgb(56 189 248 / 0.34) 100%
);
box-shadow: inset -6px -10px 16px -8px rgb(3 74 124 / 0.45), inset 6px 8px 14px -8px rgb(255 255 255 / 0.9), 0 10px 22px -14px rgb(3 74 124 / 0.5);
animation: aero-bubble-rise var(--bubble-duration, 20s) linear infinite;
animation-delay: var(--bubble-delay, 0s);
will-change: transform, opacity;
}
.ice-paused .ice-bubble {
animation-play-state: paused;
}
@keyframes aero-bubble-rise {
0% { transform: translate3d(0, 0, 0) scale(0.85); opacity: 0; }
6% { opacity: var(--bubble-opacity, 0.9); }
50% { transform: translate3d(var(--bubble-sway, 3vw), -52vh, 0) scale(1); }
94% { opacity: var(--bubble-opacity, 0.9); }
100% { transform: translate3d(calc(var(--bubble-sway, 3vw) * -0.5), -104vh, 0) scale(1.06); opacity: 0; }
}
.aero .ice-bubble-1 { width: 104px; height: 104px; left: 7%; --bubble-duration: 19s; --bubble-delay: -2s; --bubble-sway: 4vw; }
.aero .ice-bubble-2 { width: 34px; height: 34px; left: 16%; --bubble-duration: 13s; --bubble-delay: -7s; --bubble-sway: -3vw; }
.aero .ice-bubble-3 { width: 64px; height: 64px; left: 28%; --bubble-duration: 22s; --bubble-delay: -12s; --bubble-sway: 3vw; }
.aero .ice-bubble-4 { width: 20px; height: 20px; left: 38%; --bubble-duration: 11s; --bubble-delay: -4s; --bubble-sway: 2vw; }
.aero .ice-bubble-5 { width: 88px; height: 88px; left: 48%; --bubble-duration: 25s; --bubble-delay: -16s; --bubble-sway: -4vw; }
.aero .ice-bubble-6 { width: 28px; height: 28px; left: 58%; --bubble-duration: 12s; --bubble-delay: -9s; --bubble-sway: 3vw; }
.aero .ice-bubble-7 { width: 72px; height: 72px; left: 68%; --bubble-duration: 20s; --bubble-delay: -5s; --bubble-sway: -2vw; }
.aero .ice-bubble-8 { width: 46px; height: 46px; left: 78%; --bubble-duration: 16s; --bubble-delay: -13s; --bubble-sway: 4vw; }
.aero .ice-bubble-9 { width: 24px; height: 24px; left: 87%; --bubble-duration: 14s; --bubble-delay: -6s; --bubble-sway: -3vw; }
.aero .ice-bubble-10 { width: 120px; height: 120px; left: 92%; --bubble-duration: 27s; --bubble-delay: -20s; --bubble-sway: -5vw; --bubble-opacity: 0.75; }
/* Glossy aqua primary buttons and segments. */ /* Glossy aqua primary buttons and segments. */
.aero .grad-ice, .aero .grad-ice,
.aero .btn-primary, .aero .btn-primary,
@ -465,6 +556,8 @@ body:has(.overlay-open) :is(.app-header, .segmented, .tabbar, .cookie-notice) {
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.ice-blob { animation: none; } .ice-blob { animation: none; }
/* Bubbles sit still (and visible) instead of rising. */
.aero .ice-bubble { animation: none; opacity: 0.55; transform: translate3d(0, -60vh, 0); }
/* Orbs go back to the pre-rise static layout (a paused animation is not /* Orbs go back to the pre-rise static layout (a paused animation is not
applied at all in Chrome, which would leave them parked below the fold) applied at all in Chrome, which would leave them parked below the fold)
and give their composited layers back. */ and give their composited layers back. */
@ -736,6 +829,11 @@ body:has(.overlay-open) :is(.app-header, .segmented, .tabbar, .cookie-notice) {
.tabbar { .tabbar {
background: color-mix(in oklab, var(--page) 84%, transparent); background: color-mix(in oklab, var(--page) 84%, transparent);
} }
/* Aero's page is the water itself, so mixing it would leave a saturated
blue bar under dark labels (measured ~3.9:1). Mix the pale sheet glass. */
.aero .tabbar {
background: color-mix(in oklab, var(--glass-sheet-bg) 84%, transparent);
}
} }
/* Soft tinted chip for activity types (a hint of colour, not a rainbow) -- */ /* Soft tinted chip for activity types (a hint of colour, not a rainbow) -- */

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 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){}})();`; 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){}})();`;
/** /**
* Captures Chrome's `beforeinstallprompt` (it can fire before React hydrates) * Captures Chrome's `beforeinstallprompt` (it can fire before React hydrates)

View file

@ -41,7 +41,9 @@ export function CookieConsent() {
<div <div
role="region" role="region"
aria-label={t("consent.title")} aria-label={t("consent.title")}
className="card pointer-events-auto flex max-w-xl flex-wrap items-center gap-3 p-3" /* Floating glass (blurred backdrop + rim + shadow): as a plain card it
read as part of the page background instead of a notice above it. */
className="glass-panel pointer-events-auto flex max-w-xl flex-wrap items-center gap-3 p-3"
> >
<p className="min-w-0 flex-1 text-[12.5px] leading-snug text-[var(--text-muted)]"> <p className="min-w-0 flex-1 text-[12.5px] leading-snug text-[var(--text-muted)]">
{t("consent.body")}{" "} {t("consent.body")}{" "}

View file

@ -31,6 +31,18 @@ export function IceBackdrop() {
<span className="ice-orb ice-orb-3" /> <span className="ice-orb ice-orb-3" />
<span className="ice-orb ice-orb-4" /> <span className="ice-orb ice-orb-4" />
<span className="ice-orb ice-orb-5" /> <span className="ice-orb ice-orb-5" />
{/* Aero-only bubbles: display:none unless the aero theme is on, so they
cost nothing in the other themes (see .ice-bubble in globals.css). */}
<span className="ice-bubble ice-bubble-1" />
<span className="ice-bubble ice-bubble-2" />
<span className="ice-bubble ice-bubble-3" />
<span className="ice-bubble ice-bubble-4" />
<span className="ice-bubble ice-bubble-5" />
<span className="ice-bubble ice-bubble-6" />
<span className="ice-bubble ice-bubble-7" />
<span className="ice-bubble ice-bubble-8" />
<span className="ice-bubble ice-bubble-9" />
<span className="ice-bubble ice-bubble-10" />
<span className="ice-grain" /> <span className="ice-grain" />
</div> </div>
); );

View file

@ -9,7 +9,7 @@ import { MoonIcon, SunIcon } from "@/components/icons";
const STORAGE_KEY = "freezmo.theme"; const STORAGE_KEY = "freezmo.theme";
const THEME_COLORS = { light: "#f4f7fd", dark: "#0e1424", aero: "#3fa3e0" } as const; const THEME_COLORS = { light: "#f4f7fd", dark: "#0e1424", aero: "#2fa7e6" } as const;
/** The three themes. Aero is not a dark/light variant: it replaces the palette /** 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. */ * and the scene, and the light/dark toggles hide while it is on. */

View file

@ -1303,7 +1303,7 @@ const cs: Record<string, string> = {
"emptyStateHomework": "Nový úkol", "emptyStateHomework": "Nový úkol",
/* library --------------------------------------------------------- */ /* library --------------------------------------------------------- */
"library.title": "Tvoje mraznička", "library.title": "Tvůj mrazák",
"library.subtitle": "Sady zmrazené z tvých poznámek, uložené v tomto prohlížeči.", "library.subtitle": "Sady zmrazené z tvých poznámek, uložené v tomto prohlížeči.",
"library.import": "Importovat JSON", "library.import": "Importovat JSON",
"library.moreActions": "Další akce", "library.moreActions": "Další akce",

View file

@ -1,6 +1,6 @@
{ {
"name": "freezmo", "name": "freezmo",
"version": "0.1.8", "version": "0.2.1",
"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-v54"; const CACHE_VERSION = "freezmo-v55";
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

@ -29,7 +29,7 @@ const browser = await launchBrowser();
await seed(page, { locale: "cs", decks: [DECK] }); await seed(page, { locale: "cs", decks: [DECK] });
await page.goto(BASE, { waitUntil: "networkidle" }); await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForTimeout(900); await page.waitForTimeout(900);
check("library title is Czech", await page.getByRole("heading", { name: "Tvoje mraznička" }).isVisible()); check("library title is Czech", await page.getByRole("heading", { name: "Tvůj mrazák" }).isVisible());
check("sidebar says Všechny sady", await page.getByRole("button", { name: /Všechny sady/ }).first().isVisible()); check("sidebar says Všechny sady", await page.getByRole("button", { name: /Všechny sady/ }).first().isVisible());
check("sidebar says Nezařazené", await page.getByRole("button", { name: /Nezařazené/ }).first().isVisible()); check("sidebar says Nezařazené", await page.getByRole("button", { name: /Nezařazené/ }).first().isVisible());
check("deck card CTA is Studovat", (await page.getByRole("link", { name: "Studovat" }).count()) === 1); check("deck card CTA is Studovat", (await page.getByRole("link", { name: "Studovat" }).count()) === 1);

View file

@ -237,11 +237,17 @@ const AXE = axeSource();
})); }));
check("aero: applied without the dark class", state.aero && !state.dark, JSON.stringify(state)); check("aero: applied without the dark class", state.aero && !state.dark, JSON.stringify(state));
check( check(
"aero: the scene becomes a sky", "aero: the scene becomes water",
state.sky.length > 0 && state.sky !== "#f4f9ff" && state.sky !== "#070b16", state.sky.length > 0 && state.sky !== "#f4f9ff" && state.sky !== "#070b16",
state.sky, state.sky,
); );
check("aero: the status bar follows the sky", state.themeColor === "#3fa3e0", String(state.themeColor)); check(
"aero: bubbles rise",
(await page.locator(".ice-bubble").count()) === 10 &&
(await page.locator(".ice-bubble").first().evaluate((el) => getComputedStyle(el).animationName)) ===
"aero-bubble-rise",
);
check("aero: the status bar follows the water", state.themeColor === "#2fa7e6", String(state.themeColor));
check( check(
"aero: the light/dark toggle is gone", "aero: the light/dark toggle is gone",
(await page.getByRole("button", { name: /Switch to (light|dark) mode/ }).count()) === 0, (await page.getByRole("button", { name: /Switch to (light|dark) mode/ }).count()) === 0,