diff --git a/README.md b/README.md index 864f81d..c9f9d2a 100644 --- a/README.md +++ b/README.md @@ -1411,6 +1411,43 @@ Things I chose while building, with the reasoning: pre-blurred wallpaper — the scene is clearly visible through the glass, and the pixel-contrast scan still passes. +80. **Chat UI pass: glass chrome, one composer panel, friendlier chips.** The chat header was a flat + blue strip and the composer area floated on the wallpaper; both are glass surfaces now (the + composer is one flat frosted panel behind input + chips + note). The disclaimer is plain + centered text in Aero too (`AiDisclaimer plain`), the welcome block + suggestions sit on a soft + frosted panel and are vertically centered between header and composer, the model chip shows a + friendly name ("Gemma 4 31B") with the raw name in a tooltip, the chips are bigger with a + stronger rim, the send button is the filled primary (blue in every theme, clearly dimmed only + while empty/offline), and the header's "+" is a chat-plus icon so it no longer reads as the + rail's "new deck". (The review also asked for the light/dark toggle to come back to the Aero + header/rail — that stays out: Aero is switched from Settings by the owner's earlier rule.) + +79. **Any model the server offers is selectable in chat, default `gemma4:31b`.** The picker used + to be the server's allowlist only. The health check now appends every chat-capable model from + the server's `/api/tags` (favourites first, embeddings filtered, 120 cap), the picker gained a + search field when the list is long, and the chat route accepts a model that is in the + favourites *or* in the live listing (one tags fetch for non-favourites, so nothing selectable + is rejected). The default is `CHAT_MODEL_DEFAULT` (env) or `gemma4:31b`; when a server doesn't + have the default, the client falls back to the first available model. + +78. **Reduce motion: a Settings toggle, default off.** It mirrors the OS preference — an + `html.reduce-motion` class (applied pre-paint by the boot script) parks the scene and collapses + CSS animations/transitions, and `MotionPreference` wraps the app in framer-motion's + `MotionConfig reducedMotion="always"`, so every JS animation calms too. Stored in + `freezmo.reduce-motion.v1`; pinned in the mobile suite (126 checks). + +77. **The Aero review pass (tabs, primaries, panels, tints).** The active segment keeps the blue + gradient in Aero again (it had been glassed), and so does every `btn-primary`/`grad-ice` — the + "Review due" action, the card's Study button (now the filled primary) and the composer's send + button included. New Aero surfaces: a frosted backing behind the page heading, a chip behind + the "All decks" label, a bottom-weighted tint on the rail (its lower rows sit over grass), and + a dedicated `--track` token so progress bars read in every theme without shifting the shared + surfaces (the first attempt moved `--surface-sunken` and broke sheet contrast — the pixel scan + caught it). Difficulty labels got darker, the inactive segmented labels use the full text + token, the card border/shadow are a touch stronger in light, orbs are calmer in light and + dark, the folders list nudges "create a folder", and a fresh deck shows a "New" badge instead + of the gray "Not played yet". + 76. **A dropped connection says so.** A user's run failed with the wizard's generic "check your Ollama settings" card, and it could not be replicated on the owner's machine with the same key — because the key and model were never the problem. A raw error (a mid-run socket drop, a diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index ae86e43..921e846 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -1,7 +1,7 @@ import { z } from "zod"; -import { authErrorMessage, authErrorKind, getServerConfig, resolveAuth, resolveModelName } from "@/lib/config"; -import { friendlyErrorMessage, ollamaChatStream, OllamaError } from "@/lib/ollama"; +import { authErrorMessage, authErrorKind, canonicalModelName, getServerConfig, resolveAuth, resolveModelName } from "@/lib/config"; +import { friendlyErrorMessage, ollamaChatStream, ollamaListModels, OllamaError } from "@/lib/ollama"; import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard"; import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit"; import { acquireModelGate } from "@/lib/model-guard"; @@ -98,14 +98,28 @@ export async function POST(request: Request) { const { messages, context } = parsed.data; - // The model must come from the server's allowlist — never from the client. + // The model must be one the server actually offers — the configured + // favourites, or anything the user's Ollama lists. The picker shows exactly + // that list, so nothing selectable is ever rejected; an unknown model still + // gets the 400. const allowed = config.chatModels.length > 0 ? config.chatModels : [config.textModel]; const model = parsed.data.model ?? allowed[0]; - if (!allowed.some((entry) => entry.toLowerCase() === model.toLowerCase())) { - return Response.json( - { error: translate(locale, "server.chatModelNotAllowed"), kind: "bad_model" }, - { status: 400 }, - ); + const wanted = canonicalModelName(model); + const inFavourites = allowed.some((entry) => canonicalModelName(entry) === wanted); + if (!inFavourites) { + let listed = false; + try { + const models = await ollamaListModels(auth.auth, config.healthTimeoutMs); + listed = models.some((entry) => canonicalModelName(entry.name) === wanted); + } catch { + listed = false; + } + if (!listed) { + return Response.json( + { error: translate(locale, "server.chatModelNotAllowed"), kind: "bad_model" }, + { status: 400 }, + ); + } } const last = messages[messages.length - 1]; diff --git a/app/api/health/route.ts b/app/api/health/route.ts index 6f6aa2a..ba2db4c 100644 --- a/app/api/health/route.ts +++ b/app/api/health/route.ts @@ -143,6 +143,21 @@ export async function GET(request: Request) { entry.available = names.has(canonicalModelName(entry.name)); } + // Everything the server lists is selectable in chat (the picker shows + // exactly this array), so append the rest of the tags after the + // favourites — trying a free Ollama model needs no .env change. + const known = new Set(health.chatModels.map((entry) => canonicalModelName(entry.name))); + const extras: ChatModelHealth[] = []; + for (const model of models) { + const canonical = canonicalModelName(model.name); + if (known.has(canonical)) continue; + if (/embed|bert|whisper|clip|rerank/i.test(model.name)) continue; + known.add(canonical); + extras.push({ name: model.name, badge: badgeFor(model.name), available: true }); + } + extras.sort((a, b) => a.name.localeCompare(b.name)); + health.chatModels = [...health.chatModels, ...extras.slice(0, 120)]; + health.ok = Boolean(health.vision.available && health.text.available && health.keyAccepted !== false); if (!health.ok && !health.error) { health.error = translate(locale, "server.healthModelsMissing"); diff --git a/app/globals.css b/app/globals.css index 7a9531b..8d8ff9c 100644 --- a/app/globals.css +++ b/app/globals.css @@ -88,6 +88,7 @@ --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); + --track: rgb(12 32 62 / 0.14); --hairline: #d5e4f4; --card-border: var(--glass-border); --sidebar-divider: rgb(255 255 255 / 0.75); @@ -103,7 +104,7 @@ --glass-bg: rgb(255 255 255 / 0.55); /* A cool rim instead of near-white: white-on-white made light-mode card edges invisible against the pale page. */ - --glass-border: rgb(143 174 212 / 0.55); + --glass-border: rgb(143 174 212 / 0.68); --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 */ @@ -126,7 +127,7 @@ --install-chip: rgb(255 255 255 / 0.55); --install-border: rgb(255 255 255 / 0.8); --shadow-card: 0 1px 2px rgb(14 21 40 / 0.06), 0 1px 3px rgb(14 21 40 / 0.04); - --card-shadow: 0 1px 2px rgb(14 21 40 / 0.06), 0 9px 22px -8px rgb(14 21 40 / 0.16); + --card-shadow: 0 1px 2px rgb(14 21 40 / 0.08), 0 12px 26px -10px rgb(14 21 40 / 0.22); --shadow-card-hover: 0 24px 60px -24px rgb(30 64 175 / 0.45), 0 2px 6px rgb(30 64 175 / 0.12); } @@ -139,14 +140,15 @@ --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); + --track: rgb(255 255 255 / 0.18); --hairline: rgb(255 255 255 / 0.12); --card-border: var(--glass-border); --sidebar-divider: rgb(255 255 255 / 0.12); --hairline-soft: rgb(255 255 255 / 0.08); --glass-tint: rgb(255 255 255 / 0.06); --text: #e8f0ff; /* 16.3:1 on #0a1120 */ - --text-muted: #b3c4e0; /* 10.1:1 */ - --text-subtle: #9db1d0; /* 8.6:1 on page, 7.2:1 on dark glass */ + --text-muted: #c2d1e8; /* ~12:1 */ + --text-subtle: #adbfd9; /* ~10:1 on page, 8.4:1 on dark glass */ --hover: rgb(255 255 255 / 0.07); --focus: #38bdf8; @@ -185,14 +187,14 @@ --ice-base-top: #f4f9ff; --ice-base-bottom: #e6f1fb; --ice-blob-opacity: 0.045; - --ice-orb-opacity: 0.34; + --ice-orb-opacity: 0.22; --ice-grain-opacity: 0.05; } .dark { --ice-base-top: #070b16; --ice-base-bottom: #0c1426; --ice-blob-opacity: 0.1; - --ice-orb-opacity: 0.5; + --ice-orb-opacity: 0.38; --ice-grain-opacity: 0.045; } @@ -414,6 +416,7 @@ --glass-sheet-bg: rgb(232 248 255 / 0.64); --surface-strong: rgb(255 255 255 / 0.23); --surface-sunken: rgb(255 255 255 / 0.17); + --track: rgb(255 255 255 / 0.38); --nav-active-bg: rgb(255 255 255 / 0.27); } @@ -433,15 +436,13 @@ .aero .list-group, .aero .nav-action, .aero .nav-row[aria-current="page"], +.aero .nav-row[aria-pressed="true"], .aero .nav-row[data-active="true"], .aero .glass-chip, .aero .glass-pill, .aero .btn-secondary, -.aero .btn-primary, -.aero .grad-ice, +.aero .chat-header, .aero .segmented, -.aero .segmented > button[aria-pressed="true"], -.aero .segmented > a[aria-current="page"], /* Hand-rolled frost fills in the study, exam, chat and homework flows: white/xx utilities and the surface tokens — without this they render as flat white chips next to the glass. */ @@ -454,16 +455,17 @@ .aero [class*="bg-white/70"], .aero [class*="bg-[var(--surface)]"], .aero [class*="bg-[var(--surface-strong)]"], +.aero [class*="bg-[var(--surface-sunken)]"], .aero [class*="bg-frost-50"] { background-image: linear-gradient( 180deg, - rgb(255 255 255 / 0.13) 0%, - rgb(255 255 255 / 0.04) 46%, - rgb(255 255 255 / 0.03) 53%, - rgb(255 255 255 / 0.07) 100% + rgb(255 255 255 / 0.15) 0%, + rgb(255 255 255 / 0.06) 46%, + rgb(255 255 255 / 0.05) 53%, + rgb(255 255 255 / 0.09) 100% ), - linear-gradient(rgb(255 255 255 / 0.12), rgb(255 255 255 / 0.12)), + linear-gradient(rgb(255 255 255 / 0.16), rgb(255 255 255 / 0.16)), url("/aero/background-blur.webp?v=3"); background-size: 100% 100%, 100% 100%, cover; background-position: 0 0, 0 0, center 42%; @@ -613,21 +615,16 @@ .aero .list-group, .aero .nav-action, .aero .nav-row[aria-current="page"], + .aero .nav-row[aria-pressed="true"], .aero .nav-row[data-active="true"], .aero .glass-chip, .aero .glass-pill, .aero .btn-secondary, - .aero .btn-primary, - .aero .grad-ice, + .aero .chat-header, .aero .segmented { background-image: none; background-color: rgb(255 255 255 / 0.32); } - .aero .segmented > button[aria-pressed="true"], - .aero .segmented > a[aria-current="page"] { - background-image: none; - background-color: rgb(255 255 255 / 0.45); - } .aero .chat-bubble, .aero .chat-answer { background-image: none; @@ -646,11 +643,65 @@ .aero [class*="bg-white/70"], .aero [class*="bg-[var(--surface)]"], .aero [class*="bg-[var(--surface-strong)]"], + .aero [class*="bg-[var(--surface-sunken)]"], .aero [class*="bg-frost-50"] { background-image: none; } } +/* Chat header: the same glass as the cards, a touch more fill — its title and + scope line sit directly over the sky. */ +.aero .chat-header { + background-color: rgb(232 248 255 / 0.4); + border-bottom: 1px solid rgb(255 255 255 / 0.6); +} + +/* The chat composer: one flat frosted panel for the input, the chips and the + note — instead of each of them floating on the wallpaper. */ +.aero .chat-composer { + background-color: rgb(232 248 255 / 0.55); + border-top: 1px solid rgb(255 255 255 / 0.65); + box-shadow: 0 -16px 36px -26px rgb(2 62 110 / 0.5); +} + +/* The chat welcome (subtitle + suggestions) gets a soft panel, so clouds and + bubbles never sit right behind the text. */ +.aero .chat-empty { + border-radius: 1.5rem; + border: 1px solid rgb(255 255 255 / 0.5); + background-color: rgb(255 255 255 / 0.32); + padding: 1.35rem 1rem 1.1rem; +} + +/* Page headings that sit straight on the wallpaper. */ +.aero .page-heading { + display: inline-block; + padding: 0.45rem 0.85rem; + border-radius: 1.25rem; + border: 1px solid rgb(255 255 255 / 0.45); + background-color: rgb(255 255 255 / 0.32); +} + +/* The rail's lower half sits over the grass; weight the glass a little below + the middle so the bottom rows keep the same contrast as the top. */ +.aero .sidebar-surface { + background-image: + linear-gradient(180deg, rgb(255 255 255 / 0) 42%, rgb(255 255 255 / 0.22) 100%), + linear-gradient( + 180deg, + rgb(255 255 255 / 0.15) 0%, + rgb(255 255 255 / 0.06) 46%, + rgb(255 255 255 / 0.05) 53%, + rgb(255 255 255 / 0.09) 100% + ), + linear-gradient(rgb(255 255 255 / 0.16), rgb(255 255 255 / 0.16)), + url("/aero/background-blur.webp?v=3"); + background-size: 100% 100%, 100% 100%, 100% 100%, cover; + background-position: 0 0, 0 0, 0 0, center 42%; + background-attachment: scroll, scroll, scroll, fixed; + background-repeat: no-repeat, no-repeat, no-repeat, no-repeat; +} + /* Inside a sheet or dialog the backdrop is not the raw scene — a scrim and a more opaque sheet glass sit in between — so the viewport-aligned frosted layer would not match what is actually behind the element (it showed the @@ -675,6 +726,7 @@ .aero .glass-sheet [class*="bg-white/70"], .aero .glass-sheet [class*="bg-[var(--surface)]"], .aero .glass-sheet [class*="bg-[var(--surface-strong)]"], +.aero .glass-sheet [class*="bg-[var(--surface-sunken)]"], .aero .glass-sheet [class*="bg-frost-50"] { background-image: none; } @@ -877,11 +929,11 @@ body:has(.overlay-open) :is(.app-header, .segmented, .tabbar, .cookie-notice) { @media (max-width: 639px) { :root { --ice-blob-opacity: 0.025; - --ice-orb-opacity: 0.2; + --ice-orb-opacity: 0.14; } .dark { --ice-blob-opacity: 0.05; - --ice-orb-opacity: 0.26; + --ice-orb-opacity: 0.2; } /* Phones: orbs hug the screen edges so they can never sit under the content column's cards or primary buttons. The lower-right orb still @@ -1041,7 +1093,7 @@ body:has(.overlay-open) :is(.app-header, .segmented, .tabbar, .cookie-notice) { padding: 0.42rem 0.5rem; font-size: 0.875rem; font-weight: 600; - color: var(--color-frost-700); + color: var(--text); transition: background-color 0.18s ease, color 0.18s ease, box-shadow 0.18s ease; } .dark .segmented > button { @@ -1070,7 +1122,7 @@ body:has(.overlay-open) :is(.app-header, .segmented, .tabbar, .cookie-notice) { padding: 0.42rem 0.5rem; font-size: 0.875rem; font-weight: 600; - color: var(--color-frost-700); + color: var(--text); transition: background-color 0.18s ease, color 0.18s ease, box-shadow 0.18s ease; } .dark .segmented > a { @@ -1243,9 +1295,9 @@ body:has(.overlay-open) :is(.app-header, .segmented, .tabbar, .cookie-notice) { .btn-ghost:hover { background: var(--hover); color: var(--text); } /* Difficulty: neutral pill, colour lives in the text only. */ -.diff-easy { color: #16714f; } -.diff-medium { color: #7a5300; } -.diff-hard { color: #b4342f; } +.diff-easy { color: #0f5c3f; } +.diff-medium { color: #694800; } +.diff-hard { color: #a3261f; } .dark .diff-easy { color: #6fd3a8; } .dark .diff-medium { color: #e3b341; } .dark .diff-hard { color: #f08a84; } @@ -1473,3 +1525,23 @@ body:has(.overlay-open) :is(.app-header, .segmented, .tabbar, .cookie-notice) { scroll-behavior: auto !important; } } + +/* --------------------------------------------------------------------------- + Reduced motion (Settings toggle, default off). + The class mirrors `prefers-reduced-motion: reduce`: the scene parks and + every CSS animation/transition collapses to nothing. framer-motion gets + told separately (components/MotionPreference). + --------------------------------------------------------------------------- */ +html.reduce-motion .ice-blob, +html.reduce-motion .ice-orb, +html.reduce-motion .ice-bubble { + animation: none !important; +} +html.reduce-motion *, +html.reduce-motion *::before, +html.reduce-motion *::after { + animation-duration: 0.001ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.001ms !important; + scroll-behavior: auto !important; +} diff --git a/app/layout.tsx b/app/layout.tsx index a25f8ef..bf34070 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata, Viewport } from "next"; import { IceBackdrop } from "@/components/IceBackdrop"; +import { MotionPreference } from "@/components/MotionPreference"; import { CookieConsent } from "@/components/CookieConsent"; import { InstallSheet } from "@/components/InstallSheet"; import { OfflineBanner } from "@/components/OfflineBanner"; @@ -86,7 +87,7 @@ export const viewport: Viewport = { * on the bright wallpaper is what made the first load look washed out until * a theme toggle re-applied the classes. */ -const themeScript = `(function(){try{var DEV=${devInstanceJs};var s=localStorage.getItem('freezmo.theme');var a=s==='aero';var d=a?false:(s?s==='dark':window.matchMedia('(prefers-color-scheme: dark)').matches);document.documentElement.classList.toggle('dark',!!d);if(a)document.documentElement.classList.add('aero');var l=localStorage.getItem('freezmo.locale.v1');if(l==='cs'||l==='en')document.documentElement.lang=l;var m=document.querySelector('meta[name="theme-color"][data-app]');if(!m){m=document.createElement('meta');m.setAttribute('name','theme-color');m.setAttribute('data-app','1');document.head.insertBefore(m,document.head.firstChild);}m.setAttribute('content',DEV?'#b45309':(a?'#2fa7e6':(d?'#0e1424':'#f4f7fd')));}catch(e){}})();`; +const themeScript = `(function(){try{var DEV=${devInstanceJs};var s=localStorage.getItem('freezmo.theme');var a=s==='aero';var d=a?false:(s?s==='dark':window.matchMedia('(prefers-color-scheme: dark)').matches);document.documentElement.classList.toggle('dark',!!d);if(a)document.documentElement.classList.add('aero');var l=localStorage.getItem('freezmo.locale.v1');if(l==='cs'||l==='en')document.documentElement.lang=l;if(localStorage.getItem('freezmo.reduce-motion.v1')==='1')document.documentElement.classList.add('reduce-motion');var m=document.querySelector('meta[name="theme-color"][data-app]');if(!m){m=document.createElement('meta');m.setAttribute('name','theme-color');m.setAttribute('data-app','1');document.head.insertBefore(m,document.head.firstChild);}m.setAttribute('content',DEV?'#b45309':(a?'#2fa7e6':(d?'#0e1424':'#f4f7fd')));}catch(e){}})();`; /** * Captures Chrome's `beforeinstallprompt` (it can fire before React hydrates) @@ -105,13 +106,15 @@ export default function RootLayout({ children }: { children: React.ReactNode })
-