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
57
README.md
57
README.md
|
|
@ -112,10 +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
|
||||
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
|
||||
- **One navy palette pulled from the app icon, both themes**: light page `#f4f7fd` / sidebar
|
||||
`#e9eff9` / border `#dce4f1`; dark page `#0e1424` / sidebar `#0a0f1c` / surfaces `#171f33` /
|
||||
border `rgba(255,255,255,.12)`. Surfaces are frosted glass (translucent + `backdrop-blur`) over
|
||||
the icon's navy backdrop.
|
||||
- **Frosted ice glass, both themes**: light page `#eef5fd` (pale ice) with white glass panels;
|
||||
dark page `#0a1120` with `rgba(255,255,255,.06)` glass. Every large surface shares one recipe —
|
||||
translucent tint, 24px `backdrop-blur` (16px on phones), a bright 1px rim, an inner top highlight
|
||||
and a blue-tinted shadow — over a fixed ice scene (navy/pale base, drifting glacier blobs, glossy
|
||||
ice orbs and a whisper of grain).
|
||||
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
|
||||
- Deck cards: title → muted metadata → neutral tags (difficulty carries the only colour:
|
||||
|
|
@ -727,10 +728,12 @@ Things I chose while building, with the reasoning:
|
|||
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
|
||||
create → freeze → move → rename → delete → reload → import.
|
||||
18. **The visual language is glassmorphism, with restraint.** The icon's blue is the one accent
|
||||
colour driving buttons and highlights; the backdrop is a navy wash; surfaces — sidebar, cards,
|
||||
chrome, sheets — are frosted and translucent. Colour appears in activity tints and semantic
|
||||
feedback only — pretty, but still readable and quiet.
|
||||
18. **The visual language is frosted ice glass.** Glacier cyan to ice blue (`#38bdf8` → `#0ea5e9`)
|
||||
is the accent for focus rings, progress bars and selected states; the primary CTA is an icy
|
||||
gradient (deep blue in light, white-to-`#bfe3ff` in dark). The background is a fixed ice scene;
|
||||
the sidebar, cards, chrome, sheets and gate are translucent glass with a bright rim and a soft
|
||||
blue shadow. Colour appears in activity tints and semantic feedback only — pretty, but still
|
||||
readable and quiet.
|
||||
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
|
||||
utilities (which silently broke `fixed` on the feed), and a hand-written `backdrop-filter` gets
|
||||
|
|
@ -739,21 +742,24 @@ 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
|
||||
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.
|
||||
21. **Dark mode is token-driven, and contrast is audited.** Text colours come from
|
||||
21. **Dark mode is token-driven, and contrast is audited on glass.** Text colours come from
|
||||
`--text` / `--text-muted` / `--text-subtle` (never hardcoded), and a `contrast.mjs` audit checks
|
||||
every pairing at WCAG AA — currently **21/21 pairs pass** (e.g. dark secondary `#b7c3dc` on
|
||||
`#0e1424` = 10.4:1, white on the light blue CTA `#1d61ab` = 6.3:1). Difficulty colours were
|
||||
darkened until they cleared 4.5:1 on their pills.
|
||||
every pairing at WCAG AA against the *effective* frosted background — currently **18/18 pairs
|
||||
pass** (e.g. dark secondary `#b3c4e0` on glass `#1c2535` = 10:1, white on the light CTA
|
||||
mid-gradient `#1a5fd0` = 5.6:1). Difficulty colours were darkened until they cleared 4.5:1 on
|
||||
their pills.
|
||||
This was a real bug: an earlier edit silently failed, so `.dark` never overrode `--text`, leaving
|
||||
near-black text on a dark page.
|
||||
22. **The dashboard follows the icon's navy system.** Frosted navy/white surfaces, hairline borders,
|
||||
pill CTAs, 18px radii and restrained shadows; the sidebar, cards and chrome are translucent
|
||||
glass over the backdrop. Deck cards got real hierarchy: title → muted metadata → tags → summary
|
||||
→ secondary Study CTA, with secondary actions behind a quiet menu.
|
||||
23. **One fixed backdrop layer instead of per-page blobs.** The decorative wash used to be
|
||||
22. **The dashboard is one inset glass rail plus glass cards.** The sidebar is a single tall
|
||||
rounded glass panel inset from the viewport edges, so the ice scene shows around it; deck cards
|
||||
are 24px-radius glass panels with a secondary Study CTA and quiet metadata. Headings are
|
||||
semibold with tighter tracking. Nested glass (chips, inputs) is more opaque but never adds a
|
||||
second blur layer.
|
||||
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.
|
||||
It now lives in a single `position: fixed` layer (`.app-backdrop`, `z-index: -10`) in the
|
||||
icon's navy and blue, and `body`'s colour matches its bottom so mobile overscroll shows no seam.
|
||||
It now lives in a single `position: fixed` layer (`.ice-scene`, `z-index: -1`, pointer-events
|
||||
none): base gradient, four drifting glacier blobs, five glossy ice orbs and an SVG-noise grain.
|
||||
`prefers-reduced-motion` stops the drift.
|
||||
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
|
||||
seven-colour activity badges. Semantic colours (green/amber/red) survive only where they carry
|
||||
|
|
@ -789,10 +795,17 @@ Things I chose while building, with the reasoning:
|
|||
Desktop keeps the designed 12–14px field sizes.
|
||||
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
|
||||
the snowflake blue, the backdrop is the same navy wash, 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
|
||||
glacier cyan, the ice scene uses the artwork's blues, the primary CTA is an icy gradient, 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
|
||||
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
|
||||
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
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ export default function DeckPage() {
|
|||
🧊
|
||||
</p>
|
||||
<h1 className="text-xl font-extrabold text-frost-950 dark:text-frost-50">Deck not found</h1>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-300">
|
||||
<p className="text-sm text-frost-600 dark:text-frost-300">
|
||||
This deck isn't in this browser's library. It may have been deleted, or it was
|
||||
created in a different browser profile.
|
||||
</p>
|
||||
|
|
|
|||
486
app/globals.css
486
app/globals.css
|
|
@ -17,17 +17,17 @@
|
|||
--color-frost-900: #1d2740;
|
||||
--color-frost-950: #0e1526;
|
||||
|
||||
/* Accent: the icon's snowflake blue. ------------------------------------ */
|
||||
--color-accent-50: #eff7ff;
|
||||
--color-accent-100: #dbecff;
|
||||
--color-accent-200: #bcdcff;
|
||||
--color-accent-300: #8fc6ff;
|
||||
--color-accent-400: #59a8f7;
|
||||
--color-accent-500: #3f95ea;
|
||||
--color-accent-600: #2478d2;
|
||||
--color-accent-700: #1d61ab;
|
||||
--color-accent-800: #1c518b;
|
||||
--color-accent-900: #1b4473;
|
||||
/* 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;
|
||||
|
|
@ -81,104 +81,110 @@
|
|||
--------------------------------------------------------------------------- */
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--page: #f4f7fd;
|
||||
--sidebar: #e9eff9;
|
||||
--page: #eef5fd;
|
||||
--sidebar: #ffffff;
|
||||
--surface: #ffffff;
|
||||
--surface-raised: #ffffff;
|
||||
--surface-strong: #edf2fa;
|
||||
--card-hover-bg: #f7faff;
|
||||
--surface-sunken: rgb(14 21 40 / 0.05);
|
||||
--hairline: #dce4f1;
|
||||
--card-border: #d7e0ef;
|
||||
--sidebar-divider: #d7e0ef;
|
||||
--hairline-soft: rgb(14 21 40 / 0.07);
|
||||
--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: #0e1526; /* 16.9:1 on #f4f7fd */
|
||||
--text-muted: #4d5c78; /* 6.3:1 */
|
||||
--text-subtle: #5a6a86; /* 5.1:1 */
|
||||
--hover: rgb(14 21 40 / 0.05);
|
||||
--sidebar-top: #eef3fb;
|
||||
--sidebar-bottom: #e5edf9;
|
||||
--sidebar-edge: 0 1px 0 var(--sidebar-divider), 6px 0 18px -12px rgb(14 21 40 / 0.35);
|
||||
--text: #0b1830; /* 16.6:1 on #eef5fd */
|
||||
--text-muted: #46586f; /* 6.6:1 on light glass */
|
||||
--text-subtle: #566a84; /* 5.0: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);
|
||||
--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. */
|
||||
--gate-base: #eef3fb;
|
||||
--gate-glow-a: rgb(90 150 240 / 0.25);
|
||||
--gate-glow-b: rgb(140 150 240 / 0.2);
|
||||
--gate-card: #ffffff;
|
||||
--gate-card-border: #dde5f2;
|
||||
--gate-callout-bg: #eef3fa;
|
||||
--gate-callout-border: #c9d5e8;
|
||||
--field: #eef2f9;
|
||||
--field-border: #dbe3f0;
|
||||
--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: #ffffff;
|
||||
--install-card: #f7f7f8;
|
||||
--install-chip: #f2f2f2;
|
||||
--install-border: #e0e0e0;
|
||||
--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.05), 0 5px 14px -6px rgb(14 21 40 / 0.12);
|
||||
--shadow-card-hover: 0 12px 30px -12px rgb(14 21 40 / 0.2), 0 2px 6px rgb(14 21 40 / 0.07);
|
||||
--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: #0e1424;
|
||||
--sidebar: #0a0f1c;
|
||||
--surface: #171f33;
|
||||
--surface-raised: #1e2740;
|
||||
--surface-strong: #202a42;
|
||||
--card-hover-bg: #1e2740;
|
||||
--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: rgb(255 255 255 / 0.12);
|
||||
--sidebar-divider: rgb(255 255 255 / 0.1);
|
||||
--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: #e9eefb; /* 15.4:1 on #0e1424 */
|
||||
--text-muted: #b7c3dc; /* 10.4:1 */
|
||||
--text-subtle: #94a4c2; /* 7.3:1 */
|
||||
--text: #e8f0ff; /* 16.3:1 on #0a1120 */
|
||||
--text-muted: #b3c4e0; /* 10.1:1 */
|
||||
--text-subtle: #90a4c6; /* 6.9:1 */
|
||||
--hover: rgb(255 255 255 / 0.07);
|
||||
--sidebar-top: #0b1120;
|
||||
--sidebar-bottom: #080d18;
|
||||
--sidebar-edge: 0 1px 0 var(--hairline), 6px 0 16px -14px rgb(0 0 0 / 0.75);
|
||||
--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-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-base: #0a0f1c;
|
||||
--gate-glow-a: rgb(70 140 235 / 0.2);
|
||||
--gate-glow-b: rgb(120 120 235 / 0.14);
|
||||
--gate-card: #131a2c;
|
||||
--gate-card-border: rgb(255 255 255 / 0.1);
|
||||
--gate-card: rgb(255 255 255 / 0.06);
|
||||
--gate-callout-bg: rgb(255 255 255 / 0.06);
|
||||
--gate-callout-border: rgb(255 255 255 / 0.2);
|
||||
--field: #1b2438;
|
||||
--field-border: rgb(255 255 255 / 0.1);
|
||||
--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: #1c1c1e;
|
||||
--install-card: #2c2c2e;
|
||||
--install-chip: #3a3a3c;
|
||||
--install-border: rgb(255 255 255 / 0.08);
|
||||
--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 10px 28px -16px rgb(0 0 0 / 0.55), 0 1px 2px rgb(0 0 0 / 0.3);
|
||||
--shadow-card-hover: 0 24px 60px -24px rgb(30 64 175 / 0.55), 0 2px 8px rgb(0 0 0 / 0.3);
|
||||
}
|
||||
|
||||
/* Backdrop: the icon's navy base and blue wash, seen through frosted glass. */
|
||||
/* Ice scene tokens: the fixed layered background behind everything. */
|
||||
:root {
|
||||
--bd-sky: rgb(110 175 245 / 0.2);
|
||||
--bd-lilac: rgb(145 145 240 / 0.14);
|
||||
--bd-mint: rgb(110 210 230 / 0.12);
|
||||
--bd-top: #f8fafe;
|
||||
--bd-mid: #f2f6fd;
|
||||
--bd-bottom: #ecf1fb;
|
||||
--ice-base-top: #f4f9ff;
|
||||
--ice-base-bottom: #e6f1fb;
|
||||
--ice-blob-opacity: 0.18;
|
||||
--ice-orb-opacity: 0.55;
|
||||
--ice-grain-opacity: 0.05;
|
||||
}
|
||||
.dark {
|
||||
--bd-sky: rgb(60 130 230 / 0.14);
|
||||
--bd-lilac: rgb(110 115 235 / 0.1);
|
||||
--bd-mint: rgb(70 180 215 / 0.08);
|
||||
--bd-top: #111a2e;
|
||||
--bd-mid: #0e1424;
|
||||
--bd-bottom: #0a101d;
|
||||
--ice-base-top: #070b16;
|
||||
--ice-base-bottom: #0c1426;
|
||||
--ice-blob-opacity: 0.4;
|
||||
--ice-orb-opacity: 0.8;
|
||||
--ice-grain-opacity: 0.045;
|
||||
}
|
||||
|
||||
html,
|
||||
|
|
@ -209,126 +215,196 @@ body {
|
|||
background: transparent;
|
||||
}
|
||||
|
||||
/* 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 key gate rides on the shared ice scene (see .ice-scene). */
|
||||
|
||||
.app-backdrop {
|
||||
/* 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: -10;
|
||||
z-index: -1;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
background:
|
||||
radial-gradient(135vw 110vh at 4% -30%, var(--bd-sky) 0%, transparent 72%),
|
||||
radial-gradient(125vw 105vh at 104% -18%, var(--bd-lilac) 0%, transparent 70%),
|
||||
radial-gradient(135vw 105vh at 38% 128%, var(--bd-mint) 0%, transparent 72%),
|
||||
linear-gradient(180deg, var(--bd-top) 0%, var(--bd-mid) 50%, var(--bd-bottom) 100%);
|
||||
background: linear-gradient(180deg, var(--ice-base-top) 0%, var(--ice-base-bottom) 100%);
|
||||
}
|
||||
|
||||
/* Two slow, very soft blobs for a little life behind the glass. */
|
||||
.app-backdrop::before,
|
||||
.app-backdrop::after {
|
||||
content: "";
|
||||
.ice-blob {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(110px);
|
||||
opacity: 0.22;
|
||||
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. */
|
||||
}
|
||||
.app-backdrop::before {
|
||||
width: 52vw;
|
||||
height: 52vw;
|
||||
min-width: 24rem;
|
||||
min-height: 24rem;
|
||||
top: -26vh;
|
||||
left: -18vw;
|
||||
background: radial-gradient(circle, var(--bd-sky), transparent 66%);
|
||||
animation: float 16s ease-in-out infinite;
|
||||
.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;
|
||||
}
|
||||
.app-backdrop::after {
|
||||
width: 46vw;
|
||||
height: 46vw;
|
||||
min-width: 22rem;
|
||||
min-height: 22rem;
|
||||
bottom: -28vh;
|
||||
right: -14vw;
|
||||
background: radial-gradient(circle, var(--bd-mint), transparent 66%);
|
||||
animation: float 20s ease-in-out infinite reverse;
|
||||
.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;
|
||||
}
|
||||
.dark .app-backdrop::before,
|
||||
.dark .app-backdrop::after {
|
||||
opacity: 0.3;
|
||||
.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 {
|
||||
/* Glass primitives ------------------------------------------------------ */
|
||||
.glass-panel {
|
||||
@apply backdrop-blur-xl backdrop-saturate-150;
|
||||
background: color-mix(in oklab, var(--surface) 82%, transparent);
|
||||
border: 1px solid var(--hairline);
|
||||
box-shadow: var(--shadow-card);
|
||||
/* Frosted ice glass ------------------------------------------------------
|
||||
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 {
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 1.5rem;
|
||||
}
|
||||
|
||||
/* Nested glass: slightly more opaque than its parent, no extra blur. */
|
||||
.glass-chip {
|
||||
@apply backdrop-blur-md;
|
||||
background: var(--surface-sunken);
|
||||
border: 1px solid transparent;
|
||||
border: 1px solid var(--glass-border);
|
||||
}
|
||||
|
||||
/* More opaque glass for the study question card, where text must stay crisp. */
|
||||
.glass-solid {
|
||||
background: var(--glass-solid-bg);
|
||||
}
|
||||
.glass-chip:hover {
|
||||
background: var(--hover);
|
||||
}
|
||||
|
||||
.glass {
|
||||
/* 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) 70%, transparent);
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
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 {
|
||||
@apply backdrop-blur-xl backdrop-saturate-150;
|
||||
background: color-mix(in oklab, var(--surface) 88%, transparent);
|
||||
border: 1px solid var(--hairline);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
.dark .study-chrome {
|
||||
border-color: var(--hairline-soft);
|
||||
border: 1px solid var(--glass-border);
|
||||
}
|
||||
|
||||
.card {
|
||||
border-radius: 1.125rem; /* 18px */
|
||||
@apply backdrop-blur-xl backdrop-saturate-150;
|
||||
background: color-mix(in oklab, var(--surface) 78%, transparent);
|
||||
border: 1px solid var(--card-border);
|
||||
box-shadow: var(--card-shadow);
|
||||
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 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);
|
||||
}
|
||||
}
|
||||
|
||||
/* Opt-in hover lift for interactive cards (deck cards). Pointer devices only,
|
||||
|
|
@ -339,10 +415,8 @@ body {
|
|||
@media (hover: hover) and (pointer: fine) {
|
||||
.card-hover:hover {
|
||||
background: var(--card-hover-bg);
|
||||
border-color: color-mix(in oklab, var(--hairline) 45%, var(--text) 20%);
|
||||
border-color: var(--nav-active-border);
|
||||
box-shadow: var(--shadow-card-hover);
|
||||
}
|
||||
:root:not(.dark) .card-hover:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
}
|
||||
|
|
@ -386,14 +460,19 @@ body {
|
|||
}
|
||||
.segmented > button[aria-pressed="true"],
|
||||
.segmented > button[aria-checked="true"] {
|
||||
background: var(--surface-strong);
|
||||
color: var(--text);
|
||||
box-shadow: 0 1px 2px rgb(13 13 13 / 0.12);
|
||||
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: var(--surface-raised);
|
||||
color: #fff;
|
||||
background-image: linear-gradient(135deg, #38bdf8 0%, #60a5fa 100%);
|
||||
color: #06111f;
|
||||
}
|
||||
|
||||
/* Ice gradient for progress bars and selected states. */
|
||||
.grad-ice {
|
||||
background-image: linear-gradient(90deg, #38bdf8 0%, #60a5fa 100%);
|
||||
}
|
||||
|
||||
/* Sheet grabber --------------------------------------------------------- */
|
||||
|
|
@ -406,9 +485,7 @@ body {
|
|||
}
|
||||
|
||||
.tabbar {
|
||||
@apply backdrop-blur-xl;
|
||||
background: color-mix(in oklab, var(--page) 88%, transparent);
|
||||
border-top: 1px solid var(--hairline);
|
||||
border-top: 1px solid var(--glass-border);
|
||||
}
|
||||
|
||||
/* Soft tinted chip for activity types (a hint of colour, not a rainbow) -- */
|
||||
|
|
@ -424,15 +501,10 @@ body {
|
|||
.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 {
|
||||
@apply backdrop-blur-xl backdrop-saturate-150;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
color-mix(in oklab, var(--sidebar-top) 84%, transparent),
|
||||
color-mix(in oklab, var(--sidebar-bottom) 84%, transparent)
|
||||
);
|
||||
border-right: 1px solid var(--sidebar-divider);
|
||||
box-shadow: var(--sidebar-edge);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 1.5rem;
|
||||
}
|
||||
|
||||
/* Sidebar nav rows */
|
||||
|
|
@ -454,8 +526,9 @@ body {
|
|||
}
|
||||
.nav-row[aria-current="page"],
|
||||
.nav-row[aria-pressed="true"] {
|
||||
background: var(--surface-sunken);
|
||||
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: the icon's blue, with a deeper fill in light mode. */
|
||||
|
|
@ -464,35 +537,44 @@ body {
|
|||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
border-radius: 999px;
|
||||
background: #1d61ab;
|
||||
border-radius: 14px;
|
||||
background-image: linear-gradient(135deg, #1d4ed8 0%, #1a5fd0 55%, #0ea5e9 100%);
|
||||
color: #ffffff;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 1px 2px rgb(14 21 40 / 0.22);
|
||||
transition: background-color 160ms ease, transform 160ms ease, box-shadow 160ms ease;
|
||||
box-shadow: 0 10px 24px -12px rgb(14 165 233 / 0.7);
|
||||
transition: box-shadow 180ms ease, filter 180ms ease, transform 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); }
|
||||
.dark .btn-primary {
|
||||
background: #59a8f7;
|
||||
color: #081020;
|
||||
box-shadow: none;
|
||||
background-image: linear-gradient(135deg, #ffffff 0%, #bfe3ff 100%);
|
||||
color: #0b1220;
|
||||
box-shadow: 0 10px 26px -14px rgb(56 189 248 / 0.6);
|
||||
}
|
||||
.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 {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
border-radius: 14px;
|
||||
background: var(--surface-strong);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--hairline);
|
||||
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: color-mix(in oklab, var(--hairline) 70%, var(--text) 12%); }
|
||||
.btn-secondary:hover {
|
||||
background: var(--hover);
|
||||
border-color: var(--nav-active-border);
|
||||
}
|
||||
.btn-secondary:active { transform: scale(0.985); }
|
||||
|
||||
.btn-ghost {
|
||||
|
|
@ -500,7 +582,7 @@ body {
|
|||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
border-radius: 0.75rem;
|
||||
border-radius: 14px;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { Metadata, Viewport } from "next";
|
||||
|
||||
import { IceBackdrop } from "@/components/IceBackdrop";
|
||||
import { InstallSheet } from "@/components/InstallSheet";
|
||||
import { ServiceWorkerRegistrar } from "@/components/ServiceWorkerRegistrar";
|
||||
import "./globals.css";
|
||||
|
|
@ -74,7 +75,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
|||
<script dangerouslySetInnerHTML={{ __html: installScript }} />
|
||||
</head>
|
||||
<body className="min-h-full antialiased">
|
||||
<div className="app-backdrop" aria-hidden />
|
||||
<IceBackdrop />
|
||||
{children}
|
||||
<InstallSheet />
|
||||
<ServiceWorkerRegistrar />
|
||||
|
|
|
|||
|
|
@ -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