@import "tailwindcss"; /* Class-based dark mode (toggled on 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); --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.55); --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.06), 0 9px 22px -8px rgb(14 21 40 / 0.16); --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); --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: #b3c4e0; /* 10.1:1 */ --text-subtle: #9db1d0; /* 8.6:1 on page, 7.2: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.34; --ice-grain-opacity: 0.05; } .dark { --ice-base-top: #070b16; --ice-base-bottom: #0c1426; --ice-blob-opacity: 0.1; --ice-orb-opacity: 0.5; --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.9); --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.45); --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"); 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 32% 28%, rgb(255 255 255 / 0.95) 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 / 0.95), 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). */ .aero .glass-panel, .aero .study-chrome, .aero .tabbar, .aero .glass { background-image: linear-gradient( 180deg, rgb(255 255 255 / 0.75) 0%, rgb(255 255 255 / 0.3) 46%, rgb(255 255 255 / 0.06) 47%, rgb(255 255 255 / 0.24) 100% ); } /* Rising bubbles. Hidden entirely in the other themes, so they cost nothing there; under reduced motion they sit still instead of rising. */ .ice-bubble { display: none; } .aero .ice-bubble { display: block; position: absolute; bottom: -14vh; border-radius: 50%; opacity: 0; background: radial-gradient( circle at 32% 28%, rgb(255 255 255 / 0.95) 0%, rgb(226 248 255 / 0.38) 24%, rgb(125 211 252 / 0.12) 58%, rgb(56 189 248 / 0.34) 100% ); box-shadow: inset -6px -10px 16px -8px rgb(3 74 124 / 0.45), inset 6px 8px 14px -8px rgb(255 255 255 / 0.9), 0 10px 22px -14px rgb(3 74 124 / 0.5); animation: aero-bubble-rise var(--bubble-duration, 20s) linear infinite; animation-delay: var(--bubble-delay, 0s); will-change: transform, opacity; } .ice-paused .ice-bubble { animation-play-state: paused; } @keyframes aero-bubble-rise { 0% { transform: translate3d(0, 0, 0) scale(0.85); opacity: 0; } 6% { opacity: var(--bubble-opacity, 0.9); } 50% { transform: translate3d(var(--bubble-sway, 3vw), -52vh, 0) scale(1); } 94% { opacity: var(--bubble-opacity, 0.9); } 100% { transform: translate3d(calc(var(--bubble-sway, 3vw) * -0.5), -104vh, 0) scale(1.06); opacity: 0; } } .aero .ice-bubble-1 { width: 104px; height: 104px; left: 7%; --bubble-duration: 19s; --bubble-delay: -2s; --bubble-sway: 4vw; } .aero .ice-bubble-2 { width: 34px; height: 34px; left: 16%; --bubble-duration: 13s; --bubble-delay: -7s; --bubble-sway: -3vw; } .aero .ice-bubble-3 { width: 64px; height: 64px; left: 28%; --bubble-duration: 22s; --bubble-delay: -12s; --bubble-sway: 3vw; } .aero .ice-bubble-4 { width: 20px; height: 20px; left: 38%; --bubble-duration: 11s; --bubble-delay: -4s; --bubble-sway: 2vw; } .aero .ice-bubble-5 { width: 88px; height: 88px; left: 48%; --bubble-duration: 25s; --bubble-delay: -16s; --bubble-sway: -4vw; } .aero .ice-bubble-6 { width: 28px; height: 28px; left: 58%; --bubble-duration: 12s; --bubble-delay: -9s; --bubble-sway: 3vw; } .aero .ice-bubble-7 { width: 72px; height: 72px; left: 68%; --bubble-duration: 20s; --bubble-delay: -5s; --bubble-sway: -2vw; } .aero .ice-bubble-8 { width: 46px; height: 46px; left: 78%; --bubble-duration: 16s; --bubble-delay: -13s; --bubble-sway: 4vw; } .aero .ice-bubble-9 { width: 24px; height: 24px; left: 87%; --bubble-duration: 14s; --bubble-delay: -6s; --bubble-sway: -3vw; } .aero .ice-bubble-10 { width: 120px; height: 120px; left: 92%; --bubble-duration: 27s; --bubble-delay: -20s; --bubble-sway: -5vw; --bubble-opacity: 0.75; } /* 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; } /* The thin glass the Ask / Import JSON buttons use: a 34% white tint over the sharp wallpaper with a bright rim — no pre-blurred layer, so it reads as glass rather than as a white chip. Rail rows, the active nav item and the active segment all match it. */ .aero .nav-action, .aero .nav-row[aria-current="page"], .aero .nav-row[data-active="true"], .aero .segmented > button[aria-pressed="true"], .aero .segmented > a[aria-current="page"] { background-image: none; background-color: rgb(255 255 255 / 0.34); border: 1px solid rgb(255 255 255 / 0.9); } .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 pre-blurred wallpaper rule above. */ } /* Liquid glass: far more transparent than the other themes, with a strong backdrop blur doing the legibility work — the wallpaper stays visible through every surface. Contrast is pixel-scanned in all three themes, so the tint cannot drop below what the text needs. */ .aero { --glass-bg: rgb(228 246 255 / 0.34); --glass-solid-bg: rgb(232 248 255 / 0.46); --glass-sheet-bg: rgb(232 248 255 / 0.8); --surface-strong: rgb(255 255 255 / 0.34); --surface-sunken: rgb(255 255 255 / 0.26); --nav-active-bg: rgb(255 255 255 / 0.4); } /* Liquid glass without the per-frame cost: every surface shows a pre-blurred copy of the wallpaper, glued to the viewport (`background-attachment: fixed`), with the tint and the gloss layered on top. Blurring the live scene instead measured 66ms per frame at 2x CPU throttle — the bubbles move behind every surface, so a real backdrop-filter re-runs ~60 times a second (vs 17ms with nothing blurring). The look is the same; the cost is zero. */ .aero .card, .aero .glass-solid, .aero .sidebar-surface, .aero .gate-card, .aero .btn-primary, .aero .grad-ice, .aero .hint-line { background-image: linear-gradient( 180deg, rgb(255 255 255 / 0.5) 0%, rgb(255 255 255 / 0.2) 47%, rgb(255 255 255 / 0.07) 53%, rgb(255 255 255 / 0.24) 100% ), linear-gradient(rgb(230 247 255 / 0.42), rgb(230 247 255 / 0.42)), url("/aero/background-blur.webp"); 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; } 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.2; } .dark { --ice-blob-opacity: 0.05; --ice-orb-opacity: 0.26; } /* 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; } } @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(--color-frost-700); 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(--color-frost-700); 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: #16714f; } .diff-medium { color: #7a5300; } .diff-hard { color: #b4342f; } .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; } }