Design pass (all themes + chat UI), model picker, reduce-motion toggle
- Aero: blue gradients back on the active segment and every primary
(Review due, the card's Study button, the composer's send button); a
frosted backing behind the page heading and the All decks label; a
bottom-weighted rail tint; chat header/composer/welcome panels; calmer
orbs; stronger card veil + a stronger blur bake.
- Dark/Light: a dedicated --track token makes progress bars read (the
first attempt shifted --surface-sunken and the pixel scan caught the
sheet-contrast regression); brighter dark secondary text; darker light
segmented labels and difficulty tints; stronger light card border and
shadow.
- Chat UI: friendly model chip ("Gemma 4 31B") with the raw name in a
tooltip, larger chips, plain centered disclaimer, vertically centered
welcome panel, distinct chat-plus icon for new chat.
- Chat models: every chat-capable model from the server's /api/tags is
selectable (favourites first, search field when long, live-list
validation in the route); default CHAT_MODEL_DEFAULT or gemma4:31b.
- Reduce motion: Settings toggle (default off) that parks the scene,
collapses CSS animations and drives framer-motion's calm mode.
- Folders hint, "New" badge on fresh decks.
Contrast: 0 findings, axe clean, mobile 126/126, chat 102/102, wizard
95/95, regression 44/44, i18n 13/13, native 33/33.
Version 0.4.6, SW -> freezmo-v71.
This commit is contained in:
parent
b70b0e83cf
commit
6635b5d405
29 changed files with 468 additions and 90 deletions
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
130
app/globals.css
130
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 })
|
|||
<script dangerouslySetInnerHTML={{ __html: installScript }} />
|
||||
</head>
|
||||
<body className="min-h-full antialiased app-shell-in">
|
||||
<IceBackdrop />
|
||||
{children}
|
||||
<InstallSheet />
|
||||
<OfflineBanner />
|
||||
<CookieConsent />
|
||||
<Toaster />
|
||||
<ServiceWorkerRegistrar />
|
||||
<MotionPreference>
|
||||
<IceBackdrop />
|
||||
{children}
|
||||
<InstallSheet />
|
||||
<OfflineBanner />
|
||||
<CookieConsent />
|
||||
<Toaster />
|
||||
<ServiceWorkerRegistrar />
|
||||
</MotionPreference>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue