Compare commits

..

No commits in common. "52cce42cde4809be2a717d3367200443c8f682e4" and "5d0559212b98fbc2ec510cb18067cf3312be8a68" have entirely different histories.

20 changed files with 238 additions and 394 deletions

View file

@ -112,11 +112,11 @@ no database, no data leaving your machine except the requests you send to Ollama
list with per-folder counts, Settings/Dark mode pinned to the bottom). It **collapses** to a 68px list with per-folder counts, Settings/Dark mode pinned to the bottom). It **collapses** to a 68px
icon rail (remembered in `localStorage`) and becomes a slide-in **drawer** behind a hamburger on icon rail (remembered in `localStorage`) and becomes a slide-in **drawer** behind a hamburger on
phones; mobile also keeps the frosted header + iOS tab bar phones; mobile also keeps the frosted header + iOS tab bar
- **Frosted ice glass, both themes**: light page `#eef5fd` (pale ice) with white glass panels; - **One palette pulled from the app icon, both themes**: light page `#eef3fa` (pale ice) /
dark page `#0a1120` with `rgba(255,255,255,.06)` glass. Every large surface shares one recipe — sidebar and cards frosted white / border `#dce4f1`; dark page `#121a2c` / sidebar `#0c1322` /
translucent tint, 24px `backdrop-blur` (16px on phones), a bright 1px rim, an inner top highlight surfaces `#1a2337` / border `rgba(255,255,255,.1)`. Surfaces are frosted glass — translucent with
and a blue-tinted shadow — over a fixed ice scene (navy/pale base, drifting glacier blobs, glossy `backdrop-blur`, a bright 1px edge and a light-from-above sheen — over a flat backdrop with a
ice orbs and a whisper of grain). single faint highlight.
Text tiers are documented AA ratios (see below), and the cards fill the row with Text tiers are documented AA ratios (see below), and the cards fill the row with
`repeat(auto-fill, minmax(280px, 1fr))` — no stranded cards, no dead space `repeat(auto-fill, minmax(280px, 1fr))` — no stranded cards, no dead space
- Deck cards: title → muted metadata → neutral tags (difficulty carries the only colour: - Deck cards: title → muted metadata → neutral tags (difficulty carries the only colour:
@ -728,11 +728,10 @@ Things I chose while building, with the reasoning:
have no `folderId`), deleting a folder re-homes its decks instead of deleting them, and folder have no `folderId`), deleting a folder re-homes its decks instead of deleting them, and folder
names travel in exports so an imported deck lands where it came from. 13 browser checks cover names travel in exports so an imported deck lands where it came from. 13 browser checks cover
create → freeze → move → rename → delete → reload → import. create → freeze → move → rename → delete → reload → import.
18. **The visual language is frosted ice glass.** Glacier cyan to ice blue (`#38bdf8` → `#0ea5e9`) 18. **The visual language is frosted ice, with restraint.** The icon's blue is the one accent
is the accent for focus rings, progress bars and selected states; the primary CTA is an icy colour driving buttons and highlights; the backdrop is flat with a single faint top highlight,
gradient (deep blue in light, white-to-`#bfe3ff` in dark). The background is a fixed ice scene; and surfaces — sidebar, cards, chrome, sheets — are translucent glass with a bright edge and a
the sidebar, cards, chrome, sheets and gate are translucent glass with a bright rim and a soft soft sheen. Colour appears in activity tints and semantic feedback only — pretty, but still
blue shadow. Colour appears in activity tints and semantic feedback only — pretty, but still
readable and quiet. readable and quiet.
19. **Custom CSS lives in `@layer components`, and glass uses Tailwind's backdrop utilities.** 19. **Custom CSS lives in `@layer components`, and glass uses Tailwind's backdrop utilities.**
Two build-level traps, both found by looking at real screenshots: unlayered rules beat Tailwind Two build-level traps, both found by looking at real screenshots: unlayered rules beat Tailwind
@ -742,24 +741,22 @@ Things I chose while building, with the reasoning:
20. **There is no demo/sample deck.** Every activity comes from the user's own notes; the empty 20. **There is no demo/sample deck.** Every activity comes from the user's own notes; the empty
state points at the create flow and Settings instead of shipping fake content. Browser tests seed state points at the create flow and Settings instead of shipping fake content. Browser tests seed
a fixture deck into `localStorage` rather than relying on app code. a fixture deck into `localStorage` rather than relying on app code.
21. **Dark mode is token-driven, and contrast is audited on glass.** Text colours come from 21. **Dark mode is token-driven, and contrast is audited.** Text colours come from
`--text` / `--text-muted` / `--text-subtle` (never hardcoded), and a `contrast.mjs` audit checks `--text` / `--text-muted` / `--text-subtle` (never hardcoded), and a `contrast.mjs` audit checks
every pairing at WCAG AA against the *effective* frosted background — currently **18/18 pairs every pairing at WCAG AA — currently **21/21 pairs pass** (e.g. dark secondary `#b7c3dc` on
pass** (e.g. dark secondary `#b3c4e0` on glass `#1c2535` = 10:1, white on the light CTA `#0e1424` = 10.4:1, white on the light blue CTA `#1d61ab` = 6.3:1). Difficulty colours were
mid-gradient `#1a5fd0` = 5.6:1). Difficulty colours were darkened until they cleared 4.5:1 on darkened until they cleared 4.5:1 on their pills.
their pills.
This was a real bug: an earlier edit silently failed, so `.dark` never overrode `--text`, leaving This was a real bug: an earlier edit silently failed, so `.dark` never overrode `--text`, leaving
near-black text on a dark page. near-black text on a dark page.
22. **The dashboard is one inset glass rail plus glass cards.** The sidebar is a single tall 22. **The dashboard follows the icon's navy system.** Frosted navy/white surfaces, hairline borders,
rounded glass panel inset from the viewport edges, so the ice scene shows around it; deck cards pill CTAs, 18px radii and restrained shadows; the sidebar, cards and chrome are translucent
are 24px-radius glass panels with a secondary Study CTA and quiet metadata. Headings are glass over the backdrop. Deck cards got real hierarchy: title → muted metadata → tags → summary
semibold with tighter tracking. Nested glass (chips, inputs) is more opaque but never adds a → secondary Study CTA, with secondary actions behind a quiet menu.
second blur layer. 23. **One fixed backdrop layer, deliberately almost flat.** The decorative wash used to be
23. **One fixed ice scene instead of per-page blobs.** The decorative background used to be
rendered inside the content column, so on wide screens it was clipped into a visible hard edge. rendered inside the content column, so on wide screens it was clipped into a visible hard edge.
It now lives in a single `position: fixed` layer (`.ice-scene`, `z-index: -1`, pointer-events It now lives in a single `position: fixed` layer (`.app-backdrop`, `z-index: -10`): a flat ice
none): base gradient, four drifting glacier blobs, five glossy ice orbs and an SVG-noise grain. or navy base plus one faint highlight at the top — just enough for the glass to catch, without
`prefers-reduced-motion` stops the drift. turning into a gradient soup. `body`'s colour matches it so mobile overscroll shows no seam.
24. **The UI was deliberately made quieter.** One accent colour (`accent-*`), a desaturated 24. **The UI was deliberately made quieter.** One accent colour (`accent-*`), a desaturated
`frost-*` scale, hairline borders and flat surfaces replaced the saturated gradients and the `frost-*` scale, hairline borders and flat surfaces replaced the saturated gradients and the
seven-colour activity badges. Semantic colours (green/amber/red) survive only where they carry seven-colour activity badges. Semantic colours (green/amber/red) survive only where they carry
@ -795,17 +792,9 @@ Things I chose while building, with the reasoning:
Desktop keeps the designed 12–14px field sizes. Desktop keeps the designed 12–14px field sizes.
31. **The app icon drives the palette.** The navy rounded-square artwork in 31. **The app icon drives the palette.** The navy rounded-square artwork in
`assets/app-icon-source.jpeg` is the source of truth: `frost-*` is navy-tinted, the accent is `assets/app-icon-source.jpeg` is the source of truth: `frost-*` is navy-tinted, the accent is
glacier cyan, the ice scene uses the artwork's blues, the primary CTA is an icy gradient, and the snowflake blue, the primary CTA is the icon blue (deep in light mode, bright in dark), and
every icon/splash/favicon is rendered from it by `scripts/make-icons.mjs`. The sidebar, header, key gate, empty state and install sheet all show every icon/splash/favicon is rendered from it by `scripts/make-icons.mjs`. The sidebar, header, key gate, empty state and install sheet all show
the real artwork instead of the old monochrome snowflake tile. the real artwork instead of the old monochrome snowflake tile.
32. **The ice scene was profiled, and the blur moved off the animated layer.** A mid-range phone
viewport with 4x CPU throttling showed the drifting blobs were the only expensive part: a
`filter: blur(100px)` on a huge element that is also being transformed forces a
re-rasterisation every frame (150ms vs 33ms per frame in a software renderer). The blobs are now
soft radial gradients with no filter — visually the same, 4-5x cheaper — phones get two blobs
instead of four and a 16px (instead of 24px) glass blur, and nested glass never blurs. Result:
60fps idle and ~17ms median frames while scrolling under 4x throttle.
14. **`gpt-oss:120b` does all content work — but it cannot read images** (`this model does not 14. **`gpt-oss:120b` does all content work — but it cannot read images** (`this model does not
support image input`), so `VISION_MODEL` has to stay a multimodal model (`gemma4:31b-cloud`). support image input`), so `VISION_MODEL` has to stay a multimodal model (`gemma4:31b-cloud`).
Text-only inputs — pasted text, PDFs with a text layer, DOCX/PPTX/ODT — never touch the vision Text-only inputs — pasted text, PDFs with a text layer, DOCX/PPTX/ODT — never touch the vision

View file

@ -43,7 +43,7 @@ export default function DeckPage() {
🧊 🧊
</p> </p>
<h1 className="text-xl font-extrabold text-frost-950 dark:text-frost-50">Deck not found</h1> <h1 className="text-xl font-extrabold text-frost-950 dark:text-frost-50">Deck not found</h1>
<p className="text-sm text-frost-600 dark:text-frost-300"> <p className="text-sm text-slate-600 dark:text-slate-300">
This deck isn&apos;t in this browser&apos;s library. It may have been deleted, or it was This deck isn&apos;t in this browser&apos;s library. It may have been deleted, or it was
created in a different browser profile. created in a different browser profile.
</p> </p>

View file

@ -17,17 +17,17 @@
--color-frost-900: #1d2740; --color-frost-900: #1d2740;
--color-frost-950: #0e1526; --color-frost-950: #0e1526;
/* Accent: glacier cyan to ice blue — focus rings, links, highlights. ---- */ /* Accent: the icon's snowflake blue. ------------------------------------ */
--color-accent-50: #ecf9ff; --color-accent-50: #eff7ff;
--color-accent-100: #d6f1ff; --color-accent-100: #dbecff;
--color-accent-200: #b0e5ff; --color-accent-200: #bcdcff;
--color-accent-300: #7dd4fc; --color-accent-300: #8fc6ff;
--color-accent-400: #38bdf8; --color-accent-400: #59a8f7;
--color-accent-500: #0ea5e9; --color-accent-500: #3f95ea;
--color-accent-600: #0284c7; --color-accent-600: #2478d2;
--color-accent-700: #0369a1; --color-accent-700: #1d61ab;
--color-accent-800: #075985; --color-accent-800: #1c518b;
--color-accent-900: #0c4a6e; --color-accent-900: #1b4473;
/* Aurora hues used only for the frosted backdrop wash. ----------------- */ /* Aurora hues used only for the frosted backdrop wash. ----------------- */
--color-aurora-mint: #9fe3d0; --color-aurora-mint: #9fe3d0;
@ -81,110 +81,93 @@
--------------------------------------------------------------------------- */ --------------------------------------------------------------------------- */
:root { :root {
color-scheme: light; color-scheme: light;
--page: #eef5fd; --page: #eef3fa; /* pale ice */
--sidebar: #ffffff; --sidebar: #ffffff;
--surface: #ffffff; --surface: #ffffff;
--surface-raised: #ffffff; --surface-raised: #ffffff;
--surface-strong: rgb(255 255 255 / 0.72); /* nested glass: inputs, chips */ --surface-strong: #f2f6fc;
--card-hover-bg: rgb(255 255 255 / 0.72); --card-hover-bg: #ffffff;
--surface-sunken: rgb(12 32 62 / 0.06); --surface-sunken: rgb(14 21 40 / 0.05);
--hairline: #d5e4f4; --hairline: #dce4f1;
--card-border: var(--glass-border); --card-border: rgb(255 255 255 / 0.85);
--sidebar-divider: rgb(255 255 255 / 0.75); --sidebar-divider: #dfe7f3;
--hairline-soft: rgb(12 32 62 / 0.08); --hairline-soft: rgb(14 21 40 / 0.07);
--glass-tint: rgb(255 255 255 / 0.55); --glass-tint: rgb(255 255 255 / 0.6);
--text: #0b1830; /* 16.6:1 on #eef5fd */ --glass-top: rgb(255 255 255 / 0.95);
--text-muted: #46586f; /* 6.6:1 on light glass */ --glass-edge: rgb(255 255 255 / 0.85);
--text-subtle: #566a84; /* 5.0:1 on light glass */ --glass-sheen: rgb(255 255 255 / 0.55);
--hover: rgb(12 32 62 / 0.05); --text: #0e1526; /* 16.2:1 on #eef3fa */
--focus: #0ea5e9; --text-muted: #4d5c78; /* 6.1:1 */
--text-subtle: #55647f; /* 5.4:1 */
/* Frosted ice glass — one shared recipe for every large surface. */ --hover: rgb(14 21 40 / 0.05);
--glass-bg: rgb(255 255 255 / 0.55); --sidebar-edge: 0 1px 0 var(--sidebar-divider), 6px 0 18px -12px rgb(14 21 40 / 0.18);
--glass-border: rgb(255 255 255 / 0.8);
--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 */
--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. */ /* API-key gate: cool near-white stage with soft blue glows, tinted input. */
--gate-card: rgb(255 255 255 / 0.62); --gate-base: #eef3fa;
--gate-callout-bg: rgb(255 255 255 / 0.55); --gate-glow-a: rgb(90 150 240 / 0.16);
--gate-callout-border: rgb(56 189 248 / 0.32); --gate-glow-b: rgb(140 150 240 / 0.12);
--field: rgb(255 255 255 / 0.66); --gate-card: #ffffff;
--field-border: rgb(255 255 255 / 0.9); --gate-card-border: #dde5f2;
--gate-callout-bg: #eef3fa;
--gate-callout-border: #c9d5e8;
--field: #eef2f9;
--field-border: #dbe3f0;
--shadow-gate: 0 28px 70px -36px rgb(14 21 40 / 0.3), 0 1px 2px rgb(14 21 40 / 0.05); --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: light variant (dark is the default, see .dark). */
--install-sheet: rgb(255 255 255 / 0.6); --install-sheet: #ffffff;
--install-card: rgb(255 255 255 / 0.7); --install-card: #f7f7f8;
--install-chip: rgb(255 255 255 / 0.55); --install-chip: #f2f2f2;
--install-border: rgb(255 255 255 / 0.8); --install-border: #e0e0e0;
--shadow-card: 0 1px 2px rgb(14 21 40 / 0.06), 0 1px 3px rgb(14 21 40 / 0.04); --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.05), 0 5px 14px -6px rgb(14 21 40 / 0.12); --card-shadow: 0 0 0 1px rgb(14 21 40 / 0.04), 0 1px 2px rgb(14 21 40 / 0.04), 0 12px 28px -14px rgb(14 21 40 / 0.2);
--shadow-card-hover: 0 24px 60px -24px rgb(30 64 175 / 0.45), 0 2px 6px rgb(30 64 175 / 0.12); --shadow-card-hover: 0 0 0 1px rgb(14 21 40 / 0.05), 0 16px 36px -14px rgb(14 21 40 / 0.24), 0 2px 6px rgb(14 21 40 / 0.07);
/* Backdrop: flat ice with a single faint highlight at the top. */
--bd-base: #eef3fa;
--bd-highlight: rgb(255 255 255 / 0.9);
} }
.dark { .dark {
color-scheme: dark; color-scheme: dark;
--page: #0a1120; --page: #121a2c;
--sidebar: #0b1524; --sidebar: #0c1322;
--surface: #16233a; --surface: #1a2337;
--surface-raised: #1d2c47; --surface-raised: #222c44;
--surface-strong: rgb(255 255 255 / 0.08); --surface-strong: #222c44;
--card-hover-bg: rgb(255 255 255 / 0.1); --card-hover-bg: #222c44;
--surface-sunken: rgb(255 255 255 / 0.07); --surface-sunken: rgb(255 255 255 / 0.07);
--hairline: rgb(255 255 255 / 0.12); --hairline: rgb(255 255 255 / 0.1);
--card-border: var(--glass-border); --card-border: rgb(255 255 255 / 0.1);
--sidebar-divider: rgb(255 255 255 / 0.12); --sidebar-divider: rgb(255 255 255 / 0.08);
--hairline-soft: rgb(255 255 255 / 0.08); --hairline-soft: rgb(255 255 255 / 0.07);
--glass-tint: rgb(255 255 255 / 0.06); --glass-tint: rgb(255 255 255 / 0.06);
--text: #e8f0ff; /* 16.3:1 on #0a1120 */ --glass-top: rgb(255 255 255 / 0.09);
--text-muted: #b3c4e0; /* 10.1:1 */ --glass-edge: rgb(255 255 255 / 0.1);
--text-subtle: #90a4c6; /* 6.9:1 */ --glass-sheen: rgb(255 255 255 / 0.05);
--text: #e9eefb; /* 14.8:1 on #121a2c */
--text-muted: #b7c3dc; /* 10.0:1 */
--text-subtle: #94a4c2; /* 7.0:1 */
--hover: rgb(255 255 255 / 0.07); --hover: rgb(255 255 255 / 0.07);
--focus: #38bdf8; --sidebar-edge: 0 1px 0 var(--hairline), 6px 0 16px -14px rgb(0 0 0 / 0.7);
/* 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-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. */ /* API-key gate: navy stage with the icon's blue glows, raised input. */
--gate-card: rgb(255 255 255 / 0.06); --gate-base: #0c1322;
--gate-glow-a: rgb(70 140 235 / 0.14);
--gate-glow-b: rgb(120 120 235 / 0.1);
--gate-card: #131a2c;
--gate-card-border: rgb(255 255 255 / 0.1);
--gate-callout-bg: rgb(255 255 255 / 0.06); --gate-callout-bg: rgb(255 255 255 / 0.06);
--gate-callout-border: rgb(56 189 248 / 0.3); --gate-callout-border: rgb(255 255 255 / 0.2);
--field: rgb(255 255 255 / 0.07); --field: #1b2438;
--field-border: rgb(255 255 255 / 0.14); --field-border: rgb(255 255 255 / 0.1);
--shadow-gate: 0 30px 80px -40px rgb(0 0 0 / 0.85), 0 1px 2px rgb(0 0 0 / 0.4); --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: layered dark surfaces (default theme for the sheet). */
--install-sheet: rgb(255 255 255 / 0.06); --install-sheet: #1c1c1e;
--install-card: rgb(255 255 255 / 0.07); --install-card: #2c2c2e;
--install-chip: rgb(255 255 255 / 0.1); --install-chip: #3a3a3c;
--install-border: rgb(255 255 255 / 0.14); --install-border: rgb(255 255 255 / 0.08);
--shadow-card: 0 1px 2px rgb(0 0 0 / 0.25); --shadow-card: 0 1px 2px rgb(0 0 0 / 0.25);
--card-shadow: var(--shadow-card); --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); --shadow-card-hover: 0 10px 28px -16px rgb(0 0 0 / 0.55), 0 1px 2px rgb(0 0 0 / 0.3);
} --bd-base: #101a2c;
--bd-highlight: rgb(120 160 220 / 0.12);
/* Ice scene tokens: the fixed layered background behind everything. */
:root {
--ice-base-top: #f4f9ff;
--ice-base-bottom: #e6f1fb;
--ice-blob-opacity: 0.18;
--ice-orb-opacity: 0.55;
--ice-grain-opacity: 0.05;
}
.dark {
--ice-base-top: #070b16;
--ice-base-bottom: #0c1426;
--ice-blob-opacity: 0.4;
--ice-orb-opacity: 0.8;
--ice-grain-opacity: 0.045;
} }
html, html,
@ -215,196 +198,102 @@ body {
background: transparent; background: transparent;
} }
/* The key gate rides on the shared ice scene (see .ice-scene). */ /* API-key gate stage: two big blurred blobs (top-right + bottom-left) over a
near-black (dark) or near-white (light) base. Real blurred elements read as
atmosphere; a flat radial gradient was too faint. Both blobs are decorative,
non-interactive and sit behind the card (z-index 0 vs the card's z-10). */
.gate-bg {
background: var(--gate-base);
}
.gate-bg::before,
.gate-bg::after {
content: "";
position: absolute;
z-index: 0;
width: 48rem;
height: 48rem;
border-radius: 9999px;
filter: blur(120px);
pointer-events: none;
}
.gate-bg::before {
top: -20rem;
right: -16rem;
background: var(--gate-glow-a);
}
.gate-bg::after {
bottom: -20rem;
left: -16rem;
background: var(--gate-glow-b);
}
/* The fixed ice scene: navy/pale base, drifting glacier blobs, glossy ice .app-backdrop {
orbs and a whisper of grain. Purely decorative. */
.ice-scene {
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: -1; z-index: -10;
overflow: hidden; overflow: hidden;
pointer-events: none; pointer-events: none;
background: linear-gradient(180deg, var(--ice-base-top) 0%, var(--ice-base-bottom) 100%); /* Flat ice with one faint highlight at the top — just enough for the
} frosted surfaces to have something to catch. */
background:
.ice-blob { radial-gradient(120vw 54vh at 50% -16%, var(--bd-highlight) 0%, transparent 72%),
position: absolute; var(--bd-base);
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, placed so the glass panels blur them. */
.ice-orb {
position: absolute;
border-radius: 50%;
opacity: var(--ice-orb-opacity);
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; top: 14%; left: 12%; }
.ice-orb-2 { width: 84px; height: 84px; top: 58%; left: 4%; }
.ice-orb-3 { width: 220px; height: 220px; bottom: -70px; right: 10%; }
.ice-orb-4 { width: 52px; height: 52px; top: 24%; right: 28%; }
.ice-orb-5 { width: 120px; height: 120px; bottom: 16%; left: 38%; }
/* 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; }
}
/* 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-3 { width: 150px; height: 150px; }
} }
@layer components { @layer components {
/* Frosted ice glass ------------------------------------------------------ /* Glass primitives ------------------------------------------------------ */
One shared recipe for the large surfaces only: translucent tint, 24px
blur, a bright rim, an inner top highlight and a blue-tinted shadow.
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 {
@apply backdrop-blur-xl backdrop-saturate-[1.4];
background: var(--glass-bg);
box-shadow: var(--glass-shadow), inset 0 1px 0 var(--glass-highlight);
}
.glass-panel { .glass-panel {
border: 1px solid var(--glass-border); @apply backdrop-blur-xl backdrop-saturate-150;
border-radius: 1.5rem; background:
linear-gradient(180deg, var(--glass-sheen) 0%, transparent 46%),
color-mix(in oklab, var(--surface) 66%, transparent);
border: 1px solid var(--glass-edge);
box-shadow: var(--shadow-card), inset 0 1px 0 var(--glass-top);
} }
/* Nested glass: slightly more opaque than its parent, no extra blur. */
.glass-chip { .glass-chip {
@apply backdrop-blur-md;
background: var(--surface-sunken); background: var(--surface-sunken);
border: 1px solid var(--glass-border); border: 1px solid transparent;
}
/* More opaque glass for the study question card, where text must stay crisp. */
.glass-solid {
background: var(--glass-solid-bg);
} }
.glass-chip:hover { .glass-chip:hover {
background: var(--hover); background: var(--hover);
} }
.glass { .glass {
border-bottom: 1px solid var(--glass-border); /* Tailwind emits both the prefixed and standard properties; hand-written
backdrop-filter gets minified down to -webkit- only, which paints nothing. */
@apply backdrop-blur-xl backdrop-saturate-150;
background: color-mix(in oklab, var(--surface) 62%, transparent);
border-bottom: 1px solid var(--hairline);
} }
/* Study-feed chrome (progress header + bottom pager): the same neutral /* Study-feed chrome (progress header + bottom pager): the same neutral
surface as the cards, with a neutral border and shadow. */ surface as the cards, with a neutral border and shadow. */
.study-chrome { .study-chrome {
border: 1px solid var(--glass-border); @apply backdrop-blur-xl backdrop-saturate-150;
background:
linear-gradient(180deg, var(--glass-sheen) 0%, transparent 46%),
color-mix(in oklab, var(--surface) 68%, transparent);
border: 1px solid var(--hairline);
box-shadow: var(--shadow-card);
}
.dark .study-chrome {
border-color: var(--hairline-soft);
} }
.card { .card {
border-radius: 1.5rem; /* 24px */ border-radius: 1.125rem; /* 18px */
border: 1px solid var(--glass-border); @apply backdrop-blur-xl backdrop-saturate-150;
} background:
linear-gradient(180deg, var(--glass-sheen) 0%, transparent 46%),
.gate-card { color-mix(in oklab, var(--surface) 62%, transparent);
border: 1px solid var(--glass-border); border: 1px solid var(--card-border);
border-radius: 1.75rem; /* 28px */ box-shadow: var(--card-shadow), inset 0 1px 0 var(--glass-top);
}
/* Blur is expensive on phones: halve it, and thin out the ice scene. */
@media (max-width: 1023px) {
.glass-panel,
.card,
.sidebar-surface,
.study-chrome,
.tabbar,
.glass,
.gate-card {
@apply backdrop-blur-lg;
}
}
/* No backdrop-filter support: fall back to a more opaque surface. */
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
.glass-panel,
.card,
.sidebar-surface,
.study-chrome,
.tabbar,
.glass,
.gate-card {
background: var(--glass-fallback);
} }
.dark .card,
.dark .glass-panel {
border-color: var(--glass-edge);
} }
/* Opt-in hover lift for interactive cards (deck cards). Pointer devices only, /* Opt-in hover lift for interactive cards (deck cards). Pointer devices only,
@ -415,8 +304,10 @@ body {
@media (hover: hover) and (pointer: fine) { @media (hover: hover) and (pointer: fine) {
.card-hover:hover { .card-hover:hover {
background: var(--card-hover-bg); background: var(--card-hover-bg);
border-color: var(--nav-active-border); border-color: color-mix(in oklab, var(--hairline) 45%, var(--text) 20%);
box-shadow: var(--shadow-card-hover); box-shadow: var(--shadow-card-hover);
}
:root:not(.dark) .card-hover:hover {
transform: translateY(-2px); transform: translateY(-2px);
} }
} }
@ -428,7 +319,7 @@ body {
.list-group { .list-group {
border-radius: 1rem; border-radius: 1rem;
@apply backdrop-blur-lg backdrop-saturate-150; @apply backdrop-blur-lg backdrop-saturate-150;
background: color-mix(in oklab, var(--surface) 82%, transparent); background: color-mix(in oklab, var(--surface) 66%, transparent);
border: 1px solid var(--hairline); border: 1px solid var(--hairline);
overflow: hidden; overflow: hidden;
} }
@ -460,19 +351,14 @@ body {
} }
.segmented > button[aria-pressed="true"], .segmented > button[aria-pressed="true"],
.segmented > button[aria-checked="true"] { .segmented > button[aria-checked="true"] {
background-image: linear-gradient(135deg, #38bdf8 0%, #60a5fa 100%); background: var(--surface-strong);
color: #06111f; color: var(--text);
box-shadow: 0 6px 16px -8px rgb(56 189 248 / 0.7); box-shadow: 0 1px 2px rgb(13 13 13 / 0.12);
} }
.dark .segmented > button[aria-pressed="true"], .dark .segmented > button[aria-pressed="true"],
.dark .segmented > button[aria-checked="true"] { .dark .segmented > button[aria-checked="true"] {
background-image: linear-gradient(135deg, #38bdf8 0%, #60a5fa 100%); background: var(--surface-raised);
color: #06111f; color: #fff;
}
/* Ice gradient for progress bars and selected states. */
.grad-ice {
background-image: linear-gradient(90deg, #38bdf8 0%, #60a5fa 100%);
} }
/* Sheet grabber --------------------------------------------------------- */ /* Sheet grabber --------------------------------------------------------- */
@ -485,7 +371,9 @@ body {
} }
.tabbar { .tabbar {
border-top: 1px solid var(--glass-border); @apply backdrop-blur-xl;
background: color-mix(in oklab, var(--page) 70%, transparent);
border-top: 1px solid var(--hairline);
} }
/* Soft tinted chip for activity types (a hint of colour, not a rainbow) -- */ /* Soft tinted chip for activity types (a hint of colour, not a rainbow) -- */
@ -500,11 +388,14 @@ body {
.dark .tint-peach { background: linear-gradient(140deg, rgb(230 160 118 / 0.26), rgb(230 160 118 / 0.1)); color: #fadfc9 } .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 } .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: flat frosted glass with a real edge. */
/* Sidebar surface: one tall inset glass panel. */
.sidebar-surface { .sidebar-surface {
border: 1px solid var(--glass-border); @apply backdrop-blur-xl backdrop-saturate-150;
border-radius: 1.5rem; background:
linear-gradient(180deg, var(--glass-sheen) 0%, transparent 30%),
color-mix(in oklab, var(--sidebar) 64%, transparent);
border-right: 1px solid var(--sidebar-divider);
box-shadow: var(--sidebar-edge);
} }
/* Sidebar nav rows */ /* Sidebar nav rows */
@ -526,9 +417,8 @@ body {
} }
.nav-row[aria-current="page"], .nav-row[aria-current="page"],
.nav-row[aria-pressed="true"] { .nav-row[aria-pressed="true"] {
background: var(--nav-active-bg); background: var(--surface-sunken);
color: var(--text); color: var(--text);
box-shadow: inset 0 0 0 1px var(--nav-active-border), inset 0 1px 0 var(--glass-highlight);
} }
/* Shared CTA language: the icon's blue, with a deeper fill in light mode. */ /* Shared CTA language: the icon's blue, with a deeper fill in light mode. */
@ -537,44 +427,35 @@ body {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 0.5rem; gap: 0.5rem;
border-radius: 14px; border-radius: 999px;
background-image: linear-gradient(135deg, #1d4ed8 0%, #1a5fd0 55%, #0ea5e9 100%); background: #1d61ab;
color: #ffffff; color: #ffffff;
font-weight: 500; font-weight: 500;
box-shadow: 0 10px 24px -12px rgb(14 165 233 / 0.7); box-shadow: 0 1px 2px rgb(14 21 40 / 0.22);
transition: box-shadow 180ms ease, filter 180ms ease, transform 160ms ease; transition: background-color 160ms ease, transform 160ms ease, box-shadow 160ms ease;
}
.btn-primary:hover {
filter: brightness(1.07);
box-shadow: 0 14px 32px -12px rgb(56 189 248 / 0.85);
} }
.btn-primary:hover { background: #2478d2; }
.btn-primary:active { transform: scale(0.985); } .btn-primary:active { transform: scale(0.985); }
.dark .btn-primary { .dark .btn-primary {
background-image: linear-gradient(135deg, #ffffff 0%, #bfe3ff 100%); background: #59a8f7;
color: #0b1220; color: #081020;
box-shadow: 0 10px 26px -14px rgb(56 189 248 / 0.6); box-shadow: none;
}
.dark .btn-primary:hover {
filter: brightness(1.03);
box-shadow: 0 14px 34px -14px rgb(56 189 248 / 0.95);
} }
.dark .btn-primary:hover { background: #7bbaff; }
.btn-secondary { .btn-secondary {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 0.5rem; gap: 0.5rem;
border-radius: 14px; border-radius: 999px;
background: var(--surface-strong); background: transparent;
color: var(--text); color: var(--text);
border: 1px solid var(--glass-border); border: 1px solid var(--hairline);
font-weight: 500; font-weight: 500;
transition: background-color 160ms ease, border-color 160ms ease, transform 160ms ease; transition: background-color 160ms ease, border-color 160ms ease, transform 160ms ease;
} }
.btn-secondary:hover { .btn-secondary:hover { background: var(--hover); border-color: color-mix(in oklab, var(--hairline) 70%, var(--text) 12%); }
background: var(--hover);
border-color: var(--nav-active-border);
}
.btn-secondary:active { transform: scale(0.985); } .btn-secondary:active { transform: scale(0.985); }
.btn-ghost { .btn-ghost {
@ -582,7 +463,7 @@ body {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 0.5rem; gap: 0.5rem;
border-radius: 14px; border-radius: 0.75rem;
background: transparent; background: transparent;
color: var(--text-muted); color: var(--text-muted);
font-weight: 500; font-weight: 500;

View file

@ -1,6 +1,5 @@
import type { Metadata, Viewport } from "next"; import type { Metadata, Viewport } from "next";
import { IceBackdrop } from "@/components/IceBackdrop";
import { InstallSheet } from "@/components/InstallSheet"; import { InstallSheet } from "@/components/InstallSheet";
import { ServiceWorkerRegistrar } from "@/components/ServiceWorkerRegistrar"; import { ServiceWorkerRegistrar } from "@/components/ServiceWorkerRegistrar";
import "./globals.css"; import "./globals.css";
@ -75,7 +74,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<script dangerouslySetInnerHTML={{ __html: installScript }} /> <script dangerouslySetInnerHTML={{ __html: installScript }} />
</head> </head>
<body className="min-h-full antialiased"> <body className="min-h-full antialiased">
<IceBackdrop /> <div className="app-backdrop" aria-hidden />
{children} {children}
<InstallSheet /> <InstallSheet />
<ServiceWorkerRegistrar /> <ServiceWorkerRegistrar />

View file

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

View file

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

View file

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

View file

@ -108,7 +108,7 @@ export function LibraryScreen() {
<section className="mb-6 space-y-5"> <section className="mb-6 space-y-5">
<div className="flex flex-wrap items-end justify-between gap-4"> <div className="flex flex-wrap items-end justify-between gap-4">
<div> <div>
<h1 className="text-[24px] font-semibold leading-tight tracking-[-0.02em] text-[var(--text)] sm:text-[26px]"> <h1 className="text-[24px] font-medium leading-tight tracking-[-0.01em] text-[var(--text)] sm:text-[26px]">
Your icebox Your icebox
</h1> </h1>
<p className="mt-1.5 text-[13.5px] text-[var(--text-subtle)]"> <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="sheet-grabber sm:hidden" aria-hidden />
<div className="mb-4 flex items-start justify-between gap-4"> <div className="mb-4 flex items-start justify-between gap-4">
<div> <div>
<h2 id="settings-title" className="text-lg font-semibold tracking-[-0.02em] text-frost-950 dark:text-frost-50"> <h2 id="settings-title" className="text-lg font-semibold text-frost-950 dark:text-frost-50">
Settings Settings
</h2> </h2>
<p className="text-sm text-frost-600 dark:text-frost-300"> <p className="text-sm text-slate-600 dark:text-slate-300">
Models come from <code className="rounded bg-frost-100 px-1 dark:bg-white/10">.env.local</code> on 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. the server; your API key stays in this browser.
</p> </p>
@ -178,7 +178,7 @@ export function SettingsDialog({ open, onClose }: { open: boolean; onClose: () =
) : null} ) : null}
{loading && !health ? ( {loading && !health ? (
<div className="flex items-center gap-3 py-6 text-sm text-frost-600 dark:text-frost-300"> <div className="flex items-center gap-3 py-6 text-sm text-slate-600 dark:text-slate-300">
<Spinner className="size-5" /> Checking Ollama… <Spinner className="size-5" /> Checking Ollama…
</div> </div>
) : null} ) : null}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -96,7 +96,7 @@ export function ShortAnswer({ activity, active, language, onAnswered, onNext, is
disabled={state.status === "grading" || state.status === "graded"} disabled={state.status === "grading" || state.status === "graded"}
rows={3} rows={3}
placeholder="Type your answer in your own words…" placeholder="Type your answer in your own words…"
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" 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"
/> />
{state.status === "idle" ? ( {state.status === "idle" ? (

View file

@ -67,7 +67,7 @@ export function PromptCard({
className?: string; className?: string;
}) { }) {
return ( return (
<div className={cn("glass-panel glass-solid rounded-[1.5rem] px-5 py-6 text-balance sm:px-6", className)}>{children}</div> <div className={cn("glass-panel rounded-[1.35rem] px-5 py-6 text-balance sm:px-6", className)}>{children}</div>
); );
} }
@ -116,7 +116,7 @@ export function OptionTile({
)} )}
> >
{shortcut ? ( {shortcut ? (
<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"> <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">
{shortcut} {shortcut}
</span> </span>
) : null} ) : null}

View file

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