freezmo/app/globals.css
Martin Dvorak 724a068bec Aero music: the button works on the podcast player
"The music thing button with the Frutiger Aero doesn't work on the podcasts."
The podcast page is a full-screen overlay at z-45 and the control sat at z-40:
visible through the transparent page, but every tap landed on the overlay.

The control now takes a data-raised="player" position on /podcast/[id] -
z-index 46, docked to the header's corner (top under the header pill, right
mirroring the max-w-3xl geometry) where neither the transport bar nor the
voice panel lives - and the transcript gained pt-32 so the episode card clears
it. Study/review/exam feeds keep their "steps aside" behaviour.

aero-music-test: a podcast section asserts the control exists, that
elementFromPoint at its centre hits the button, and that a tap expands the
credit bar. 31 checks. Version 0.7.9, SW v101.
2026-10-16 21:04:39 +00:00

1845 lines
68 KiB
CSS
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

@import "tailwindcss";
/* Class-based dark mode (toggled on <html> by the theme script). */
@custom-variant dark (&:where(.dark, .dark *));
@theme {
/* Navy-tinted neutral scale — sampled from the app icon. ---------------- */
--color-frost-50: #f5f8fd;
--color-frost-100: #eaf0fa;
--color-frost-200: #d9e3f3;
--color-frost-300: #c2cfe4;
--color-frost-400: #92a3c0;
--color-frost-500: #6a7c9b;
--color-frost-600: #4e5f7d;
--color-frost-700: #3c4b66;
--color-frost-800: #2b3752;
--color-frost-900: #1d2740;
--color-frost-950: #0e1526;
/* Accent: glacier cyan to ice blue — focus rings, links, highlights. ---- */
--color-accent-50: #ecf9ff;
--color-accent-100: #d6f1ff;
--color-accent-200: #b0e5ff;
--color-accent-300: #7dd4fc;
--color-accent-400: #38bdf8;
--color-accent-500: #0ea5e9;
--color-accent-600: #0284c7;
--color-accent-700: #0369a1;
--color-accent-800: #075985;
--color-accent-900: #0c4a6e;
/* Aurora hues used only for the frosted backdrop wash. ----------------- */
--color-aurora-mint: #9fe3d0;
--color-aurora-sky: #9ccdf5;
--color-aurora-lilac: #c3b6f0;
--color-aurora-peach: #f6cdb4;
--font-sans: -apple-system, BlinkMacSystemFont, ui-sans-serif, system-ui,
"Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif,
"Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji";
--shadow-frost: 0 14px 34px -22px rgb(13 13 13 / 0.28);
--shadow-lift: 0 4px 14px -8px rgb(13 13 13 / 0.18);
--shadow-hairline: 0 1px 0 0 rgb(13 13 13 / 0.04);
--animate-float: float 11s ease-in-out infinite;
--animate-pop: pop 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
--animate-wiggle: wiggle 0.4s ease-in-out;
--animate-rise: rise 0.4s cubic-bezier(0.22, 1, 0.36, 1) both;
--animate-sheet: sheet 0.32s cubic-bezier(0.22, 1, 0.36, 1) both;
}
@keyframes float {
0%, 100% { transform: translate3d(0, 0, 0) scale(1) }
50% { transform: translate3d(0, -18px, 0) scale(1.04) }
}
@keyframes pop {
0% { transform: scale(0.92) }
60% { transform: scale(1.02) }
100% { transform: scale(1) }
}
@keyframes wiggle {
0%, 100% { transform: translateX(0) }
25% { transform: translateX(-5px) }
75% { transform: translateX(5px) }
}
@keyframes rise {
from { opacity: 0; transform: translateY(10px) }
to { opacity: 1; transform: translateY(0) }
}
@keyframes sheet {
from { opacity: 0; transform: translateY(24px) }
to { opacity: 1; transform: translateY(0) }
}
/* ---------------------------------------------------------------------------
Theme tokens.
Light: ChatGPT-ish neutral (page #ffffff, sidebar #f9f9f9, border #e5e5e5).
Dark: page #212121, sidebar #171717, surfaces #2f2f2f, borders rgba(255,255,255,.1).
Text tiers are picked to clear WCAG AA (4.5:1) on their own background.
--------------------------------------------------------------------------- */
:root {
color-scheme: light;
--page: #eef5fd;
--sidebar: #ffffff;
--surface: #ffffff;
--surface-raised: #ffffff;
--surface-strong: rgb(255 255 255 / 0.72); /* nested glass: inputs, chips */
--card-hover-bg: rgb(255 255 255 / 0.72);
--surface-sunken: rgb(12 32 62 / 0.06);
--track: rgb(12 32 62 / 0.14);
--hairline: #d5e4f4;
--card-border: var(--glass-border);
--sidebar-divider: rgb(255 255 255 / 0.75);
--hairline-soft: rgb(12 32 62 / 0.08);
--glass-tint: rgb(255 255 255 / 0.55);
--text: #0b1830; /* 16.6:1 on #eef5fd */
--text-muted: #40536b; /* 7.2:1 on light glass, 5.9:1 on sheet glass */
--text-subtle: #566a84; /* 5.6:1 on light glass */
--hover: rgb(12 32 62 / 0.05);
--focus: #0ea5e9;
/* Shared layout scale: one gutter for every floating panel and one radius
per role, so the rail, the header, the composer and the cards line up in
every theme — only the surface treatment differs. */
--layout-gutter: 0.75rem; /* 12px — the same inset the rail floats with */
--radius-panel: 1.5rem; /* 24px — floating panels and cards */
--radius-control: 0.875rem; /* 14px — buttons, inputs, tiles */
--radius-chip: 0.75rem; /* 12px — chips, nav rows, small controls */
--radius-pill: 999px; /* fully rounded */
/* Frosted ice glass — one shared recipe for every large surface. */
--glass-bg: rgb(255 255 255 / 0.55);
/* A cool rim instead of near-white: white-on-white made light-mode card
edges invisible against the pale page. */
--glass-border: rgb(143 174 212 / 0.68);
--glass-highlight: rgb(255 255 255 / 0.85);
--glass-shadow: 0 20px 50px -20px rgb(30 64 175 / 0.35);
--glass-solid-bg: rgb(255 255 255 / 0.8); /* question card: more opaque */
/* Sheet panels float over a dark scrim, so they need a more opaque surface
than the cards: at 0.55 the muted text under it measured ~2.8:1. */
--glass-sheet-bg: rgb(255 255 255 / 0.93);
--glass-fallback: rgb(247 251 255 / 0.97); /* no backdrop-filter */
--nav-active-bg: rgb(219 238 254 / 0.95);
--nav-active-border: rgb(56 189 248 / 0.65);
/* API-key gate: cool near-white stage with soft blue glows, tinted input. */
--gate-card: rgb(255 255 255 / 0.62);
--gate-callout-bg: rgb(255 255 255 / 0.55);
--gate-callout-border: rgb(56 189 248 / 0.32);
--field: rgb(255 255 255 / 0.66);
--field-border: rgb(255 255 255 / 0.9);
--shadow-gate: 0 28px 70px -36px rgb(14 21 40 / 0.3), 0 1px 2px rgb(14 21 40 / 0.05);
/* Install sheet: light variant (dark is the default, see .dark). */
--install-sheet: rgb(255 255 255 / 0.6);
--install-card: rgb(255 255 255 / 0.7);
--install-chip: rgb(255 255 255 / 0.55);
--install-border: rgb(255 255 255 / 0.8);
--shadow-card: 0 1px 2px rgb(14 21 40 / 0.06), 0 1px 3px rgb(14 21 40 / 0.04);
--card-shadow: 0 1px 2px rgb(14 21 40 / 0.08), 0 12px 26px -10px rgb(14 21 40 / 0.22);
--shadow-card-hover: 0 24px 60px -24px rgb(30 64 175 / 0.45), 0 2px 6px rgb(30 64 175 / 0.12);
}
.dark {
color-scheme: dark;
--page: #0a1120;
--sidebar: #0b1524;
--surface: #16233a;
--surface-raised: #1d2c47;
--surface-strong: rgb(255 255 255 / 0.08);
--card-hover-bg: rgb(255 255 255 / 0.1);
--surface-sunken: rgb(255 255 255 / 0.07);
--track: rgb(255 255 255 / 0.18);
--hairline: rgb(255 255 255 / 0.12);
--card-border: var(--glass-border);
--sidebar-divider: rgb(255 255 255 / 0.12);
--hairline-soft: rgb(255 255 255 / 0.08);
--glass-tint: rgb(255 255 255 / 0.06);
--text: #e8f0ff; /* 16.3:1 on #0a1120 */
--text-muted: #c2d1e8; /* ~12:1 */
--text-subtle: #adbfd9; /* ~10:1 on page, 8.4:1 on dark glass */
--hover: rgb(255 255 255 / 0.07);
--focus: #38bdf8;
/* Frosted ice glass — dark: barely-there tint, brighter rim. */
--glass-bg: rgb(255 255 255 / 0.06);
--glass-border: rgb(255 255 255 / 0.12);
--glass-highlight: rgb(255 255 255 / 0.25);
--glass-shadow: 0 20px 50px -20px rgb(30 64 175 / 0.35);
--glass-solid-bg: rgb(255 255 255 / 0.1);
--glass-sheet-bg: rgb(17 27 46 / 0.94);
--glass-fallback: rgb(16 26 45 / 0.97);
--nav-active-bg: rgb(56 189 248 / 0.18);
--nav-active-border: rgb(56 189 248 / 0.55);
/* API-key gate: navy stage with the icon's blue glows, raised input. */
--gate-card: rgb(255 255 255 / 0.06);
--gate-callout-bg: rgb(255 255 255 / 0.06);
--gate-callout-border: rgb(56 189 248 / 0.3);
--field: rgb(255 255 255 / 0.07);
--field-border: rgb(255 255 255 / 0.14);
--shadow-gate: 0 30px 80px -40px rgb(0 0 0 / 0.85), 0 1px 2px rgb(0 0 0 / 0.4);
/* Install sheet: layered dark surfaces (default theme for the sheet). */
--install-sheet: rgb(255 255 255 / 0.06);
--install-card: rgb(255 255 255 / 0.07);
--install-chip: rgb(255 255 255 / 0.1);
--install-border: rgb(255 255 255 / 0.14);
--shadow-card: 0 1px 2px rgb(0 0 0 / 0.25);
--card-shadow: var(--shadow-card);
--shadow-card-hover: 0 24px 60px -24px rgb(30 64 175 / 0.55), 0 2px 8px rgb(0 0 0 / 0.3);
}
/* Ice scene tokens: the fixed layered background behind everything.
The four glacier blobs are deliberately whisper-faint now: at 0.18/0.40
they competed with the content (the "huge circles" read as part of the
UI). 25% of the old values. */
:root {
--ice-base-top: #f4f9ff;
--ice-base-bottom: #e6f1fb;
--ice-blob-opacity: 0.045;
--ice-orb-opacity: 0.22;
}
.dark {
--ice-base-top: #070b16;
--ice-base-bottom: #0c1426;
--ice-blob-opacity: 0.1;
--ice-orb-opacity: 0.38;
}
/* Frutiger Aero — the third theme (Settings → Theme). An underwater scene:
sunlight shafts, caustic patches, reeds on the left, a city silhouette on
the right and bubbles rising through it; glass is a pale blue with the
classic horizon gloss. Token-driven like light/dark; the light/dark toggles
hide while it is on (components/ThemeToggle.tsx). */
.aero {
color-scheme: light;
--page: #1f86c9;
--sidebar: #d6efff;
--surface: #ffffff;
--surface-raised: #ffffff;
--surface-strong: rgb(255 255 255 / 0.72);
--card-hover-bg: rgb(255 255 255 / 0.5);
--surface-sunken: rgb(255 255 255 / 0.5);
--hairline: rgb(255 255 255 / 0.75);
--card-border: var(--glass-border);
--sidebar-divider: rgb(255 255 255 / 0.65);
--hairline-soft: rgb(255 255 255 / 0.55);
--glass-tint: rgb(255 255 255 / 0.6);
--text: #05203a; /* 10.9:1 on the aero glass */
--text-muted: #0a2137; /* 11.5:1 on glass, 5.0:1 on the water */
--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
wizard's step help): darker here, because they sit on the water. */
--color-frost-600: #16324a;
--color-frost-700: #102a41;
--hover: rgb(255 255 255 / 0.32);
--focus: #0369a1;
/* 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-highlight: rgb(255 255 255 / 0.98);
--glass-shadow: 0 18px 44px -20px rgb(2 62 110 / 0.55);
--glass-solid-bg: rgb(232 248 255 / 0.9);
--glass-sheet-bg: rgb(232 246 255 / 0.94);
--glass-fallback: rgb(226 242 253 / 0.97);
--nav-active-bg: rgb(255 255 255 / 0.5);
--nav-active-border: rgb(14 165 233 / 0.75);
--gate-card: rgb(226 244 255 / 0.7);
--gate-callout-bg: rgb(255 255 255 / 0.6);
--gate-callout-border: rgb(56 189 248 / 0.45);
--field: rgb(255 255 255 / 0.78);
--field-border: rgb(255 255 255 / 0.95);
--shadow-gate: 0 30px 70px -34px rgb(2 62 110 / 0.5), 0 1px 2px rgb(2 62 110 / 0.08);
--install-sheet: rgb(226 244 255 / 0.7);
--install-card: rgb(236 248 255 / 0.78);
--install-chip: rgb(255 255 255 / 0.62);
--install-border: rgb(255 255 255 / 0.88);
--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);
--shadow-card-hover: 0 26px 60px -26px rgb(2 90 150 / 0.6), 0 3px 8px rgb(2 90 150 / 0.25);
/* Scene tokens: the water itself is painted below. */
--ice-base-top: #2fa7e6;
--ice-base-bottom: #0a5490;
--ice-blob-opacity: 0.5;
--ice-orb-opacity: 0.6;
}
/* The scene is the wallpaper itself (a Frutiger Aero scene made with Gemini,
in public/aero/). Only the moving bubbles are drawn on top; the image
already has grass, sun, water and clouds. */
.aero .ice-scene {
background-image: url("/aero/background.webp?v=4");
background-size: cover;
background-position: center 42%;
background-repeat: no-repeat;
}
/* The drawn extras belong to the old CSS scene: off over the photo. */
.aero .ice-scene::before,
.aero .ice-scene::after,
.aero .ice-blob {
display: none;
}
/* The orbs become extra bubbles over the photo. */
.aero .ice-orb {
background:
radial-gradient(circle at 30% 24%, rgb(255 255 255 / 0.95) 0%, rgb(255 255 255 / 0) 16%),
radial-gradient(circle at 72% 80%, rgb(255 255 255 / 0.4) 0%, rgb(255 255 255 / 0) 20%),
radial-gradient(
circle at 32% 28%,
rgb(255 255 255 / 0.9) 0%,
rgb(226 248 255 / 0.4) 22%,
rgb(125 211 252 / 0.14) 56%,
rgb(56 189 248 / 0.38) 100%
);
box-shadow:
inset -8px -12px 20px -10px rgb(3 74 124 / 0.5),
inset 8px 10px 16px -10px rgb(255 255 255 / 1),
inset 0 0 0 1px rgb(255 255 255 / 0.35),
0 12px 26px -16px rgb(3 74 124 / 0.55);
}
/* The Aero gloss for the floating surfaces (sheets, dropdowns and the sticky
chrome keep a real backdrop-filter — they sit over scrolling content).
Thin like the card glass: a whisper of sheen, not a white lid. */
.aero .glass-panel,
.aero .study-chrome,
.aero .tabbar,
.aero .glass {
background-image: linear-gradient(
180deg,
rgb(255 255 255 / 0.28) 0%,
rgb(255 255 255 / 0.08) 46%,
rgb(255 255 255 / 0.03) 47%,
rgb(255 255 255 / 0.08) 100%
);
}
/* Rising bubbles. Hidden entirely in the other themes, so they cost nothing
there; under reduced motion they sit still instead of rising. Each one
rises from the bottom of the open band to its top (see .ice-floats) and
fades in and out inside it, so the band's clip never cuts a visible
bubble. */
.ice-bubble {
display: none;
}
.aero .ice-bubble {
display: block;
position: absolute;
bottom: 0;
border-radius: 50%;
opacity: 0;
background:
radial-gradient(circle at 30% 24%, rgb(255 255 255 / 0.95) 0%, rgb(255 255 255 / 0) 16%),
radial-gradient(circle at 72% 80%, rgb(255 255 255 / 0.4) 0%, rgb(255 255 255 / 0) 20%),
radial-gradient(
circle at 32% 28%,
rgb(255 255 255 / 0.9) 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.5),
inset 6px 8px 14px -8px rgb(255 255 255 / 1),
inset 0 0 0 1px rgb(255 255 255 / 0.35),
0 10px 22px -14px rgb(3 74 124 / 0.5);
border: 1px solid rgb(255 255 255 / 0.25);
animation: aero-bubble-rise var(--bubble-duration, 20s) linear infinite;
animation-delay: var(--bubble-delay, 0s);
will-change: transform, opacity;
}
@keyframes aero-bubble-rise {
0% { transform: translate3d(0, 0, 0) scale(0.85); opacity: 0; }
18% { opacity: var(--bubble-opacity, 0.9); }
50% { transform: translate3d(var(--bubble-sway, 3vw), calc(var(--ice-rise) * 0.5), 0) scale(1); }
90% { opacity: var(--bubble-opacity, 0.9); }
100% { transform: translate3d(calc(var(--bubble-sway, 3vw) * -0.5), calc(var(--ice-rise) + 3%), 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; }
.aero .ice-bubble-11 { width: 18px; height: 18px; left: 4%; --bubble-duration: 12s; --bubble-delay: -3s; --bubble-sway: 3vw; }
.aero .ice-bubble-12 { width: 58px; height: 58px; left: 12%; --bubble-duration: 18s; --bubble-delay: -11s; --bubble-sway: -3vw; }
.aero .ice-bubble-13 { width: 30px; height: 30px; left: 21%; --bubble-duration: 15s; --bubble-delay: -5s; --bubble-sway: -2vw; }
.aero .ice-bubble-14 { width: 96px; height: 96px; left: 34%; --bubble-duration: 23s; --bubble-delay: -19s; --bubble-sway: 4vw; --bubble-opacity: 0.8; }
.aero .ice-bubble-15 { width: 22px; height: 22px; left: 42%; --bubble-duration: 10s; --bubble-delay: -8s; --bubble-sway: -3vw; }
.aero .ice-bubble-16 { width: 44px; height: 44px; left: 54%; --bubble-duration: 16s; --bubble-delay: -2s; --bubble-sway: 2vw; }
.aero .ice-bubble-17 { width: 62px; height: 62px; left: 62%; --bubble-duration: 21s; --bubble-delay: -14s; --bubble-sway: -4vw; }
.aero .ice-bubble-18 { width: 26px; height: 26px; left: 74%; --bubble-duration: 13s; --bubble-delay: -6s; --bubble-sway: 3vw; }
/* Helper lines that sit straight on the wallpaper (the keyboard tip, the AI
note) need their own surface in Aero: dark text on the grass measured
2.5:1. A small glass chip fixes it without hiding the wallpaper — the
shared frosted recipe below, not a live blur. */
.aero .hint-line {
display: inline-block;
padding: 0.2rem 0.6rem;
border-radius: 0.75rem;
background-color: var(--glass-bg);
border: 1px solid var(--glass-border);
}
/* Bright white rims on the study/homework segments (the frosted fill itself
comes from the shared glass recipe below). */
.aero .segmented > button,
.aero .segmented > a {
border-color: rgb(255 255 255 / 0.5);
}
/* Liquid glass buttons: translucent, glossed top half, bright rim — the
Vista/Aero button, not a solid fill. */
.aero .grad-ice,
.aero .btn-primary,
.aero .segmented > button[aria-pressed="true"],
.aero .segmented > a[aria-current="page"] {
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 frosted recipes below. */
}
/* 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.22);
--glass-solid-bg: rgb(232 248 255 / 0.3);
--glass-sheet-bg: rgb(232 248 255 / 0.64);
--surface-strong: rgb(255 255 255 / 0.23);
--surface-sunken: rgb(255 255 255 / 0.17);
--track: rgb(255 255 255 / 0.38);
--nav-active-bg: rgb(255 255 255 / 0.5);
}
/* Liquid glass — one recipe for every surface, so the rail, the cards, the
chips, the segments and the buttons all read exactly like the Ask pill: a
pre-blurred, slightly lifted copy of the wallpaper (glued to the viewport,
same cover/42% geometry as the scene) under a whisper-thin veil and a
bright rim. Real backdrop-filter is skipped on purpose: the bubbles move
behind every surface, so it re-runs ~60 times a second (measured 66ms per
frame at 2x CPU throttle vs 17ms with nothing blurring). The veils are the
minimum the pixel-contrast scan needs to hold 4.5:1 for the small muted
text — anything more and the glass reads as a white panel. */
.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 .chat-header,
.aero .chat-empty,
.aero .chat-composer-band,
.aero .segmented,
.aero .hint-line,
/* 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-[var(--surface-sunken)]"],
.aero [class*="bg-frost-50"] {
background-image:
linear-gradient(
180deg,
rgb(255 255 255 / 0.15) 0%,
rgb(255 255 255 / 0.06) 46%,
rgb(255 255 255 / 0.05) 53%,
rgb(255 255 255 / 0.09) 100%
),
linear-gradient(rgb(255 255 255 / 0.16), rgb(255 255 255 / 0.16)),
url("/aero/background-blur.webp?v=5");
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 chat header and composer are floating glass (a live backdrop blur),
but the bake above is opaque: the blur under it never shows, yet it still
re-ran on every frame. The touch fallback below drops the bake, so it
brings the blur back there. */
.aero .chat-header,
.aero .chat-composer-band {
@apply backdrop-filter-none;
}
/* The wizard's phone action bar: Aero's page token is the saturated water
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). */
.aero [data-wizard-bar] {
background: rgb(255 255 255 / 0.2);
border-top-color: rgb(255 255 255 / 0.6);
box-shadow: 0 -14px 34px -22px rgb(2 62 110 / 0.5);
}
/* Interactive glass feedback: a soft inner glow and a brighter rim on hover,
an inner shade while pressed. Deliberately no transform and no filter — on
a pane with a viewport-fixed backdrop either one re-anchors the glass and
it flashes a wrong crop of the wallpaper (the old hover lift did exactly
that to the deck cards). */
.aero .nav-action,
.aero .nav-row,
.aero .glass-chip,
.aero .glass-pill,
.aero .btn-secondary,
.aero .btn-primary,
.aero .segmented > button,
.aero .segmented > a,
.aero .card-hover {
transition: box-shadow 170ms ease, border-color 170ms ease, background-color 170ms ease, color 170ms ease;
}
@media (hover: hover) and (pointer: fine) {
.aero .nav-action:hover,
.aero .nav-row:hover,
.aero .glass-chip:hover,
.aero .glass-pill:hover,
.aero .btn-secondary:hover,
.aero .btn-primary:hover,
.aero .segmented > button:hover,
.aero .segmented > a:hover,
.aero .card-hover:hover {
border-color: rgb(255 255 255 / 1);
background-color: rgb(255 255 255 / 0.34);
filter: none;
box-shadow:
0 18px 38px -20px rgb(2 62 110 / 0.6),
inset 0 1px 0 rgb(255 255 255 / 1),
inset 0 0 30px rgb(255 255 255 / 0.55);
}
.aero .card-hover:hover {
transform: none;
}
}
.aero .card-hover:active,
.aero .card-hover:hover,
.aero .press:active,
.aero .btn-primary:active,
.aero .segmented > button:active,
.aero .segmented > a:active,
.aero .nav-action:active,
.aero .glass-pill:active,
.aero .btn-secondary:active {
transform: none;
filter: none;
}
.aero .card-hover:active,
.aero .press:active,
.aero .btn-primary:active,
.aero .nav-action:active,
.aero .glass-pill:active,
.aero .btn-secondary:active {
box-shadow: inset 0 2px 8px rgb(2 62 110 / 0.2), inset 0 1px 0 rgb(255 255 255 / 0.8);
}
/* The conversation scrolls right up to the glass header; a hard-clipped line
of text at that edge reads as a rendering bug over the busy wallpaper, so
the top of the scroll area fades out instead (the mask is fixed to the
element, not the content — and stays inside the column's 16px top padding,
so at the very top nothing is dimmed). */
.aero .chat-scroll {
mask-image: linear-gradient(to bottom, transparent 0, black 14px);
-webkit-mask-image: linear-gradient(to bottom, transparent 0, black 14px);
}
/* 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: the shared blurred wallpaper under a veil thick
enough for reading, still translucent enough to read as glass. */
.aero .chat-bubble,
.aero .chat-answer {
border-color: rgb(255 255 255 / 0.85);
background-image:
linear-gradient(rgb(255 255 255 / 0.3), rgb(255 255 255 / 0.3)),
url("/aero/background-blur.webp?v=5");
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.85);
border-radius: var(--radius-panel);
border-bottom-left-radius: var(--radius-chip);
padding: 0.7rem 0.95rem;
}
/* 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(
180deg,
rgb(255 255 255 / 0.22) 0%,
rgb(255 255 255 / 0.1) 46%,
rgb(255 255 255 / 0.08) 53%,
rgb(255 255 255 / 0.13) 100%
),
url("/aero/background-blur.webp?v=5");
background-size: 100% 100%, cover;
background-position: 0 0, center 42%;
background-attachment: scroll, fixed;
background-repeat: no-repeat, no-repeat;
}
/* The chat welcome (title, subtitle, suggestion chips): a card like the
library's, stretched across the conversation column so it lines up with the
floating header above and composer below it. */
.aero .chat-empty {
margin-inline: -1rem;
padding: 1.35rem 1rem 1.1rem;
border-radius: var(--radius-panel);
border: 1px solid var(--glass-border);
background-color: var(--glass-bg);
box-shadow: var(--glass-shadow), inset 0 1px 0 var(--glass-highlight);
}
@media (min-width: 1024px) {
.aero .chat-empty {
margin-inline: -2.5rem;
padding: 1.5rem 2.5rem 1.25rem;
}
}
/* The column floats inside the main pane with the shared gutter; on desktop
the rail already carries its own 12px right-hand gutter, so the column
drops its left padding — every gap between the panels is then the same
--layout-gutter. (Unlayered on purpose: it must beat the Tailwind
`p-[var(--layout-gutter)]` utility.) */
@media (min-width: 1024px) {
.chat-column { padding-left: 0; }
}
/* The composer's field: the app's frosted tile plus one light veil, so it
reads as a brighter, even glass field inside the panel. Both extremes were
wrong: the field's own wallpaper gradient (a blue→green wash) and a flat
white fill (a bar on a translucent panel). The heavy blur in the shared
backdrop keeps this one clean and even. */
.aero .chat-composer-band textarea,
.aero .chat-composer-band input[type="search"] {
background-image:
linear-gradient(rgb(255 255 255 / 0.3), rgb(255 255 255 / 0.3)),
linear-gradient(
180deg,
rgb(255 255 255 / 0.15) 0%,
rgb(255 255 255 / 0.06) 46%,
rgb(255 255 255 / 0.05) 53%,
rgb(255 255 255 / 0.09) 100%
),
linear-gradient(rgb(255 255 255 / 0.16), rgb(255 255 255 / 0.16)),
url("/aero/background-blur.webp?v=5");
background-size: 100% 100%, 100% 100%, 100% 100%, cover;
background-position: 0 0, 0 0, 0 0, center 42%;
background-attachment: scroll, scroll, scroll, fixed;
background-repeat: no-repeat, no-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 .chat-composer-band,
.aero .segmented,
.aero .hint-line {
background-image: none;
background-color: rgb(255 255 255 / 0.32);
}
.aero .chat-header,
.aero .chat-composer-band {
@apply backdrop-blur-md;
}
/* 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=5");
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=5");
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);
}
/* Inside the real-blur chrome (study pager), the ghost buttons drop the bake
too — same anchoring bug as every other nested tile, and the pill's own
blur is the glass they should match. */
.aero .glass-panel .btn-ghost,
.aero .glass .btn-ghost,
.aero .study-chrome .btn-ghost,
.aero .tabbar .btn-ghost {
background-image: none;
border-color: var(--glass-border);
background-color: rgb(255 255 255 / 0.23);
}
/* Page headings that sit straight on the wallpaper. */
.aero .page-heading {
display: inline-block;
padding: 0.45rem 0.85rem;
border-radius: 1.25rem;
border: 1px solid rgb(255 255 255 / 0.45);
background-color: rgb(255 255 255 / 0.32);
}
/* The rail used to weight its lower half (the grass read darker through the
glass) with a gradient that left a visible blue → green smear. It now uses
the same even veil as the chat chrome — see the shared recipe above — so
every row keeps the same contrast from top to bottom. */
/* 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,
.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-[var(--surface-sunken)]"],
.aero .glass-sheet [class*="bg-frost-50"] {
background-image: none;
}
/* The same principle extends to the floating chrome that carries a real
backdrop blur — the chat header, the composer band, the desktop pickers,
the study pill and the phone tab bar (the `.glass` header bar too).
Chrome cannot anchor a `background-attachment: fixed` layer inside a
backdrop-filter ancestor: the bake is laid out against the element itself,
so a small tile samples a tiny random detail of the wallpaper and reads as
a solid, mismatched button — the composer's attach button rendered as a
dark patch of grass next to the pale glass field. The panel's own blur is
the glass here, exactly like inside sheets, so nested chrome drops the
bake and keeps its flat token fill. The composer's field is deliberately
exempt (`:not()` on the bake selectors): its brighter glass tile is its
own thin recipe, and it is the wide element that still anchors correctly. */
.aero .glass-panel .card,
.aero .glass-panel .glass-solid,
.aero .glass-panel .sidebar-surface,
.aero .glass-panel .gate-card,
.aero .glass-panel .list-group,
.aero .glass-panel .nav-action,
.aero .glass-panel .nav-row[aria-current="page"],
.aero .glass-panel .nav-row[aria-pressed="true"],
.aero .glass-panel .nav-row[data-active="true"],
.aero .glass-panel .glass-chip,
.aero .glass-panel .glass-pill,
.aero .glass-panel .btn-secondary,
.aero .glass-panel .chat-empty,
.aero .glass-panel .segmented,
.aero .glass-panel [class*="bg-white/35"],
.aero .glass-panel [class*="bg-white/40"],
.aero .glass-panel [class*="bg-white/45"],
.aero .glass-panel [class*="bg-white/50"],
.aero .glass-panel [class*="bg-white/55"],
.aero .glass-panel [class*="bg-white/60"],
.aero .glass-panel [class*="bg-white/70"],
.aero .glass-panel [class*="bg-[var(--surface)]"],
.aero .glass-panel [class*="bg-[var(--surface-strong)]"]:not(textarea):not(input[type="search"]),
.aero .glass-panel [class*="bg-[var(--surface-sunken)]"],
.aero .glass-panel [class*="bg-frost-50"] {
background-image: none;
}
.aero .study-chrome [class*="bg-white/35"],
.aero .study-chrome [class*="bg-white/40"],
.aero .study-chrome [class*="bg-white/45"],
.aero .study-chrome [class*="bg-white/50"],
.aero .study-chrome [class*="bg-white/55"],
.aero .study-chrome [class*="bg-white/60"],
.aero .study-chrome [class*="bg-white/70"],
.aero .study-chrome [class*="bg-[var(--surface)]"],
.aero .study-chrome [class*="bg-[var(--surface-strong)]"],
.aero .study-chrome [class*="bg-[var(--surface-sunken)]"],
.aero .study-chrome [class*="bg-frost-50"] {
background-image: none;
}
html,
body {
height: 100%;
overscroll-behavior: none;
}
body {
font-family: var(--font-sans);
-webkit-tap-highlight-color: transparent;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
/* Matches the backdrop so iOS rubber-banding never shows a seam. */
background-color: var(--page);
color: var(--text);
overscroll-behavior-y: none;
}
.dark body {
color: var(--color-frost-100);
}
/* The page backdrop now lives in ONE viewport-fixed layer (see .app-backdrop)
so it can never stretch, scroll or be clipped by a content column.
`.frost-bg` stays as a transparent wrapper for pages that used it. */
.frost-bg {
background: transparent;
}
/* A full-screen overlay (study feed, review/exam session, homework guide) is
transparent on purpose so the fixed ice scene stays visible — which also
means the app chrome of the page behind it would ghost through the glass.
While one is open, that chrome is hidden instead. */
body:has(.overlay-open) :is(.app-header, .segmented, .tabbar, .cookie-notice) {
visibility: hidden;
}
/* Frutiger Aero's soundtrack player: a fixed glass circle (expanded, the
credit bar) in the bottom-right corner. It clears the phone tab bar, the
chat composer and the wizard action bar, and it steps aside with the rest
of the chrome while an immersive overlay (study feed, review, exam) is
open — the music keeps playing, the control comes back when you do. */
.aero-music {
position: fixed;
right: max(0.75rem, env(safe-area-inset-right, 0px));
bottom: calc(0.75rem + env(safe-area-inset-bottom, 0px));
z-index: 40;
}
@media (max-width: 639px) {
.aero-music {
/* Above the phone tab bar (5.25rem + safe area), plus the gutter. */
bottom: calc(6rem + env(safe-area-inset-bottom, 0px));
}
}
/* The chat composer owns the bottom of that screen (tab bar and all). */
.aero-music[data-raised="chat"] {
bottom: calc(11.5rem + env(safe-area-inset-bottom, 0px));
}
/* The wizard's action bar on phones. */
.aero-music[data-raised="wizard"] {
bottom: calc(5.5rem + env(safe-area-inset-bottom, 0px));
}
/* The podcast player is a full-screen overlay (z-45): the music control has
to ride above it, and it moves to the header's corner — the transport and
the voice panel own the bottom of that screen. */
.aero-music[data-raised="player"] {
top: calc(max(env(safe-area-inset-top), 0.75rem) + 3.9rem);
right: max(0.75rem, calc((100vw - 48rem) / 2 + 0.75rem));
bottom: auto;
z-index: 46;
}
body:has(.overlay-open) .aero-music,
/* The one-time notice shares this corner; it wins while it is up. */
body:has(.cookie-notice) .aero-music {
visibility: hidden;
}
/* The key gate rides on the shared ice scene (see .ice-scene). */
/* The fixed ice scene: navy/pale base, soft glacier blobs and glossy ice
orbs. Purely decorative. Only the orbs (and Aero's bubbles) move; the base
and the blobs are painted once. */
.ice-scene {
position: fixed;
inset: 0;
z-index: -1;
overflow: hidden;
pointer-events: none;
background: linear-gradient(180deg, var(--ice-base-top) 0%, var(--ice-base-bottom) 100%);
}
.ice-blob {
position: absolute;
border-radius: 50%;
opacity: var(--ice-blob-opacity);
/* No filter: blur() here on purpose. A radial gradient fading to
transparent is already a soft blob, and blurring a huge layer forces a
re-rasterisation (measured 150ms vs 33ms per frame in a software
renderer). The falloff stops below keep the same softness.
No drift either: a few vw over 44-60s never showed at this opacity, but
four viewport-sized moving layers turned every frame into a full-screen
redraw. */
}
.ice-blob-a {
width: 52vw; height: 52vw; min-width: 24rem; min-height: 24rem;
top: -14vh; left: -10vw;
background: radial-gradient(circle, rgb(56 189 248 / 0.9) 0%, rgb(56 189 248 / 0.45) 38%, transparent 72%);
}
.ice-blob-b {
width: 46vw; height: 46vw; min-width: 22rem; min-height: 22rem;
top: 4vh; right: -12vw;
background: radial-gradient(circle, rgb(96 165 250 / 0.9) 0%, rgb(96 165 250 / 0.45) 38%, transparent 72%);
}
.ice-blob-c {
width: 48vw; height: 48vw; min-width: 22rem; min-height: 22rem;
bottom: -16vh; left: 14vw;
background: radial-gradient(circle, rgb(129 140 248 / 0.9) 0%, rgb(129 140 248 / 0.42) 38%, transparent 74%);
}
.ice-blob-d {
width: 34vw; height: 34vw; min-width: 16rem; min-height: 16rem;
bottom: 2vh; right: 6vw;
background: radial-gradient(circle, rgb(56 189 248 / 0.85) 0%, rgb(56 189 248 / 0.4) 38%, transparent 74%);
}
/* Glossy ice orbs: slow bubbles that rise through the open band (see
.ice-floats), swaying gently and fading in/out at its edges so the loop
never pops. Only transform + opacity animate, each orb on its own
composited layer (will-change: transform): no filter/backdrop-filter, and no
animated size, offsets or shadow. Per-orb duration, delay (negative, so the
scene is already populated on load), horizontal position and sway keep them
out of sync. */
.ice-orb {
position: absolute;
bottom: 0;
border-radius: 50%;
opacity: var(--ice-orb-opacity);
will-change: transform;
animation: ice-rise var(--ice-orb-duration, 48s) ease-in-out infinite;
animation-delay: var(--ice-orb-delay, 0s);
background: radial-gradient(
circle at 30% 25%,
rgb(255 255 255 / 0.95) 0%,
rgb(191 227 255 / 0.55) 18%,
rgb(96 165 250 / 0.35) 48%,
rgb(37 99 235 / 0.45) 100%
);
box-shadow: 0 18px 40px -18px rgb(30 64 175 / 0.55), inset 0 -8px 18px -10px rgb(30 64 175 / 0.6);
}
.ice-orb-1 { width: 190px; height: 190px; left: 12%; --ice-orb-duration: 46s; --ice-orb-delay: -18s; --ice-sway: 4vw; }
.ice-orb-2 { width: 84px; height: 84px; left: 4%; --ice-orb-duration: 58s; --ice-orb-delay: -41s; --ice-sway: -3vw; }
.ice-orb-3 { width: 220px; height: 220px; right: 10%; --ice-orb-duration: 36s; --ice-orb-delay: -7s; --ice-sway: 5vw; }
.ice-orb-4 { width: 52px; height: 52px; right: 28%; --ice-orb-duration: 64s; --ice-orb-delay: -52s; --ice-sway: -4vw; }
.ice-orb-5 { width: 120px; height: 120px; left: 38%; --ice-orb-duration: 52s; --ice-orb-delay: -27s; --ice-sway: 3vw; }
/* The rise: --ice-rise lifts an orb from the bottom of the band until its
top edge meets the band's top (100% = the orb's own height; the extra 3%
at the end takes back what the 1.06 scale adds above it); the sway crosses
the path twice and the scale drifts slightly. Opacity reaches the token
value by 18% and holds it until 90%, so a visible orb never touches the
band's edges. */
@keyframes ice-rise {
0% { transform: translate3d(0, 0, 0) scale(0.94); opacity: 0; }
18% { opacity: var(--ice-orb-opacity); }
32% { transform: translate3d(var(--ice-sway, 0px), calc(var(--ice-rise) * 0.32), 0) scale(0.97); }
58% { transform: translate3d(calc(var(--ice-sway, 0px) * -0.55), calc(var(--ice-rise) * 0.58), 0) scale(1.02); }
80% { transform: translate3d(calc(var(--ice-sway, 0px) * 0.4), calc(var(--ice-rise) * 0.8), 0) scale(1.04); }
90% { opacity: var(--ice-orb-opacity); }
100% { transform: translate3d(0, calc(var(--ice-rise) + 3%), 0) scale(1.06); opacity: 0; }
}
/* The open band the orbs and bubbles rise through. The floating glass
panels (the rail, the chat header/composer, the tab bar) sit on top of the
scene, and a bubble reaching one of their edges looked sliced by a straight
line — so the floats stay clear of them: below the top chrome, above the
bottom chrome (the values come from where the header and the composer
actually end) and, on desktop, right of the rail strip. Every float starts
and ends inside the band at zero opacity, so this clip never cuts a visible
one. It is a plain rectangular clip on purpose: the gradient mask it
replaces re-rendered the whole animated layer offscreen on every frame
(Gecko on a 144Hz screen: ~114% CPU idle on the library, ~39% without the
mask). Static under reduced motion: nothing moves there, and the parked
orbs keep their full-screen spots. */
.ice-floats {
position: absolute;
inset: 68px 0 172px;
overflow: hidden;
--ice-rise: calc(100% - 100vh + 240px);
}
@media (min-width: 640px) {
.ice-floats {
bottom: 151px;
--ice-rise: calc(100% - 100vh + 219px);
}
}
@media (min-width: 1024px) {
/* …and the rail's strip plus its gutter (268px, see Sidebar). */
.ice-floats { left: 268px; }
}
@media (prefers-reduced-motion: reduce) {
.ice-floats { inset: 0; }
/* Bubbles sit still (and visible) instead of rising. */
.aero .ice-bubble { animation: none; opacity: 0.55; transform: translate3d(0, -46vh, 0); }
/* Orbs go back to the pre-rise static layout (a paused animation is not
applied at all in Chrome, which would leave them parked at the bottom of
the band) and give their composited layers back. */
.ice-orb {
animation: none;
will-change: auto;
}
.ice-orb-1 { top: 14%; left: 12%; }
.ice-orb-2 { top: 58%; left: 4%; }
.ice-orb-3 { top: auto; bottom: -70px; right: 10%; }
.ice-orb-4 { top: 24%; right: 28%; }
.ice-orb-5 { top: auto; bottom: 16%; left: 38%; }
}
/* Park every scene animation while nobody is watching it, so the page costs
no compositor work: a background tab or a window without focus (IceBackdrop
sets .ice-paused), and any dialog or sheet over the page — its scrim
carries .scene-pause, and the blur of the scrim and the sheet would
otherwise re-run on every frame of the scene moving behind them. */
.ice-paused :is(.ice-orb, .ice-bubble),
body:has(.scene-pause) .ice-scene :is(.ice-orb, .ice-bubble) {
animation-play-state: paused;
}
/* Phones and tablets: fewer blobs and orbs, so the scene stays calm and
cheap. Unlayered on purpose — layered rules would lose to the .ice-blob
declarations above. */
@media (max-width: 1023px) {
.ice-blob-c,
.ice-blob-d,
.ice-orb-4,
.ice-orb-5 {
display: none;
}
.ice-orb-1 { width: 120px; height: 120px; --ice-orb-duration: 54s; --ice-orb-delay: -21s; }
.ice-orb-2 { --ice-orb-duration: 64s; --ice-orb-delay: -46s; }
.ice-orb-3 { width: 150px; height: 150px; --ice-orb-duration: 44s; --ice-orb-delay: -8s; }
}
/* Phones: the decoration is already *behind* the content (the scene is a
fixed, pointer-events:none layer at z-index -1), but it shines through the
translucent page and glass, so it reads as overlap. Thin it right down,
tuck the orbs to the edges, away from the text column and the tab bar, and
slow their rise down further so the drifting slivers stay calm. */
@media (max-width: 639px) {
:root {
--ice-blob-opacity: 0.025;
--ice-orb-opacity: 0.14;
}
.dark {
--ice-blob-opacity: 0.05;
--ice-orb-opacity: 0.2;
}
/* Phones: orbs hug the screen edges so they can never sit under the
content column's cards or primary buttons. The lower-right orb still
read as a glow behind the wizard action bar, so phones drop it. */
.ice-orb-1 { width: 96px; height: 96px; left: -48px; --ice-orb-duration: 62s; --ice-orb-delay: -30s; --ice-sway: 5vw; }
.ice-orb-2 { width: 72px; height: 72px; left: -44px; --ice-orb-duration: 68s; --ice-orb-delay: -49s; --ice-sway: -4vw; }
.ice-orb-3 { display: none; }
}
/* The 404 mascot: the scene's glossy ice orb, at full strength so it reads on
the card glass in every theme (the scene's own orbs run at 0.22–0.6). */
.not-found-orb {
border-radius: 50%;
background: radial-gradient(
circle at 32% 26%,
rgb(255 255 255 / 0.98) 0%,
rgb(219 242 255 / 0.92) 26%,
rgb(125 200 245 / 0.95) 58%,
rgb(38 121 208 / 1) 100%
);
box-shadow:
inset -10px -14px 24px -12px rgb(3 74 124 / 0.6),
inset 8px 12px 20px -10px rgb(255 255 255 / 0.95),
inset 0 0 0 1px rgb(255 255 255 / 0.5),
0 18px 36px -18px rgb(2 62 110 / 0.6);
}
@layer components {
/* Frosted ice glass ------------------------------------------------------
Two recipes now:
- **Floating glass** (sheets, dropdowns, dialogs and the sticky chrome):
the tint below plus a backdrop blur — the blur is what reads as glass
when content scrolls underneath (the app header, the study chrome, the
tab bar, a sheet over the page).
- **Static glass** (cards, the rail, the gate, the study prompt card, and
everything that sits in the page flow: answer tiles, chips, inputs,
dropzones, grouped lists, segmented controls): the exact same tint,
rim, highlight and shadow, but **no** backdrop-filter. On a smooth
gradient the blur is invisible — and because the ice scene animates,
every blurred surface re-runs the filter on every frame. Measured on
desktop (2x throttle, software renderer): p50 frame time 66ms while the
static surfaces blurred the scene, 33ms without them, 17ms with the
scene's animation also stopped; on a 144Hz MacBook screen the study
feed's answer tiles alone cost Chromium ~20% CPU.
Nested glass (chips, inputs) never adds a second blur layer. Tailwind's
backdrop utilities emit the -webkit- prefix too (hand-written
backdrop-filter gets minified to -webkit- only, which paints nothing). */
.glass-panel,
.card,
.sidebar-surface,
.study-chrome,
.tabbar,
.glass,
.gate-card {
background-color: var(--glass-bg);
box-shadow: var(--glass-shadow), inset 0 1px 0 var(--glass-highlight);
}
/* Only the layers that can have content behind them blur. */
.glass-panel,
.study-chrome,
.tabbar,
.glass {
@apply backdrop-blur-lg backdrop-saturate-[1.4];
}
.glass-panel {
border: 1px solid var(--glass-border);
border-radius: var(--radius-panel);
}
/* Nested glass: slightly more opaque than its parent, no extra blur. */
.glass-chip {
background: var(--surface-sunken);
border: 1px solid var(--glass-border);
}
/* More opaque glass for the study question card, where text must stay crisp.
background-color (not the shorthand) so the Aero gloss can layer on top. */
.glass-solid {
background-color: var(--glass-solid-bg);
}
/* Sheets and dialogs sit over a scrim (and, on phones, over the page), so
their text needs a more opaque surface than the cards have. */
.glass-sheet {
background-color: var(--glass-sheet-bg);
}
.glass-chip:hover {
background: var(--hover);
}
.glass {
border-bottom: 1px solid var(--glass-border);
}
/* Study-feed chrome (progress header + bottom pager): the same neutral
surface as the cards, with a neutral border and shadow. */
.study-chrome {
border: 1px solid var(--glass-border);
}
.card {
border-radius: var(--radius-panel); /* 24px */
border: 1px solid var(--glass-border);
}
.gate-card {
border: 1px solid var(--glass-border);
border-radius: var(--radius-panel);
}
/* Blur is expensive: halve it on phones (floating glass only). */
@media (max-width: 1023px) {
.glass-panel,
.study-chrome,
.tabbar,
.glass {
@apply backdrop-blur-md;
}
}
/* No backdrop-filter support: fall back to a more opaque surface (floating
glass only — the static surfaces already look right without a filter). */
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
.glass-panel,
.study-chrome,
.tabbar,
.glass {
background-color: var(--glass-fallback);
}
}
/* Opt-in hover lift for interactive cards (deck cards). Pointer devices only,
so touch screens never get sticky hover states. */
.card-hover {
transition: background-color 180ms ease, box-shadow 180ms ease, border-color 180ms ease, transform 180ms ease;
}
@media (hover: hover) and (pointer: fine) {
.card-hover:hover {
background: var(--card-hover-bg);
border-color: var(--nav-active-border);
box-shadow: var(--shadow-card-hover);
transform: translateY(-2px);
}
}
.card-hover:active {
transform: translateY(0);
}
/* Inset grouped list ---------------------------------------------------- */
.list-group {
border-radius: var(--radius-control);
background: color-mix(in oklab, var(--surface) 82%, transparent);
border: 1px solid var(--hairline);
overflow: hidden;
}
.list-group > * + * {
border-top: 1px solid var(--hairline-soft);
}
/* Shared chip ------------------------------------------------------------
One control language for the chat's suggestion chips, the composer's
context/model chips and the small chips inside a message: a light glass
fill, a visible rim, full-contrast text and a real tap target (44px on
phones, like every other control). */
.chip {
display: inline-flex;
align-items: center;
gap: 0.4rem;
min-height: 2.5rem;
border-radius: var(--radius-chip);
border: 1px solid var(--glass-border);
background: var(--surface-strong);
color: var(--text);
font-weight: 600;
box-shadow: inset 0 1px 0 var(--glass-highlight);
transition: background-color 160ms ease, border-color 160ms ease, color 160ms ease;
}
.chip:hover {
background: var(--hover);
border-color: var(--nav-active-border);
}
@media (max-width: 639px) {
.chip { min-height: 44px; }
}
/* Podcast transcript: the word under the voice rides a highlight, the way
lyrics do in Spotify. Words are plain spans; the player flips one class. */
.podcast-word {
border-radius: 0.4rem;
transition: background-color 140ms ease, color 140ms ease;
cursor: pointer;
}
.podcast-word-active,
.podcast-word:hover {
background: var(--nav-active-bg);
color: var(--text);
box-shadow: 0 0 0 2px var(--nav-active-bg);
}
.podcast-word-active {
font-weight: 600;
}
/* The AI note under a surface that shows model output -------------------- */
.ai-disclaimer {
font-size: 12px;
line-height: 1.4;
color: var(--text-muted);
text-align: center;
}
/* iOS segmented control ------------------------------------------------- */
.segmented {
display: flex;
gap: 2px;
padding: 3px;
border-radius: var(--radius-control);
background: var(--surface-sunken);
border: 1px solid var(--hairline);
}
.segmented > button {
flex: 1;
border-radius: var(--radius-chip);
padding: 0.42rem 0.5rem;
font-size: 0.875rem;
font-weight: 600;
color: var(--text);
transition: background-color 0.18s ease, color 0.18s ease, box-shadow 0.18s ease;
}
.dark .segmented > button {
color: var(--color-frost-300);
}
.segmented > button[aria-pressed="true"],
.segmented > button[aria-checked="true"] {
background-image: linear-gradient(135deg, #38bdf8 0%, #60a5fa 100%);
color: #06111f;
box-shadow: 0 6px 16px -8px rgb(56 189 248 / 0.7);
}
.dark .segmented > button[aria-pressed="true"],
.dark .segmented > button[aria-checked="true"] {
background-image: linear-gradient(135deg, #38bdf8 0%, #60a5fa 100%);
color: #06111f;
}
/* Link segments (library tabs): same control, but links navigate. */
.segmented > a {
flex: 1;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.375rem;
min-height: 2.75rem;
border-radius: var(--radius-chip);
padding: 0.42rem 0.5rem;
font-size: 0.875rem;
font-weight: 600;
color: var(--text);
transition: background-color 0.18s ease, color 0.18s ease, box-shadow 0.18s ease;
}
.dark .segmented > a {
color: var(--color-frost-300);
}
.segmented > a[aria-current="page"] {
background-image: linear-gradient(135deg, #38bdf8 0%, #60a5fa 100%);
color: #06111f;
box-shadow: 0 6px 16px -8px rgb(56 189 248 / 0.7);
}
/* Ice gradient for progress bars and selected states. */
.grad-ice {
background-image: linear-gradient(90deg, #38bdf8 0%, #60a5fa 100%);
}
/* Sheet grabber --------------------------------------------------------- */
.sheet-grabber {
width: 36px;
height: 5px;
border-radius: 999px;
background: var(--hairline-soft);
margin: 0 auto 0.75rem;
}
.tabbar {
border-top: 1px solid var(--glass-border);
}
/* Phones: the tab bar sits over the bottom of the ice scene, so give the
labels a calmer surface than the barely-there shared glass tint. */
@media (max-width: 639px) {
.tabbar {
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) -- */
.tint-sky { background: linear-gradient(140deg, rgb(156 205 245 / 0.34), rgb(156 205 245 / 0.14)); color: #1c4e72 }
.tint-mint { background: linear-gradient(140deg, rgb(159 227 208 / 0.4), rgb(159 227 208 / 0.16)); color: #1d5c4c }
.tint-lilac { background: linear-gradient(140deg, rgb(195 182 240 / 0.4), rgb(195 182 240 / 0.16)); color: #47397f }
.tint-peach { background: linear-gradient(140deg, rgb(246 205 180 / 0.45), rgb(246 205 180 / 0.18)); color: #7d4522 }
.tint-rose { background: linear-gradient(140deg, rgb(246 178 190 / 0.4), rgb(246 178 190 / 0.16)); color: #7c2b3d }
.dark .tint-sky { background: linear-gradient(140deg, rgb(74 164 220 / 0.26), rgb(74 164 220 / 0.1)); color: #bfe2fb }
.dark .tint-mint { background: linear-gradient(140deg, rgb(88 201 173 / 0.26), rgb(88 201 173 / 0.1)); color: #bdf0e2 }
.dark .tint-lilac { background: linear-gradient(140deg, rgb(160 140 235 / 0.26), rgb(160 140 235 / 0.1)); color: #ddd5fb }
.dark .tint-peach { background: linear-gradient(140deg, rgb(230 160 118 / 0.26), rgb(230 160 118 / 0.1)); color: #fadfc9 }
.dark .tint-rose { background: linear-gradient(140deg, rgb(230 130 150 / 0.26), rgb(230 130 150 / 0.1)); color: #fbd5dd }
/* Sidebar surface: frosted navy glass with a real edge. */
/* Sidebar surface: one tall inset glass panel. */
.sidebar-surface {
border: 1px solid var(--glass-border);
border-radius: var(--radius-panel);
}
/* Sidebar nav rows */
.nav-row {
display: flex;
align-items: center;
gap: 0.625rem;
width: 100%;
border-radius: var(--radius-chip);
padding: 0.5rem 0.625rem;
font-size: 13.5px;
font-weight: 500;
color: var(--text-muted);
transition: background-color 140ms ease, color 140ms ease;
}
.nav-row:hover {
background: var(--hover);
color: var(--text);
}
/* The rail's two creation actions ("New deck", "New homework") are buttons,
not nav rows: once the library is full the empty-state CTAs are long gone,
and on desktop the rail is the only place left to start something. */
.nav-action {
display: flex;
align-items: center;
gap: 0.625rem;
width: 100%;
border-radius: var(--radius-chip);
padding: 0.5rem 0.625rem;
font-size: 13.5px;
font-weight: 600;
color: var(--text);
background: var(--surface-strong);
border: 1px solid var(--glass-border);
transition: background-color 160ms ease, border-color 160ms ease;
}
.nav-action:hover {
background: var(--hover);
border-color: var(--nav-active-border);
}
.nav-row[aria-current="page"],
.nav-row[aria-pressed="true"] {
background: var(--nav-active-bg);
color: var(--text);
font-weight: 600;
box-shadow: inset 0 0 0 1.5px var(--nav-active-border), inset 0 1px 0 var(--glass-highlight);
}
/* …and the icon carries the accent, so the selected row can never be mistaken
for one more tinted action button ("New deck", "New homework"). */
.nav-row[aria-current="page"] svg,
.nav-row[aria-pressed="true"] svg {
color: var(--focus);
opacity: 1;
}
/* Shared CTA language: one ice gradient in *both* themes (it mirrors the
segmented control, the progress bars and the tab-bar plus). #06111f on
#38bdf8 is ~8.8:1, so the dark label clears AA everywhere on the ramp. */
.btn-primary {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
border-radius: var(--radius-control);
background-image: linear-gradient(135deg, #38bdf8 0%, #60a5fa 100%);
color: #06111f;
font-weight: 600;
box-shadow: 0 10px 24px -14px rgb(14 165 233 / 0.75);
transition: box-shadow 180ms ease, filter 180ms ease, transform 160ms ease;
}
.btn-primary:hover {
filter: brightness(1.06);
box-shadow: 0 14px 32px -14px rgb(56 189 248 / 0.9);
}
.btn-primary:active { transform: scale(0.985); }
/* Disabled: a real, visible chip instead of a 40%-opacity ghost. Important
declarations deliberately live in this (earlier) layer so they beat the
important rose/emerald overrides of the danger/success variants. */
.btn-disabled {
background-image: none !important;
background-color: var(--surface-sunken) !important;
color: var(--text-subtle) !important;
box-shadow: none !important;
filter: none !important;
transform: none !important;
cursor: not-allowed;
}
.btn-secondary {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
border-radius: var(--radius-control);
background: var(--surface-strong);
color: var(--text);
border: 1px solid var(--glass-border);
font-weight: 500;
transition: background-color 160ms ease, border-color 160ms ease, transform 160ms ease;
}
.btn-secondary:hover {
background: var(--hover);
border-color: var(--nav-active-border);
}
.btn-secondary:active { transform: scale(0.985); }
.btn-ghost {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
border-radius: var(--radius-control);
background: transparent;
color: var(--text-muted);
font-weight: 500;
transition: background-color 160ms ease, color 160ms ease;
}
.btn-ghost:hover { background: var(--hover); color: var(--text); }
/* Difficulty: neutral pill, colour lives in the text only. */
.diff-easy { color: #0f5c3f; }
.diff-medium { color: #694800; }
.diff-hard { color: #a3261f; }
.dark .diff-easy { color: #6fd3a8; }
.dark .diff-medium { color: #e3b341; }
.dark .diff-hard { color: #f08a84; }
/* Loading skeleton */
.skeleton {
position: relative;
overflow: hidden;
background: var(--surface-sunken);
border-radius: 0.75rem;
}
.skeleton::after {
content: "";
position: absolute;
inset: 0;
transform: translateX(-100%);
background: linear-gradient(90deg, transparent, rgb(255 255 255 / 0.45), transparent);
animation: shimmer 1.6s infinite;
}
.dark .skeleton::after {
background: linear-gradient(90deg, transparent, rgb(255 255 255 / 0.06), transparent);
}
@keyframes shimmer {
100% { transform: translateX(100%); }
}
.text-balance {
text-wrap: balance;
}
/* Focus rings ------------------------------------------------------------ */
:where(button, a, input, textarea, [tabindex]):focus-visible {
outline: 2.5px solid var(--color-accent-500);
outline-offset: 2px;
border-radius: 0.75rem;
}
/* Scrollbars ------------------------------------------------------------- */
.thin-scroll {
scrollbar-width: thin;
scrollbar-color: color-mix(in oklab, var(--color-frost-400) 55%, transparent) transparent;
}
.thin-scroll::-webkit-scrollbar { width: 8px; height: 8px }
.thin-scroll::-webkit-scrollbar-thumb {
background: color-mix(in oklab, var(--color-frost-400) 45%, transparent);
border-radius: 999px;
}
.no-scrollbar::-webkit-scrollbar { display: none }
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none }
/* Mobile safe areas ------------------------------------------------------ */
.pt-safe { padding-top: max(env(safe-area-inset-top), 0.75rem) }
.pb-safe { padding-bottom: max(env(safe-area-inset-bottom), 0.75rem) }
.pb-tabbar { padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 5.25rem) }
/* ---------------------------------------------------------------------------
Mobile animation foundation (≤639px / coarse pointers).
Press feedback is CSS so every button, chip, card, tab and row gets the
same instant response without wiring each component. The springy release is
a spring-like cubic-bezier on transform only; reduced motion drops it.
--------------------------------------------------------------------------- */
@media (max-width: 639px), (pointer: coarse) {
.press,
.btn-primary,
.btn-secondary,
.btn-ghost,
.card-hover,
.nav-row,
.folder-pill,
.segmented > button,
.segmented > a,
.tabbar a,
.tabbar button,
summary {
transition:
transform 0.22s cubic-bezier(0.34, 1.56, 0.64, 1),
background-color 0.16s ease,
color 0.16s ease,
border-color 0.16s ease,
box-shadow 0.16s ease;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
}
.press:active,
.btn-primary:active,
.btn-secondary:active,
.btn-ghost:active,
.card-hover:active,
.nav-row:active,
.folder-pill:active,
.segmented > button:active,
.segmented > a:active,
.tabbar a:active,
.tabbar button:active {
transform: scale(0.96);
}
/* UI chrome is not selectable; content text stays selectable. */
.btn-primary,
.btn-secondary,
.btn-ghost,
.nav-row,
.folder-pill,
.segmented,
.tabbar,
.study-chrome,
summary,
.press {
-webkit-user-select: none;
user-select: none;
-webkit-touch-callout: none;
}
}
@media (prefers-reduced-motion: reduce) {
.press:active,
.btn-primary:active,
.btn-secondary:active,
.btn-ghost:active,
.card-hover:active,
.nav-row:active,
.folder-pill:active,
.segmented > button:active,
.segmented > a:active,
.tabbar a:active,
.tabbar button:active {
transform: none;
}
}
/* Sliding segmented highlight: when a control carries the shared layout
pill, the item itself goes transparent so the pill can glide between
items. (Unlayered, like the rules above, so it wins over them.) */
.segmented > .seg-slide[aria-current="page"],
.segmented > .seg-slide[aria-pressed="true"],
.segmented > .seg-slide[aria-checked="true"] {
background-image: none;
box-shadow: none;
}
.segmented > .seg-slide[aria-current="page"] > .seg-label,
.segmented > .seg-slide[aria-pressed="true"] > .seg-label,
.segmented > .seg-slide[aria-checked="true"] > .seg-label {
color: #06111f;
}
.dark .segmented > .seg-slide > .seg-label {
color: var(--color-frost-300);
}
.dark .segmented > .seg-slide[aria-current="page"] > .seg-label,
.dark .segmented > .seg-slide[aria-pressed="true"] > .seg-label,
.dark .segmented > .seg-slide[aria-checked="true"] > .seg-label {
color: #06111f;
}
/* Shared sliding pill for non-segmented pickers (difficulty, size). */
.slide-pill {
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
}
/* App launch: one quick fade of the shell, no layout shift. */
@keyframes app-in {
from { opacity: 0 }
to { opacity: 1 }
}
.app-shell-in { animation: app-in 0.28s ease-out both; }
@media (prefers-reduced-motion: reduce) {
.app-shell-in { animation: none; }
}
/* The loading screen's icon: a slow rock and swell for as long as a model
runs. CSS on the individual transform properties, so the compositor runs
it — the framer-motion loop it replaces rewrote the transform from the
main thread on every frame. */
@keyframes loading-rock {
0%, 100% { rotate: 0deg; }
33.333% { rotate: 8deg; }
66.667% { rotate: -8deg; }
}
@keyframes loading-swell {
0%, 100% { scale: 1; }
50% { scale: 1.08; }
}
.loading-bob {
animation: loading-rock 3.2s ease-in-out infinite, loading-swell 3.2s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.loading-bob { animation: none; }
}
/* Phones: a quick fade when the study feed takes over (the generation
hand-off), so the result doesn't hard-cut in. */
@media (max-width: 639px) {
.fade-in-mobile { animation: app-in 0.28s ease-out both; }
}
@media (max-width: 639px) and (prefers-reduced-motion: reduce) {
.fade-in-mobile { animation: none; }
}
/* Phones: Apple's 44px minimum for touch targets. The class is a no-op on
desktop (and on tablets), so components can carry it unconditionally. */
@media (max-width: 639px) {
.touch-target { min-height: 44px; min-width: 44px; }
.nav-row { min-height: 44px; }
.segmented > button { min-height: 44px; }
.folder-pill { min-height: 44px; }
}
/* Settings sheet: contained momentum scrolling; the safe-area padding comes
from the pb-safe utility plus a trailing spacer. The mask fades the last
36px of *content* (not a coloured overlay, so it blends with the glass in
both themes) — that is the scroll cue; the trailing spacer keeps real
content out of the faded strip once you reach the bottom. */
.sheet-scroll {
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
-webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - 36px), transparent);
mask-image: linear-gradient(to bottom, #000 calc(100% - 36px), transparent);
}
}
/* Mobile form fields ----------------------------------------------------- */
/* iOS Safari zooms the whole page when a focused field's text is under 16px
(it ignores user-scalable=no for that). Deliberately unlayered so it wins
over the per-component Tailwind sizes, and only on phone-sized screens. */
@media (max-width: 639px) {
input:not([type="checkbox"]):not([type="radio"]):not([type="range"]),
textarea,
select {
font-size: 16px;
}
}
/* Reduced motion --------------------------------------------------------- */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
scroll-behavior: auto !important;
}
}
/* ---------------------------------------------------------------------------
Reduced motion (Settings toggle, default off).
The class mirrors `prefers-reduced-motion: reduce`: the scene parks and
every CSS animation/transition collapses to nothing. framer-motion gets
told separately (components/MotionPreference).
--------------------------------------------------------------------------- */
html.reduce-motion .ice-floats {
inset: 0;
}
html.reduce-motion .ice-orb,
html.reduce-motion .ice-bubble {
animation: none !important;
will-change: auto;
}
html.reduce-motion.aero .ice-bubble {
opacity: 0.55;
transform: translate3d(0, -46vh, 0);
}
html.reduce-motion .ice-orb-1 { top: 14%; left: 12%; }
html.reduce-motion .ice-orb-2 { top: 58%; left: 4%; }
html.reduce-motion .ice-orb-3 { top: auto; bottom: -70px; right: 10%; }
html.reduce-motion .ice-orb-4 { top: 24%; right: 28%; }
html.reduce-motion .ice-orb-5 { top: auto; bottom: 16%; left: 38%; }
html.reduce-motion *,
html.reduce-motion *::before,
html.reduce-motion *::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
scroll-behavior: auto !important;
}