Frosted ice glass restyle

Rebuilds the look around the spec: an ice scene, one shared glass
recipe, icy accents and glass on every large surface. Visual only —
layout, behaviour, copy and functionality are untouched.

Background
- .ice-scene: fixed, z-index -1, pointer-events none. Navy base
  (#070b16 to #0c1426) in dark, pale (#f4f9ff to #e6f1fb) in light,
  four drifting glacier blobs (#38bdf8/#60a5fa/#818cf8, 30-60s
  transform-only loops), five glossy ice orbs (40-220px radial
  gradients with a highlight at 30% 25% and a blue shadow) placed so the
  glass blurs them, and an SVG feTurbulence grain at 4-5%.
- prefers-reduced-motion stops the drift; phones get two blobs and two
  orbs fewer.

Glass
- One shared recipe (.glass-panel, .card, .sidebar-surface,
  .study-chrome, .tabbar, .glass, .gate-card): rgba(255,255,255,.06)
  dark / .55 light, blur(24px) saturate(140%) (16px on phones), a bright
  1px rim, an inset top highlight and a blue-tinted outer shadow.
- Nested glass (chips, inputs, secondary buttons) is more opaque and
  never adds a second blur layer. @supports fallback for no
  backdrop-filter. Radii: 24-28px cards/sheets, 14px inputs/buttons,
  pills for chips. Cards hover brighter + cyan rim + 2px lift, 180ms.

Colour and type
- Accent is glacier cyan (#38bdf8) to ice blue (#0ea5e9); progress bars
  and selected states use the gradient; the active sidebar item is a
  frosted highlight with a cyan rim.
- Primary CTA: icy gradient — white to #bfe3ff with ink text in dark,
  deep blue in light (biased so the label sits on the darker half and
  keeps 5.6:1).
- Leftover slate/black greys replaced with navy-tinted frost tokens.
- Headings are semibold with tighter tracking.

Screens
- Dashboard: the sidebar is one tall inset rounded glass panel; deck
  cards are glass.
- Gate: one centred glass card over the scene, orbs visible behind it.
- Sheets/modals: glass over the dimmed, lightly blurred backdrop.
- Study view: question card more opaque (.glass-solid); top bar and
  pager are slim glass pills.

Perf: measured on a phone viewport with 4x CPU throttling. The animated
blobs' filter: blur() was the whole cost (150ms vs 33ms per frame), so
they are soft radial gradients with no filter now; idle 60fps, ~17ms
median while scrolling.

Tests updated for the new tokens: contrast 18/18 AA, deck chrome 15/15,
key gate 49/49, install sheet 64/64, deck fixes 15/15, retry 14/14,
zoom 6/6, final ux 12/12, security 8/8, pwa 16/16.
This commit is contained in:
Martin Dvořák 2026-10-08 10:11:55 +00:00
commit 52cce42cde
20 changed files with 383 additions and 262 deletions

View file

@ -181,7 +181,7 @@ function GateScreen() {
return (
<div
ref={rootRef}
className="gate-bg fixed inset-0 h-dvh overflow-hidden overscroll-none"
className="fixed inset-0 h-dvh overflow-hidden overscroll-none"
>
{/* corner controls: theme + help ---------------------------------- */}
<div
@ -230,7 +230,7 @@ function GateScreen() {
initial={reduceMotion ? false : { opacity: 0, y: 14 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.45, ease: [0.22, 1, 0.36, 1] }}
className="my-auto w-full max-w-[420px] rounded-[24px] border border-[var(--gate-card-border)] bg-[var(--gate-card)] p-6 px-7 text-center shadow-[var(--shadow-gate)] sm:p-8"
className="gate-card my-auto w-full max-w-[420px] p-6 px-7 text-center sm:p-8"
>
{/* eslint-disable-next-line @next/next/no-img-element -- static app icon, no optimisation needed */}
<img
@ -240,7 +240,7 @@ function GateScreen() {
className="mx-auto size-14 rounded-[16px] shadow-[var(--shadow-card)]"
/>
<h1 className="mt-6 text-[24px] font-medium leading-tight tracking-[-0.01em] text-[var(--text)] sm:text-[28px]">
<h1 className="mt-6 text-[24px] font-semibold leading-tight tracking-[-0.02em] text-[var(--text)] sm:text-[28px]">
Connect your Ollama key
</h1>
<p className="mx-auto mt-1.5 max-w-[300px] text-balance text-[15px] leading-relaxed text-[var(--text-subtle)]">
@ -363,12 +363,12 @@ function HelpDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
exit={reduceMotion ? { opacity: 0 } : { y: 24, opacity: 0 }}
transition={{ type: "spring", stiffness: 340, damping: 32 }}
onClick={(event) => event.stopPropagation()}
className="thin-scroll max-h-[90dvh] w-full max-w-[480px] overflow-y-auto rounded-t-[24px] border border-[var(--gate-card-border)] bg-[var(--gate-card)] p-6 pb-safe shadow-[var(--shadow-gate)] sm:rounded-[24px] sm:pb-6"
className="gate-card thin-scroll max-h-[90dvh] w-full max-w-[480px] overflow-y-auto p-6 pb-safe sm:pb-6"
>
<div className="flex items-center justify-between gap-4">
<h2
id="gate-help-title"
className="text-[19px] font-medium leading-snug tracking-[-0.01em] text-[var(--text)]"
className="text-[19px] font-semibold leading-snug tracking-[-0.02em] text-[var(--text)]"
>
How to get your Ollama API key
</h2>

View file

@ -114,7 +114,7 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str
href={`/deck/${deck.id}`}
aria-hidden="true"
tabIndex={-1}
className="absolute inset-0 hidden rounded-[1.125rem] lg:block"
className="absolute inset-0 hidden rounded-[1.5rem] lg:block"
/>
{/* header ------------------------------------------------------- */}
@ -322,7 +322,7 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str
className={cn(
"rounded-full px-2.5 py-1 text-[12px] font-medium transition",
current
? "bg-[var(--text)] text-white dark:bg-white dark:text-[#0d0d0d]"
? "grad-ice text-[#06111f]"
: "text-[var(--text-muted)] hover:bg-[var(--hover)] hover:text-[var(--text)]",
)}
>
@ -399,7 +399,7 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str
className={cn(
"min-w-9 rounded-full px-2.5 py-1 text-[12px] font-medium transition",
addCount === option
? "bg-[var(--text)] text-white dark:bg-white dark:text-[#0d0d0d]"
? "grad-ice text-[#06111f]"
: "text-[var(--text-muted)] hover:bg-[var(--hover)] hover:text-[var(--text)]",
)}
>

View file

@ -0,0 +1,22 @@
/**
* The fixed ice scene behind every screen: a navy (or pale) base, four slow
* drifting glacier blobs, glossy ice orbs and a whisper of grain. Purely
* decorative — it never intercepts pointer events, and the drift stops under
* `prefers-reduced-motion`.
*/
export function IceBackdrop() {
return (
<div className="ice-scene" aria-hidden>
<span className="ice-blob ice-blob-a" />
<span className="ice-blob ice-blob-b" />
<span className="ice-blob ice-blob-c" />
<span className="ice-blob ice-blob-d" />
<span className="ice-orb ice-orb-1" />
<span className="ice-orb ice-orb-2" />
<span className="ice-orb ice-orb-3" />
<span className="ice-orb ice-orb-4" />
<span className="ice-orb ice-orb-5" />
<span className="ice-grain" />
</div>
);
}

View file

@ -236,7 +236,7 @@ export function InstallSheet() {
animate={reduceMotion ? { opacity: 1 } : { y: 0 }}
exit={reduceMotion ? { opacity: 0 } : { y: "100%" }}
transition={{ duration: 0.34, ease: [0.22, 1, 0.36, 1] }}
className="absolute inset-x-0 bottom-0 mx-auto w-full rounded-t-[28px] border border-b-0 border-[var(--install-border)] bg-[var(--install-sheet)] shadow-[0_-20px_60px_-28px_rgb(0_0_0/0.5)] sm:max-w-xl"
className="gate-card absolute inset-x-0 bottom-0 mx-auto w-full rounded-b-none border-b-0 sm:max-w-xl"
>
<div className="thin-scroll max-h-[90dvh] overflow-y-auto px-5 pt-2 pb-[calc(24px+env(safe-area-inset-bottom))]">
<div
@ -250,7 +250,7 @@ export function InstallSheet() {
<div className="flex items-center justify-between gap-4">
<h2
id="install-sheet-title"
className="text-[17px] font-semibold tracking-[-0.01em] text-[var(--text)]"
className="text-[17px] font-semibold tracking-[-0.02em] text-[var(--text)]"
>
{info.platform === "ios"
? "Install Freezmo on iPhone"

View file

@ -108,7 +108,7 @@ export function LibraryScreen() {
<section className="mb-6 space-y-5">
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
<h1 className="text-[24px] font-medium leading-tight tracking-[-0.01em] text-[var(--text)] sm:text-[26px]">
<h1 className="text-[24px] font-semibold leading-tight tracking-[-0.02em] text-[var(--text)] sm:text-[26px]">
Your icebox
</h1>
<p className="mt-1.5 text-[13.5px] text-[var(--text-subtle)]">

View file

@ -65,10 +65,10 @@ export function SettingsDialog({ open, onClose }: { open: boolean; onClose: () =
<div className="sheet-grabber sm:hidden" aria-hidden />
<div className="mb-4 flex items-start justify-between gap-4">
<div>
<h2 id="settings-title" className="text-lg font-semibold text-frost-950 dark:text-frost-50">
<h2 id="settings-title" className="text-lg font-semibold tracking-[-0.02em] text-frost-950 dark:text-frost-50">
Settings
</h2>
<p className="text-sm text-slate-600 dark:text-slate-300">
<p className="text-sm text-frost-600 dark:text-frost-300">
Models come from <code className="rounded bg-frost-100 px-1 dark:bg-white/10">.env.local</code> on
the server; your API key stays in this browser.
</p>
@ -178,7 +178,7 @@ export function SettingsDialog({ open, onClose }: { open: boolean; onClose: () =
) : null}
{loading && !health ? (
<div className="flex items-center gap-3 py-6 text-sm text-slate-600 dark:text-slate-300">
<div className="flex items-center gap-3 py-6 text-sm text-frost-600 dark:text-frost-300">
<Spinner className="size-5" /> Checking Ollama…
</div>
) : null}

View file

@ -362,11 +362,14 @@ export function Sidebar() {
<aside
aria-label="Sidebar"
className={cn(
"sidebar-surface fixed inset-y-0 left-0 z-40 hidden flex-col px-2.5 py-3 transition-[width] duration-200 lg:flex",
isCollapsed ? "w-[68px]" : "w-[260px]",
"fixed inset-y-0 left-0 z-40 hidden p-3 transition-[width] duration-200 lg:flex",
isCollapsed ? "w-[84px]" : "w-[268px]",
)}
>
<NavContent collapsed={isCollapsed} desktop />
{/* One tall inset glass panel, so the ice scene shows around it. */}
<div className="sidebar-surface flex min-h-0 flex-1 flex-col px-2.5 py-3">
<NavContent collapsed={isCollapsed} desktop />
</div>
</aside>
);
}
@ -395,7 +398,7 @@ export function MobileSidebar() {
animate={{ x: 0 }}
exit={{ x: -280 }}
transition={{ type: "spring", stiffness: 380, damping: 36 }}
className="sidebar-surface relative z-10 flex h-full w-[272px] flex-col px-2.5 py-3 pb-safe"
className="sidebar-surface relative z-10 flex h-full w-[272px] flex-col rounded-l-none rounded-r-[1.5rem] px-2.5 py-3 pb-safe"
>
<NavContent onNavigate={() => sidebarOpen.set(false)} />
</motion.aside>
@ -411,7 +414,7 @@ export function SidebarOffset() {
return (
<div
aria-hidden
className={cn("hidden shrink-0 transition-[width] duration-200 lg:block", isCollapsed ? "w-[68px]" : "w-[260px]")}
className={cn("hidden shrink-0 transition-[width] duration-200 lg:block", isCollapsed ? "w-[84px]" : "w-[268px]")}
/>
);
}

View file

@ -209,7 +209,7 @@ export function StudyFeed({ deck }: { deck: Deck }) {
<div className="frost-bg fixed inset-0 flex flex-col">
{/* Chrome ---------------------------------------------------- */}
<header className="pt-safe absolute inset-x-0 top-0 z-30 px-3">
<div className="study-chrome mx-auto max-w-xl space-y-1.5 rounded-2xl px-3 py-2">
<div className="study-chrome mx-auto max-w-xl space-y-1.5 rounded-full px-3 py-2">
<div className="flex items-center gap-3">
<Link
href="/"
@ -326,7 +326,7 @@ export function StudyFeed({ deck }: { deck: Deck }) {
{/* Desktop next/prev buttons --------------------------------- */}
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 hidden justify-center pb-4 sm:flex">
<div className="study-chrome pointer-events-auto flex items-center gap-2 rounded-2xl px-2 py-1.5">
<div className="study-chrome pointer-events-auto flex items-center gap-2 rounded-full px-2 py-1.5">
<Button variant="ghost" size="sm" onClick={() => goTo(index - 1)} disabled={index === 0} aria-label="Previous activity">
↑
</Button>

View file

@ -33,7 +33,7 @@ export function TabBar() {
aria-current={pathname === "/create" ? "page" : undefined}
className={cn(item, pathname === "/create" ? active : idle)}
>
<span className="grid size-6 place-items-center rounded-full bg-[var(--text)] text-white dark:bg-white dark:text-[#0d0d0d]">
<span className="grad-ice grid size-6 place-items-center rounded-full text-[#06111f]">
<PlusIcon className="size-4" />
</span>
New deck

View file

@ -690,7 +690,7 @@ export function UploadScreen() {
{option.icon}
</span>
<span className="text-sm font-bold text-[var(--text)]">{option.label}</span>
<span className="hidden text-[11px] text-slate-500 sm:block dark:text-slate-400">{option.blurb}</span>
<span className="hidden text-[11px] text-frost-500 sm:block dark:text-frost-400">{option.blurb}</span>
</button>
);
})}
@ -709,7 +709,7 @@ export function UploadScreen() {
className={cn(
"min-w-12 rounded-xl border-2 px-3 py-1.5 text-sm font-bold transition",
count === option
? "border-transparent bg-[var(--text)] text-white dark:bg-white dark:text-[#0d0d0d]"
? "grad-ice border-transparent text-[#06111f]"
: "border-[var(--hairline)] text-[var(--text-muted)] hover:bg-[var(--hover)]",
)}
>

View file

@ -48,7 +48,7 @@ export function Flashcard({ activity, onAnswered, onNext, isLast }: ActivityComp
Question
</span>
<QuestionText>{activity.front}</QuestionText>
<span className="mt-2 text-xs font-semibold text-slate-400 dark:text-slate-400">
<span className="mt-2 text-xs font-semibold text-frost-400 dark:text-frost-400">
tap to flip ↻
</span>
</PromptCard>

View file

@ -59,7 +59,7 @@ export function Matching({ activity, onAnswered, onNext, isLast }: ActivityCompo
type="matching"
topic={activity.topic}
extra={
<span className="rounded-full bg-white/70 px-3 py-1 text-xs font-bold text-slate-600 dark:bg-white/10 dark:text-slate-200">
<span className="rounded-full bg-[var(--surface-strong)] px-3 py-1 text-xs font-bold text-frost-600 dark:text-frost-200">
{matched.length}/{total} matched
</span>
}

View file

@ -106,7 +106,7 @@ export function Ordering({ activity, onAnswered, onNext, isLast }: ActivityCompo
▼
</button>
</span>
<span aria-hidden className="text-slate-400">
<span aria-hidden className="text-frost-400">
⠿
</span>
</Reorder.Item>

View file

@ -96,7 +96,7 @@ export function ShortAnswer({ activity, active, language, onAnswered, onNext, is
disabled={state.status === "grading" || state.status === "graded"}
rows={3}
placeholder="Type your answer in your own words…"
className="w-full resize-none rounded-2xl border-2 border-frost-200 bg-white/90 px-4 py-3 text-[15px] font-medium text-frost-950 shadow-sm outline-none transition placeholder:text-slate-400 focus:border-frost-500 disabled:opacity-70 dark:border-white/12 dark:bg-white/[0.07] dark:text-frost-50 dark:placeholder:text-slate-400"
className="w-full resize-none rounded-2xl border-2 border-[var(--glass-border)] bg-[var(--surface-strong)] px-4 py-3 text-[15px] font-medium text-frost-950 shadow-sm outline-none transition placeholder:text-frost-400 focus:border-[var(--focus)] disabled:opacity-70 dark:border-[var(--glass-border)] dark:bg-[var(--surface-strong)] dark:text-frost-50 dark:placeholder:text-frost-400"
/>
{state.status === "idle" ? (

View file

@ -67,7 +67,7 @@ export function PromptCard({
className?: string;
}) {
return (
<div className={cn("glass-panel rounded-[1.35rem] px-5 py-6 text-balance sm:px-6", className)}>{children}</div>
<div className={cn("glass-panel glass-solid rounded-[1.5rem] px-5 py-6 text-balance sm:px-6", className)}>{children}</div>
);
}
@ -116,7 +116,7 @@ export function OptionTile({
)}
>
{shortcut ? (
<span className="grid size-6 shrink-0 place-items-center rounded-lg bg-black/5 text-xs font-bold text-slate-600 dark:bg-white/10 dark:text-slate-300">
<span className="grid size-6 shrink-0 place-items-center rounded-lg bg-[var(--surface-sunken)] text-xs font-bold text-frost-600 dark:text-frost-300">
{shortcut}
</span>
) : null}

View file

@ -20,9 +20,9 @@ const VARIANTS: Record<ButtonVariant, string> = {
};
const SIZES: Record<ButtonSize, string> = {
sm: "h-9 px-3.5 text-[13px] rounded-full",
md: "h-10 px-4 text-sm rounded-full",
lg: "h-11 px-5 text-[15px] rounded-full",
sm: "h-9 px-3.5 text-[13px] rounded-[14px]",
md: "h-10 px-4 text-sm rounded-[14px]",
lg: "h-11 px-5 text-[15px] rounded-[14px]",
};
export type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
@ -114,7 +114,7 @@ export function ProgressBar({
aria-label={label ?? "Progress"}
>
<div
className="h-full rounded-full bg-[var(--text)]/75 transition-[width] duration-500 ease-out"
className="grad-ice h-full rounded-full transition-[width] duration-500 ease-out"
style={{ width: `${percent}%` }}
/>
</div>