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:
parent
586e4b5a40
commit
52cce42cde
20 changed files with 383 additions and 262 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)]",
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
22
components/IceBackdrop.tsx
Normal file
22
components/IceBackdrop.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)]">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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]")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)]",
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" ? (
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue