app/not-found.tsx renders a standalone screen: the root layout's ice scene is the backdrop, the glass card/rim/bubbles come from the shared recipes (full Aero with that theme on, frosted glass in light/dark), and the mascot is the scene's glossy ice orb at full strength with a snowflake and a "404" chip. Two ways out - back to the library and into the chat - both 44px targets, en + cs. The regression suite pins the 404 status, both links and the Czech copy; the pixel-contrast scan covers the screen in all three themes. The service worker no longer caches non-OK navigations as the offline shell (a typo'd URL used to write the error page into the shell cache). Version 0.5.6, SW -> freezmo-v78.
1667 lines
60 KiB
CSS
1667 lines
60 KiB
CSS
@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;
|
||
|
||
/* 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(255 255 255 / 0.75);
|
||
--nav-active-border: rgb(56 189 248 / 0.5);
|
||
/* 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.14);
|
||
--nav-active-border: rgb(56 189 248 / 0.4);
|
||
/* 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;
|
||
--ice-grain-opacity: 0.05;
|
||
}
|
||
.dark {
|
||
--ice-base-top: #070b16;
|
||
--ice-base-bottom: #0c1426;
|
||
--ice-blob-opacity: 0.1;
|
||
--ice-orb-opacity: 0.38;
|
||
--ice-grain-opacity: 0.045;
|
||
}
|
||
|
||
/* 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.85);
|
||
--nav-active-border: rgb(14 165 233 / 0.7);
|
||
--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;
|
||
--ice-grain-opacity: 0.02;
|
||
}
|
||
|
||
/* 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=3");
|
||
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. */
|
||
.ice-bubble {
|
||
display: none;
|
||
}
|
||
.aero .ice-bubble {
|
||
display: block;
|
||
position: absolute;
|
||
bottom: -14vh;
|
||
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;
|
||
}
|
||
.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; }
|
||
.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. */
|
||
.aero .hint-line {
|
||
display: inline-block;
|
||
padding: 0.2rem 0.6rem;
|
||
border-radius: 0.75rem;
|
||
background: var(--glass-bg);
|
||
border: 1px solid var(--glass-border);
|
||
/* Same trap as the buttons above: use the Tailwind utilities. */
|
||
@apply backdrop-blur-md backdrop-saturate-150;
|
||
}
|
||
|
||
/* 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.27);
|
||
}
|
||
|
||
/* 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 .segmented,
|
||
/* 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=3");
|
||
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 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 (a stronger veil than the shared glass, so an
|
||
answer reads as its own bubble). */
|
||
.aero .chat-bubble,
|
||
.aero .chat-answer {
|
||
border-color: rgb(255 255 255 / 0.7);
|
||
background-image:
|
||
linear-gradient(
|
||
180deg,
|
||
rgb(255 255 255 / 0.6) 0%,
|
||
rgb(255 255 255 / 0.44) 46%,
|
||
rgb(255 255 255 / 0.4) 53%,
|
||
rgb(255 255 255 / 0.52) 100%
|
||
),
|
||
url("/aero/background-blur.webp?v=3");
|
||
background-size: 100% 100%, cover;
|
||
background-position: 0 0, center 42%;
|
||
background-attachment: scroll, fixed;
|
||
background-repeat: no-repeat, no-repeat;
|
||
box-shadow: 0 16px 34px -22px rgb(2 62 110 / 0.45), inset 0 1px 0 rgb(255 255 255 / 0.9);
|
||
}
|
||
.aero .chat-answer {
|
||
border: 1px solid rgb(255 255 255 / 0.7);
|
||
border-radius: 1.25rem;
|
||
border-bottom-left-radius: 0.6rem;
|
||
padding: 0.7rem 0.95rem;
|
||
}
|
||
|
||
/* The phone tab bar carries its labels right at the bottom of the water, so
|
||
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=3");
|
||
background-size: 100% 100%, cover;
|
||
background-position: 0 0, center 42%;
|
||
background-attachment: scroll, fixed;
|
||
background-repeat: no-repeat, no-repeat;
|
||
}
|
||
|
||
/* Touch devices: flat glass, no bake and no live blur. The baked layer is
|
||
`background-attachment: fixed`, which mobile engines repaint late (iOS
|
||
treats it as scroll) — panes showed a stale crop until a tap forced a
|
||
repaint. The honest alternative (a real backdrop-filter) is worse: every
|
||
pane re-blurs on every frame while the bubbles animate, and phones choke —
|
||
measured as input lag, "buttons take ages to respond". So on touch the
|
||
panes keep the thin Ask-pill tint over the sharp scene: transparent glass
|
||
at zero per-frame cost. Desktop keeps the baked blur. */
|
||
@media (max-width: 639px), (pointer: coarse) {
|
||
.aero .card,
|
||
.aero .glass-solid,
|
||
.aero .sidebar-surface,
|
||
.aero .gate-card,
|
||
.aero .list-group,
|
||
.aero .nav-action,
|
||
.aero .nav-row[aria-current="page"],
|
||
.aero .nav-row[aria-pressed="true"],
|
||
.aero .nav-row[data-active="true"],
|
||
.aero .glass-chip,
|
||
.aero .glass-pill,
|
||
.aero .btn-secondary,
|
||
.aero .btn-ghost,
|
||
.aero .chat-header,
|
||
.aero .chat-empty,
|
||
.aero .segmented {
|
||
background-image: none;
|
||
background-color: rgb(255 255 255 / 0.32);
|
||
}
|
||
.aero .chat-bubble,
|
||
.aero .chat-answer {
|
||
background-image: none;
|
||
background-color: rgb(255 255 255 / 0.55);
|
||
}
|
||
.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=3");
|
||
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=3");
|
||
background-size: 100% 100%, cover;
|
||
background-position: 0 0, center 42%;
|
||
background-attachment: scroll, fixed;
|
||
background-repeat: no-repeat, no-repeat;
|
||
border: 1px solid rgb(255 255 255 / 0.6);
|
||
color: var(--text);
|
||
}
|
||
|
||
/* Chat header: the same glass as the cards, a touch more fill — its title and
|
||
scope line sit directly over the sky. At lg its divider is the inset hairline
|
||
the desktop docking block below draws (::after), so it never hits the seam. */
|
||
.aero .chat-header {
|
||
background-color: rgb(232 248 255 / 0.4);
|
||
border-bottom: 1px solid rgb(255 255 255 / 0.6);
|
||
}
|
||
.aero .chat-header::after {
|
||
background: rgb(255 255 255 / 0.6);
|
||
}
|
||
|
||
/* The chat composer: one flat frosted panel for the input, the chips and the
|
||
note — instead of each of them floating on the wallpaper. */
|
||
.aero .chat-composer {
|
||
background-color: rgb(232 248 255 / 0.55);
|
||
border-top: 1px solid rgb(255 255 255 / 0.65);
|
||
box-shadow: 0 -16px 36px -26px rgb(2 62 110 / 0.5);
|
||
}
|
||
.aero .chat-composer::after {
|
||
background: rgb(255 255 255 / 0.65);
|
||
}
|
||
|
||
/* Desktop: the chat chrome docks into the rail. The header and the composer
|
||
are full-bleed bands inside the main pane; at lg that pane starts exactly
|
||
at the sidebar, so both bands used to stop in a hard vertical edge beside
|
||
the floating rail card — the scene showed through the 12px gutter and the
|
||
bands' own fill/blur made it read as a color step at the seam. They now
|
||
reach the card's right edge (the rail's own 12px padding, in the expanded
|
||
and the collapsed state alike) and their hairlines span the content box
|
||
instead of starting in mid-air at the seam. */
|
||
@media (min-width: 1024px) {
|
||
.chat-header {
|
||
margin-left: -0.75rem;
|
||
padding-left: 1.75rem;
|
||
border-bottom: 0;
|
||
}
|
||
.aero .chat-header {
|
||
border-bottom: 0;
|
||
}
|
||
.chat-header::after {
|
||
content: "";
|
||
position: absolute;
|
||
left: 1.75rem;
|
||
right: 1rem;
|
||
bottom: 0;
|
||
height: 1px;
|
||
background: var(--glass-border);
|
||
}
|
||
.chat-composer {
|
||
margin-left: -0.75rem;
|
||
}
|
||
.chat-composer-band {
|
||
padding-left: 3.25rem;
|
||
border-top: 0;
|
||
}
|
||
.aero .chat-composer {
|
||
border-top: 0;
|
||
}
|
||
.chat-composer::after {
|
||
content: "";
|
||
position: absolute;
|
||
left: 3.25rem;
|
||
right: 2.5rem;
|
||
top: 0;
|
||
height: 1px;
|
||
background: var(--glass-border);
|
||
}
|
||
}
|
||
|
||
/* The chat welcome (subtitle + suggestions): the same frosted glass as the
|
||
cards (the shared recipe above), stretched flush with the header and the
|
||
composer — a panel floating 40px inside the scene looked cut off. */
|
||
.aero .chat-empty {
|
||
margin-inline: -1rem;
|
||
border-radius: 1.5rem;
|
||
border: 1px solid rgb(255 255 255 / 0.5);
|
||
background-color: rgb(255 255 255 / 0.32);
|
||
padding: 1.35rem 1rem 1.1rem;
|
||
}
|
||
@media (min-width: 1024px) {
|
||
.aero .chat-empty {
|
||
margin-inline: -2.5rem;
|
||
padding: 1.5rem 2.5rem 1.25rem;
|
||
}
|
||
}
|
||
|
||
/* 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's lower half sits over the grass; weight the glass a little below
|
||
the middle so the bottom rows keep the same contrast as the top. */
|
||
.aero .sidebar-surface {
|
||
background-image:
|
||
linear-gradient(180deg, rgb(255 255 255 / 0) 42%, rgb(255 255 255 / 0.22) 100%),
|
||
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=3");
|
||
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;
|
||
}
|
||
|
||
/* 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;
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
/* The key gate rides on the shared ice scene (see .ice-scene). */
|
||
|
||
/* The fixed ice scene: navy/pale base, drifting glacier blobs, glossy ice
|
||
orbs and a whisper of grain. Purely decorative. */
|
||
.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);
|
||
will-change: transform;
|
||
/* No filter: blur() here on purpose. A radial gradient fading to
|
||
transparent is already a soft blob, and blurring a huge layer that is
|
||
also being animated forces a re-rasterisation every frame (measured
|
||
150ms vs 33ms per frame in a software renderer). The falloff stops below
|
||
keep the same softness. */
|
||
}
|
||
.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%);
|
||
animation: ice-drift-a 44s ease-in-out infinite;
|
||
}
|
||
.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%);
|
||
animation: ice-drift-b 52s ease-in-out infinite;
|
||
}
|
||
.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%);
|
||
animation: ice-drift-c 60s ease-in-out infinite;
|
||
}
|
||
.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%);
|
||
animation: ice-drift-a 48s ease-in-out infinite reverse;
|
||
}
|
||
|
||
@keyframes ice-drift-a {
|
||
0%, 100% { transform: translate3d(0, 0, 0) scale(1); }
|
||
50% { transform: translate3d(4vw, 3vh, 0) scale(1.08); }
|
||
}
|
||
@keyframes ice-drift-b {
|
||
0%, 100% { transform: translate3d(0, 0, 0) scale(1.05); }
|
||
50% { transform: translate3d(-5vw, 4vh, 0) scale(1); }
|
||
}
|
||
@keyframes ice-drift-c {
|
||
0%, 100% { transform: translate3d(0, 0, 0) scale(1); }
|
||
50% { transform: translate3d(3vw, -4vh, 0) scale(1.1); }
|
||
}
|
||
|
||
/* Glossy ice orbs: slow bubbles that rise from just below the bottom edge to
|
||
above the top edge, swaying gently and fading in/out near the 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;
|
||
top: 100%;
|
||
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: -100vh - 100% clears the top edge (100% = the orb's own height);
|
||
the sway crosses the path twice and the scale drifts slightly. Opacity
|
||
reaches the token value by 12% and holds it until 88%, so the fades happen
|
||
while the orb is still partly outside the scene. */
|
||
@keyframes ice-rise {
|
||
0% { transform: translate3d(0, 0, 0) scale(0.94); opacity: 0; }
|
||
12% { opacity: var(--ice-orb-opacity); }
|
||
32% { transform: translate3d(var(--ice-sway, 0px), calc(-32vh - 32%), 0) scale(0.97); }
|
||
58% { transform: translate3d(calc(var(--ice-sway, 0px) * -0.55), calc(-58vh - 58%), 0) scale(1.02); }
|
||
80% { transform: translate3d(calc(var(--ice-sway, 0px) * 0.4), calc(-80vh - 80%), 0) scale(1.04); }
|
||
88% { opacity: var(--ice-orb-opacity); }
|
||
100% { transform: translate3d(0, calc(-100vh - 100%), 0) scale(1.06); opacity: 0; }
|
||
}
|
||
|
||
/* Frosted grain over the whole scene. */
|
||
.ice-grain {
|
||
position: absolute;
|
||
inset: 0;
|
||
opacity: var(--ice-grain-opacity);
|
||
mix-blend-mode: overlay;
|
||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='180' height='180' filter='url(%23n)'/%3E%3C/svg%3E");
|
||
}
|
||
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.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
|
||
applied at all in Chrome, which would leave them parked below the fold)
|
||
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%; }
|
||
}
|
||
|
||
/* Background tab: park every scene animation, so an invisible page costs no
|
||
compositor work. */
|
||
.ice-paused .ice-orb,
|
||
.ice-paused .ice-blob {
|
||
animation-play-state: paused;
|
||
}
|
||
|
||
/* Phones: fewer, softer blobs so the drift animation stays 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): 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.
|
||
|
||
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: 1.5rem;
|
||
}
|
||
|
||
/* 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: 1.5rem; /* 24px */
|
||
border: 1px solid var(--glass-border);
|
||
}
|
||
|
||
.gate-card {
|
||
border: 1px solid var(--glass-border);
|
||
border-radius: 1.75rem; /* 28px */
|
||
}
|
||
|
||
/* 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: 1rem;
|
||
@apply backdrop-blur-lg backdrop-saturate-150;
|
||
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);
|
||
}
|
||
|
||
/* iOS segmented control ------------------------------------------------- */
|
||
.segmented {
|
||
display: flex;
|
||
gap: 2px;
|
||
padding: 3px;
|
||
border-radius: 0.95rem;
|
||
@apply backdrop-blur-sm;
|
||
background: var(--surface-sunken);
|
||
border: 1px solid var(--hairline);
|
||
}
|
||
.segmented > button {
|
||
flex: 1;
|
||
border-radius: 0.75rem;
|
||
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: 0.75rem;
|
||
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: 1.5rem;
|
||
}
|
||
|
||
/* Sidebar nav rows */
|
||
.nav-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.625rem;
|
||
width: 100%;
|
||
border-radius: 0.625rem;
|
||
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: 0.75rem;
|
||
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);
|
||
box-shadow: inset 0 0 0 1px var(--nav-active-border), inset 0 1px 0 var(--glass-highlight);
|
||
}
|
||
|
||
/* 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: 14px;
|
||
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: 14px;
|
||
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: 14px;
|
||
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; }
|
||
}
|
||
|
||
/* 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; }
|
||
}
|
||
|
||
@media (display-mode: standalone) {
|
||
.standalone-hide { display: 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-blob,
|
||
html.reduce-motion .ice-orb,
|
||
html.reduce-motion .ice-bubble {
|
||
animation: none !important;
|
||
}
|
||
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;
|
||
}
|