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
37
README.md
37
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
|
||||
|
|
|
|||
|
|
@ -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,15 +98,29 @@ 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())) {
|
||||
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];
|
||||
if (last.role !== "user") {
|
||||
|
|
|
|||
|
|
@ -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,6 +106,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
|||
<script dangerouslySetInnerHTML={{ __html: installScript }} />
|
||||
</head>
|
||||
<body className="min-h-full antialiased app-shell-in">
|
||||
<MotionPreference>
|
||||
<IceBackdrop />
|
||||
{children}
|
||||
<InstallSheet />
|
||||
|
|
@ -112,6 +114,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
|||
<CookieConsent />
|
||||
<Toaster />
|
||||
<ServiceWorkerRegistrar />
|
||||
</MotionPreference>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -12,11 +12,13 @@ import { cn } from "@/lib/utils";
|
|||
* --text-subtle is the muted helper token and is documented at 5.6:1 (light)
|
||||
* and 8.6:1 (dark), so the note stays readable at this size.
|
||||
*/
|
||||
export function AiDisclaimer({ className }: { className?: string }) {
|
||||
export function AiDisclaimer({ className, plain = false }: { className?: string; plain?: boolean }) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
// hint-line: Aero gives this its own glass chip (it sits on the wallpaper).
|
||||
<p className={cn("hint-line text-center text-[11.5px] leading-snug text-[var(--text-subtle)]", className)}>
|
||||
// Where the note already sits on a glass panel (the chat composer), `plain`
|
||||
// keeps it plain centered text like the other themes.
|
||||
<p className={cn(!plain && "hint-line", "text-center text-[11.5px] leading-snug text-[var(--text-subtle)]", className)}>
|
||||
{t("common.aiDisclaimer")}
|
||||
</p>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -225,9 +225,17 @@ export function DeckCard({
|
|||
) : null}
|
||||
</span>
|
||||
{/* Desktop always shows the same two fields. */}
|
||||
<span className="hidden lg:inline">
|
||||
<span className="hidden lg:inline-flex items-center">
|
||||
{deck.attempts > 0 ? (
|
||||
<>
|
||||
<span aria-hidden> · </span>
|
||||
{deck.attempts > 0 ? plural(deck.attempts, "deck.runs") : t("deck.notPlayed")}
|
||||
{plural(deck.attempts, "deck.runs")}
|
||||
</>
|
||||
) : (
|
||||
<span className="tint-sky ml-1 rounded-full px-2 py-0.5 text-[10.5px] font-semibold">
|
||||
{t("deck.new")}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -343,7 +351,7 @@ export function DeckCard({
|
|||
{t("deck.study")}
|
||||
</Link>
|
||||
<div className="relative z-10 hidden lg:flex lg:justify-end">
|
||||
<Link href={`/deck/${deck.id}`} className="btn-secondary h-9 px-4 text-[13px]">
|
||||
<Link href={`/deck/${deck.id}`} className="btn-primary h-9 px-4 text-[13px]">
|
||||
<PlayIcon className="size-4" />
|
||||
{t("deck.study")}
|
||||
</Link>
|
||||
|
|
|
|||
|
|
@ -192,6 +192,7 @@ export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" |
|
|||
{/* page header ---------------------------------------------------- */}
|
||||
<section className="mb-6 space-y-5">
|
||||
<div>
|
||||
<div className="page-heading">
|
||||
<h1 className="text-[24px] font-semibold leading-tight tracking-[-0.02em] text-[var(--text)] sm:text-[26px]">
|
||||
{t("library.title")}
|
||||
</h1>
|
||||
|
|
@ -199,6 +200,7 @@ export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" |
|
|||
{t("library.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop keeps Import above the tabs; phones get a small icon
|
||||
button in the tab row instead. */}
|
||||
|
|
@ -486,7 +488,7 @@ export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" |
|
|||
|
||||
{/* One quiet label replaces the redundant single-group heading. */}
|
||||
{!hasFolders ? (
|
||||
<p className="mb-4 hidden text-[12px] font-medium text-[var(--text-subtle)] lg:block">
|
||||
<p className="hint-line mb-4 hidden text-[12px] font-medium text-[var(--text-subtle)] lg:block">
|
||||
{t("common.allDecks")}
|
||||
</p>
|
||||
) : null}
|
||||
|
|
|
|||
16
components/MotionPreference.tsx
Normal file
16
components/MotionPreference.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
"use client";
|
||||
|
||||
import { MotionConfig } from "framer-motion";
|
||||
|
||||
import { useReducedMotionPref } from "@/lib/motion-pref";
|
||||
|
||||
/**
|
||||
* Teaches framer-motion about the in-app "reduce motion" setting: with the
|
||||
* toggle on, every motion component runs in its calm mode (`always`), not just
|
||||
* when the OS asks (`user`). Renders nothing itself, so the tree below stays
|
||||
* exactly as it was.
|
||||
*/
|
||||
export function MotionPreference({ children }: { children: React.ReactNode }) {
|
||||
const { reduced } = useReducedMotionPref();
|
||||
return <MotionConfig reducedMotion={reduced ? "always" : "user"}>{children}</MotionConfig>;
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ import { ModelErrorActions } from "@/components/ModelErrorActions";
|
|||
import { DataSection } from "@/components/DataSection";
|
||||
import { LearningSection } from "@/components/LearningSection";
|
||||
import { ThemeSection } from "@/components/ThemeSection";
|
||||
import { useReducedMotionPref } from "@/lib/motion-pref";
|
||||
import { SyncSection } from "@/components/SyncSection";
|
||||
import { clearApiKey, saveSettings, settingsStore } from "@/lib/settings";
|
||||
import { useBackClose } from "@/lib/use-back-close";
|
||||
|
|
@ -54,6 +55,7 @@ export function SettingsDialog({
|
|||
focusField?: { field: "key"; nonce: number } | null;
|
||||
}) {
|
||||
const { health, loading, error, refresh, apiKey, checkedAt } = useHealth();
|
||||
const { reduced: reducedMotion, setReduced: setReducedMotion } = useReducedMotionPref();
|
||||
const installAvailable = useInstallAvailable();
|
||||
const { locale, setLocale, t, plural } = useI18n();
|
||||
const version = useVersion();
|
||||
|
|
@ -411,6 +413,19 @@ export function SettingsDialog({
|
|||
{/* ------------------------------------------- theme */}
|
||||
<ThemeSection />
|
||||
|
||||
{/* --------------------------------------- motion */}
|
||||
<section className="mb-4 space-y-2 rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] p-3">
|
||||
<ToggleRow
|
||||
label={t("settings.reduceMotion")}
|
||||
note={t("settings.reduceMotionNote")}
|
||||
checked={reducedMotion}
|
||||
onChange={() => {
|
||||
setReducedMotion(!reducedMotion);
|
||||
haptic("toggle");
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* ----------------------------------------- learning */}
|
||||
<LearningSection level={3} />
|
||||
|
||||
|
|
@ -773,6 +788,19 @@ export function SettingsDialog({
|
|||
{/* ------------------------------------------- theme */}
|
||||
<ThemeSection />
|
||||
|
||||
{/* --------------------------------------- motion */}
|
||||
<section className="mb-4 space-y-2 rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] p-3">
|
||||
<ToggleRow
|
||||
label={t("settings.reduceMotion")}
|
||||
note={t("settings.reduceMotionNote")}
|
||||
checked={reducedMotion}
|
||||
onChange={() => {
|
||||
setReducedMotion(!reducedMotion);
|
||||
haptic("toggle");
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* ----------------------------------------- learning */}
|
||||
<LearningSection level={3} />
|
||||
|
||||
|
|
|
|||
|
|
@ -209,6 +209,12 @@ function NavContent({ collapsed = false }: { collapsed?: boolean }) {
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
{sortedFolders.length === 0 && !collapsed ? (
|
||||
<p className="px-2.5 pb-1 pt-0.5 text-[11.5px] leading-snug text-[var(--text-subtle)]">
|
||||
{t("common.foldersHint")}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{sortedFolders.map((folder) => (
|
||||
<button
|
||||
key={folder.id}
|
||||
|
|
|
|||
|
|
@ -689,7 +689,7 @@ export function UploadScreen() {
|
|||
aria-valuemin={1}
|
||||
aria-valuemax={3}
|
||||
aria-valuenow={step}
|
||||
className="h-1 flex-1 overflow-hidden rounded-full bg-[var(--surface-sunken)]"
|
||||
className="h-1 flex-1 overflow-hidden rounded-full bg-[var(--track)]"
|
||||
>
|
||||
<div
|
||||
className="grad-ice h-full rounded-full transition-[width] duration-300 motion-reduce:transition-none"
|
||||
|
|
|
|||
|
|
@ -10,13 +10,14 @@ import { ContextPicker } from "./ContextPicker";
|
|||
import { HistorySheet } from "./HistoryPanel";
|
||||
import { MessageRow } from "./MessageRow";
|
||||
import { ModelPicker } from "./ModelPicker";
|
||||
import { ChatIcon, ChevronDownIcon, ChevronLeftIcon, HistoryIcon, PlusIcon, RefreshIcon } from "@/components/icons";
|
||||
import { ChatIcon, ChatPlusIcon, ChevronDownIcon, ChevronLeftIcon, HistoryIcon, RefreshIcon } from "@/components/icons";
|
||||
import { ModelErrorActions } from "@/components/ModelErrorActions";
|
||||
import { ThemeToggle } from "@/components/ThemeToggle";
|
||||
import { Button } from "@/components/ui";
|
||||
import { ApiError } from "@/lib/client-api";
|
||||
import { extractChatAttachment, streamChat } from "@/lib/chat-client";
|
||||
import { buildChatContext, deckHasNotes } from "@/lib/chat-context";
|
||||
import { cn, prettyModelName } from "@/lib/utils";
|
||||
import { takeChatSeed, type ChatSeed } from "@/lib/chat-seed";
|
||||
import { chatActive } from "@/lib/chat-active";
|
||||
import { chatHistoryStore, conversationMessages, deleteConversation, ensureChatsHydrated, getConversation, renameConversation, saveConversation } from "@/lib/chat-store";
|
||||
|
|
@ -167,7 +168,10 @@ export function ChatScreen() {
|
|||
|
||||
const activeModel = useMemo(() => {
|
||||
if (model && chatModels.some((entry) => entry.name === model)) return model;
|
||||
return chatModels[0]?.name ?? model;
|
||||
// The server puts the default first, but a different server may not have
|
||||
// it — fall back to the first model that is actually available.
|
||||
const firstAvailable = chatModels.find((entry) => entry.available !== false);
|
||||
return (firstAvailable ?? chatModels[0])?.name ?? model;
|
||||
}, [model, chatModels]);
|
||||
|
||||
const scopeText = useMemo(() => {
|
||||
|
|
@ -524,7 +528,7 @@ export function ChatScreen() {
|
|||
<div className="flex h-full min-h-0 w-full">
|
||||
{/* Desktop's conversation list lives in the main sidebar (on /chat). */}
|
||||
<div className="relative flex h-full min-h-0 min-w-0 flex-1 flex-col" style={{ paddingBottom: "var(--chat-keyboard, 0px)" }}>
|
||||
<header className="glass sticky top-0 z-30 flex h-14 shrink-0 items-center gap-2 px-2 sm:px-4">
|
||||
<header className="glass chat-header sticky top-0 z-30 flex h-14 shrink-0 items-center gap-2 px-2 sm:px-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => (window.history.length > 1 ? router.back() : router.push("/"))}
|
||||
|
|
@ -563,16 +567,21 @@ export function ChatScreen() {
|
|||
title={t("chat.new")}
|
||||
className="grid size-11 place-items-center rounded-2xl border border-[var(--glass-border)] bg-[var(--surface-strong)] text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
|
||||
>
|
||||
<PlusIcon className="size-5" />
|
||||
<ChatPlusIcon className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="relative min-h-0 flex-1">
|
||||
<div ref={scrollRef} onScroll={onScroll} className="chat-scroll h-full overflow-y-auto overscroll-contain">
|
||||
<div className="mx-auto w-full max-w-3xl space-y-4 px-4 py-4">
|
||||
<div
|
||||
className={cn(
|
||||
"mx-auto w-full max-w-3xl space-y-4 px-4 py-4",
|
||||
messages.length === 0 && "flex min-h-full flex-col justify-center",
|
||||
)}
|
||||
>
|
||||
{messages.length === 0 ? (
|
||||
<div className="space-y-5 py-6">
|
||||
<div className="chat-empty space-y-5 py-2">
|
||||
<div className="text-center">
|
||||
<span className="mx-auto mb-4 grid size-12 place-items-center rounded-2xl border border-[var(--hairline)] bg-[var(--surface-strong)] text-accent-600 dark:text-accent-200">
|
||||
<ChatIcon className="size-6" />
|
||||
|
|
@ -671,7 +680,7 @@ export function ChatScreen() {
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="relative shrink-0">
|
||||
<div className="chat-composer relative shrink-0">
|
||||
<Composer
|
||||
value={draft}
|
||||
onChange={setDraft}
|
||||
|
|
@ -680,7 +689,8 @@ export function ChatScreen() {
|
|||
busy={busy}
|
||||
online={online}
|
||||
scopeLabel={scopeText}
|
||||
modelLabel={activeModel || t("chat.modelTitle")}
|
||||
modelLabel={activeModel ? prettyModelName(activeModel) : t("chat.modelTitle")}
|
||||
modelTitle={activeModel || undefined}
|
||||
scopeOpen={scopeOpen}
|
||||
modelOpen={modelOpen}
|
||||
// The chips toggle their own popover (and open one at a time), so a
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ export function Composer({
|
|||
online,
|
||||
scopeLabel,
|
||||
modelLabel,
|
||||
modelTitle,
|
||||
scopeOpen,
|
||||
modelOpen,
|
||||
onOpenScope,
|
||||
|
|
@ -43,6 +44,8 @@ export function Composer({
|
|||
online: boolean;
|
||||
scopeLabel: string;
|
||||
modelLabel: string;
|
||||
/** The raw model name, shown as the chip's tooltip. */
|
||||
modelTitle?: string;
|
||||
scopeOpen: boolean;
|
||||
modelOpen: boolean;
|
||||
onOpenScope: () => void;
|
||||
|
|
@ -168,8 +171,8 @@ export function Composer({
|
|||
onClick={onOpenScope}
|
||||
className={pickerChipClass(scopeOpen)}
|
||||
>
|
||||
<LayersIcon className="size-3.5 shrink-0" />
|
||||
<span className="max-w-[170px] truncate">{scopeLabel}</span>
|
||||
<LayersIcon className="size-4 shrink-0" />
|
||||
<span className="max-w-[190px] truncate">{scopeLabel}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -177,10 +180,11 @@ export function Composer({
|
|||
aria-haspopup="dialog"
|
||||
aria-expanded={modelOpen}
|
||||
onClick={onOpenModel}
|
||||
title={modelTitle ?? modelLabel}
|
||||
className={pickerChipClass(modelOpen)}
|
||||
>
|
||||
<ChatIcon className="size-3.5 shrink-0" />
|
||||
<span className="max-w-[170px] truncate">{modelLabel}</span>
|
||||
<ChatIcon className="size-4 shrink-0" />
|
||||
<span className="max-w-[190px] truncate">{modelLabel}</span>
|
||||
</button>
|
||||
{!online ? (
|
||||
<span role="status" className="ml-auto shrink-0 text-[11.5px] text-amber-600 dark:text-amber-300">
|
||||
|
|
@ -189,7 +193,7 @@ export function Composer({
|
|||
) : null}
|
||||
</div>
|
||||
|
||||
<AiDisclaimer className="mt-1.5" />
|
||||
<AiDisclaimer plain className="mt-1.5" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -200,9 +204,9 @@ export function Composer({
|
|||
*/
|
||||
function pickerChipClass(open: boolean): string {
|
||||
return cn(
|
||||
"flex min-w-0 items-center gap-1.5 rounded-xl border px-2.5 py-1.5 text-[12px] font-medium transition",
|
||||
"flex min-w-0 items-center gap-1.5 rounded-xl border px-3 py-2 text-[12.5px] font-medium transition",
|
||||
open
|
||||
? "border-[var(--nav-active-border)] bg-[var(--nav-active-bg)] text-[var(--text)]"
|
||||
: "border-[var(--hairline-soft)] bg-[var(--surface-sunken)] text-[var(--text-muted)] hover:text-[var(--text)]",
|
||||
: "border-[var(--hairline)] bg-[var(--surface-sunken)] text-[var(--text)] hover:bg-[var(--hover)]",
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -107,7 +107,7 @@ export function ContextPicker({
|
|||
"flex w-full items-center gap-3 rounded-2xl border px-3 py-3 text-left transition",
|
||||
scope.kind === value
|
||||
? "border-[var(--nav-active-border)] bg-[var(--nav-active-bg)]"
|
||||
: "border-[var(--hairline-soft)] bg-[var(--surface-bg)] hover:bg-[var(--hover)]",
|
||||
: "border-[var(--hairline-soft)] bg-[var(--surface-strong)] hover:bg-[var(--hover)]",
|
||||
)}
|
||||
>
|
||||
<span className="shrink-0 text-[var(--text-muted)]">{icon}</span>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
|
||||
|
||||
import { CheckCircleIcon } from "@/components/icons";
|
||||
|
|
@ -32,6 +32,15 @@ export function ModelPicker({
|
|||
const isPhone = useIsPhone();
|
||||
const reduceMotion = useReducedMotion();
|
||||
const panel = useRef<HTMLDivElement>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
/* Every model the server lists is offered, so the list can be long — filter
|
||||
it. Cleared each time the picker opens. */
|
||||
useEffect(() => {
|
||||
if (open) setQuery("");
|
||||
}, [open]);
|
||||
const needle = query.trim().toLowerCase();
|
||||
const visible = needle ? models.filter((model) => model.name.toLowerCase().includes(needle)) : models;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
|
@ -61,8 +70,23 @@ export function ModelPicker({
|
|||
model.badge === "vision" ? t("chat.modelVision") : model.badge === "both" ? t("chat.modelBoth") : t("chat.modelText");
|
||||
|
||||
const rows = (
|
||||
<>
|
||||
{models.length > 8 ? (
|
||||
<input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder={t("chat.modelSearch")}
|
||||
aria-label={t("chat.modelSearch")}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className="mb-2 w-full rounded-xl border border-[var(--hairline)] bg-[var(--surface-sunken)] px-3 py-2 text-[13px] text-[var(--text)] outline-none placeholder:text-[var(--text-subtle)] focus:border-accent-400"
|
||||
/>
|
||||
) : null}
|
||||
<ul className="max-h-[60dvh] space-y-1 overflow-y-auto overscroll-contain">
|
||||
{models.map((model) => {
|
||||
{visible.length === 0 ? (
|
||||
<li className="px-3 py-2 text-[12.5px] text-[var(--text-subtle)]">{t("chat.modelSearchEmpty")}</li>
|
||||
) : null}
|
||||
{visible.map((model) => {
|
||||
const selected = model.name === value;
|
||||
const unavailable = model.available === false;
|
||||
return (
|
||||
|
|
@ -79,7 +103,7 @@ export function ModelPicker({
|
|||
"flex w-full items-center gap-3 rounded-2xl border px-3 py-3 text-left transition",
|
||||
selected
|
||||
? "border-[var(--nav-active-border)] bg-[var(--nav-active-bg)]"
|
||||
: "border-[var(--hairline-soft)] bg-[var(--surface-bg)] hover:bg-[var(--hover)]",
|
||||
: "border-[var(--hairline-soft)] bg-[var(--surface-strong)] hover:bg-[var(--hover)]",
|
||||
unavailable && "opacity-55",
|
||||
)}
|
||||
>
|
||||
|
|
@ -100,6 +124,7 @@ export function ModelPicker({
|
|||
);
|
||||
})}
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
|
||||
if (isPhone) {
|
||||
|
|
|
|||
|
|
@ -440,7 +440,7 @@ export function HomeworkInput({
|
|||
aria-valuemin={1}
|
||||
aria-valuemax={2}
|
||||
aria-valuenow={step}
|
||||
className="h-1 flex-1 overflow-hidden rounded-full bg-[var(--surface-sunken)]"
|
||||
className="h-1 flex-1 overflow-hidden rounded-full bg-[var(--track)]"
|
||||
>
|
||||
<div
|
||||
className="grad-ice h-full rounded-full transition-[width] duration-300 motion-reduce:transition-none"
|
||||
|
|
|
|||
|
|
@ -197,6 +197,17 @@ export function PlusIcon({ className }: IconProps) {
|
|||
);
|
||||
}
|
||||
|
||||
/** A chat bubble with a plus: "new chat" — distinct from the plain plus the
|
||||
* rail uses for "new deck" (two identical + buttons read as the same thing). */
|
||||
export function ChatPlusIcon({ className }: IconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5Z" />
|
||||
<path d="M12 8.5v5M9.5 11h5" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ShareIcon({ className }: IconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
|
|
|
|||
|
|
@ -113,7 +113,7 @@ export function ProgressBar({
|
|||
const percent = max <= 0 ? 0 : Math.min(100, Math.max(0, (value / max) * 100));
|
||||
return (
|
||||
<div
|
||||
className={cn("h-1 w-full overflow-hidden rounded-full bg-[var(--surface-sunken)]", className)}
|
||||
className={cn("h-1 w-full overflow-hidden rounded-full bg-[var(--track)]", className)}
|
||||
role="progressbar"
|
||||
aria-valuenow={Math.round(percent)}
|
||||
aria-valuemin={0}
|
||||
|
|
|
|||
|
|
@ -142,7 +142,7 @@ export async function generateDeck(
|
|||
body: JSON.stringify(input),
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch (error) {
|
||||
} catch {
|
||||
if (forwarded?.aborted) throw new ApiError(t("api.cancelled"), "aborted");
|
||||
if (headersTimedOut) throw new ApiError(t("api.stalled"), "timeout");
|
||||
throw new ApiError(t("api.unreachable"), "network");
|
||||
|
|
@ -262,7 +262,7 @@ export async function generateHomework(
|
|||
body: JSON.stringify(input),
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch (error) {
|
||||
} catch {
|
||||
if (forwarded?.aborted) throw new ApiError(t("api.cancelled"), "aborted");
|
||||
if (headersTimedOut) throw new ApiError(t("api.stalled"), "timeout");
|
||||
throw new ApiError(t("api.unreachable"), "network");
|
||||
|
|
|
|||
|
|
@ -127,6 +127,11 @@ export function getServerConfig(): ServerConfig {
|
|||
const visionModel = process.env.VISION_MODEL?.trim() || "gemma4:31b-cloud";
|
||||
const textModel = process.env.TEXT_MODEL?.trim() || "gpt-oss:120b-cloud";
|
||||
|
||||
// The chat picker opens on this one when the server offers it. Every model
|
||||
// the server lists is selectable anyway (see /api/health), so trying a free
|
||||
// Ollama model never needs an .env change.
|
||||
const chatDefault = process.env.CHAT_MODEL_DEFAULT?.trim() || "gemma4:31b";
|
||||
|
||||
// Chat model allowlist: CHAT_MODELS wins, otherwise the configured text and
|
||||
// vision models. Dedupe on the canonical name so `x` and `x:cloud` collapse.
|
||||
const requestedChatModels = (process.env.CHAT_MODELS ?? "")
|
||||
|
|
@ -135,7 +140,7 @@ export function getServerConfig(): ServerConfig {
|
|||
.filter(Boolean);
|
||||
const chatModels: string[] = [];
|
||||
const seenModels = new Set<string>();
|
||||
for (const entry of requestedChatModels.length > 0 ? requestedChatModels : [textModel, visionModel]) {
|
||||
for (const entry of [chatDefault, ...(requestedChatModels.length > 0 ? requestedChatModels : [textModel, visionModel])]) {
|
||||
const canonical = canonicalModelName(entry);
|
||||
if (seenModels.has(canonical)) continue;
|
||||
seenModels.add(canonical);
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ const en: Record<string, string> = {
|
|||
"common.folder": "Folder",
|
||||
"common.folders": "Folders",
|
||||
"common.newFolder": "New folder",
|
||||
"common.foldersHint": "Create a folder to organize decks.",
|
||||
"common.folderName": "Folder name",
|
||||
"common.newFolderName": "New folder name",
|
||||
"common.unorganized": "Unorganized",
|
||||
|
|
@ -386,6 +387,7 @@ const en: Record<string, string> = {
|
|||
"deck.runs.few": "{n} runs",
|
||||
"deck.runs.many": "{n} runs",
|
||||
"deck.notPlayed": "Not played yet",
|
||||
"deck.new": "New",
|
||||
"deck.best": "Best {n}%",
|
||||
"deck.bestLabel": "Best",
|
||||
"deck.study": "Study",
|
||||
|
|
@ -951,6 +953,8 @@ const en: Record<string, string> = {
|
|||
"chat.modelText": "Text",
|
||||
"chat.modelVision": "Vision",
|
||||
"chat.modelBoth": "Text + vision",
|
||||
"chat.modelSearch": "Search models",
|
||||
"chat.modelSearchEmpty": "No model matches.",
|
||||
"chat.modelAvailable": "available",
|
||||
"chat.modelUnavailable": "not found",
|
||||
"chat.modelNote": "Photos and documents are transcribed first, so any of these works.",
|
||||
|
|
@ -1013,6 +1017,8 @@ const en: Record<string, string> = {
|
|||
"consent.ok": "Got it",
|
||||
"settings.version": "Version",
|
||||
"settings.chatNotes": "Keep notes text for chat",
|
||||
"settings.reduceMotion": "Reduce motion",
|
||||
"settings.reduceMotionNote": "Calms the scene and UI animations. Useful when motion feels distracting.",
|
||||
"settings.chatNotesNote": "Chat answers from the notes you added (stored with each deck). Turn off to use only the generated study content.",
|
||||
"server.chatModelNotAllowed": "That chat model is not on the server's allowlist.",
|
||||
"server.chatNeedsQuestion": "The conversation must end with a user message.",
|
||||
|
|
@ -1033,6 +1039,7 @@ const cs: Record<string, string> = {
|
|||
"common.folder": "Složka",
|
||||
"common.folders": "Složky",
|
||||
"common.newFolder": "Nová složka",
|
||||
"common.foldersHint": "Vytvořte složku pro uspořádání sad.",
|
||||
"common.folderName": "Název složky",
|
||||
"common.newFolderName": "Název nové složky",
|
||||
"common.unorganized": "Nezařazené",
|
||||
|
|
@ -1343,6 +1350,7 @@ const cs: Record<string, string> = {
|
|||
"deck.runs.few": "{n} běhy",
|
||||
"deck.runs.many": "{n} běhů",
|
||||
"deck.notPlayed": "Zatím nehráno",
|
||||
"deck.new": "Nové",
|
||||
"deck.best": "Nejlepší {n} %",
|
||||
"deck.bestLabel": "Nejlepší",
|
||||
"deck.study": "Studovat",
|
||||
|
|
@ -1908,6 +1916,8 @@ const cs: Record<string, string> = {
|
|||
"chat.modelText": "Text",
|
||||
"chat.modelVision": "Obrázky",
|
||||
"chat.modelBoth": "Text + obrázky",
|
||||
"chat.modelSearch": "Hledat modely",
|
||||
"chat.modelSearchEmpty": "Žádný model neodpovídá.",
|
||||
"chat.modelAvailable": "dostupný",
|
||||
"chat.modelUnavailable": "nenalezen",
|
||||
"chat.modelNote": "Fotky a dokumenty se nejdřív přepíšou na text, takže funguje kterýkoli z nich.",
|
||||
|
|
@ -1970,6 +1980,8 @@ const cs: Record<string, string> = {
|
|||
"consent.ok": "Rozumím",
|
||||
"settings.version": "Verze",
|
||||
"settings.chatNotes": "Uchovávat text poznámek pro chat",
|
||||
"settings.reduceMotion": "Omezit pohyb",
|
||||
"settings.reduceMotionNote": "Zklidní scénu i animace rozhraní. Hodí se, když je pohyb rušivý.",
|
||||
"settings.chatNotesNote": "Chat odpovídá z poznámek, které jsi přidal (jsou uložené u každé sady). Vypnutím bude používat jen vygenerovaný obsah.",
|
||||
"server.chatModelNotAllowed": "Tento model chatu není v povoleném seznamu serveru.",
|
||||
"server.chatNeedsQuestion": "Konverzace musí končit zprávou uživatele.",
|
||||
|
|
|
|||
51
lib/motion-pref.ts
Normal file
51
lib/motion-pref.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
"use client";
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
/**
|
||||
* The Settings "reduce motion" toggle (default off). It mirrors the OS
|
||||
* preference: the scene stops moving, CSS transitions collapse, and
|
||||
* framer-motion runs in its calm mode via MotionPreference. One shared store,
|
||||
* so the toggle and every consumer update together.
|
||||
*/
|
||||
|
||||
const KEY = "freezmo.reduce-motion.v1";
|
||||
|
||||
let cached: boolean | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function read(): boolean {
|
||||
try {
|
||||
return window.localStorage.getItem(KEY) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function snapshot(): boolean {
|
||||
cached ??= read();
|
||||
return cached;
|
||||
}
|
||||
|
||||
export function applyReducedMotion(next: boolean) {
|
||||
cached = next;
|
||||
document.documentElement.classList.toggle("reduce-motion", next);
|
||||
try {
|
||||
window.localStorage.setItem(KEY, next ? "1" : "0");
|
||||
} catch {
|
||||
/* private mode */
|
||||
}
|
||||
listeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
export function useReducedMotionPref() {
|
||||
const reduced = useSyncExternalStore(
|
||||
(listener) => {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
snapshot,
|
||||
() => false,
|
||||
);
|
||||
return { reduced, setReduced: applyReducedMotion };
|
||||
}
|
||||
27
lib/utils.ts
27
lib/utils.ts
|
|
@ -57,3 +57,30 @@ export function parseJsonLoose(text: string): unknown {
|
|||
export function truncate(value: string, max: number): string {
|
||||
return value.length > max ? `${value.slice(0, max - 1)}…` : value;
|
||||
}
|
||||
|
||||
/**
|
||||
* A friendlier label for a model name: "gemma4:31b-cloud" → "Gemma 4 31B",
|
||||
* "gpt-oss:120b" → "GPT OSS 120B". Chips show this; the raw name lives in a
|
||||
* tooltip (and the picker keeps the technical names).
|
||||
*/
|
||||
export function prettyModelName(name: string): string {
|
||||
const base = name.replace(/:cloud$/i, "").replace(/-cloud$/i, "");
|
||||
const [family, ...tagParts] = base.split(":");
|
||||
const words = family
|
||||
.replace(/[-_.]+/g, " ")
|
||||
.replace(/([a-z])(\d)/gi, "$1 $2")
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter(Boolean);
|
||||
const prettyFamily = words
|
||||
.map((word) =>
|
||||
/^[a-z]{1,3}$/i.test(word) && word === word.toLowerCase()
|
||||
? word.toUpperCase()
|
||||
: word.charAt(0).toUpperCase() + word.slice(1),
|
||||
)
|
||||
.join(" ");
|
||||
const tag = tagParts
|
||||
.join(" ")
|
||||
.replace(/^(\d+)([a-z])$/i, (_match, size: string, unit: string) => `${size}${unit.toUpperCase()}`);
|
||||
return [prettyFamily, tag].filter(Boolean).join(" ");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "freezmo",
|
||||
"version": "0.4.1",
|
||||
"version": "0.4.6",
|
||||
"private": true,
|
||||
"license": "GPL-3.0-only",
|
||||
"scripts": {
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 8.7 KiB After Width: | Height: | Size: 6.1 KiB |
|
|
@ -9,7 +9,7 @@
|
|||
*
|
||||
* Bumping CACHE_VERSION is how a new deploy invalidates old assets.
|
||||
*/
|
||||
const CACHE_VERSION = "freezmo-v70";
|
||||
const CACHE_VERSION = "freezmo-v71";
|
||||
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
|
||||
const ASSET_CACHE = `${CACHE_VERSION}-assets`;
|
||||
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ const ANSWER = [
|
|||
const stub = http.createServer((request, response) => {
|
||||
if (request.url?.startsWith("/api/tags")) {
|
||||
response.writeHead(200, { "Content-Type": "application/json" });
|
||||
response.end(JSON.stringify({ models: [{ name: "stub-text" }, { name: "stub-vision" }] }));
|
||||
response.end(JSON.stringify({ models: [{ name: "stub-text" }, { name: "stub-vision" }, { name: "stub-extra" }] }));
|
||||
return;
|
||||
}
|
||||
let raw = "";
|
||||
|
|
@ -127,6 +127,7 @@ const app = spawnApp(APP_PORT, {
|
|||
OLLAMA_AUTH: "local",
|
||||
OLLAMA_VERIFY_KEY: "false",
|
||||
CHAT_MODELS: "stub-text,stub-vision",
|
||||
CHAT_MODEL_DEFAULT: "stub-text",
|
||||
});
|
||||
|
||||
const DECK_NOTES = {
|
||||
|
|
@ -292,7 +293,7 @@ try {
|
|||
|
||||
/* 6b. desktop pickers: the chip that opens also closes ---------------
|
||||
(It used to only ever open, so the only way out was a choice.) */
|
||||
const modelChip = () => desktop.page.getByRole("button", { name: /stub-(text|vision)|Chat model/ }).first();
|
||||
const modelChip = () => desktop.page.getByRole("button", { name: /Stub (Text|Vision)|Chat model/ }).first();
|
||||
const modelDialog = desktop.page.getByRole("dialog", { name: "Chat model" });
|
||||
const scopeDialog = desktop.page.getByRole("dialog", { name: "What may chat read?" });
|
||||
const scopeChip = () => desktop.page.getByRole("button", { name: "All notes" }).first();
|
||||
|
|
@ -357,20 +358,21 @@ try {
|
|||
check("chat: context reached the model", String(lastChat?.messages?.[0]?.content ?? "").includes("FOTOSYNTEZA-MARKER"));
|
||||
|
||||
/* 8. UI: model picker + context picker ------------------------------ */
|
||||
await phone.page.getByRole("button", { name: "stub-text" }).click();
|
||||
await phone.page.getByRole("button", { name: "Stub Text" }).click();
|
||||
await phone.page.waitForTimeout(400);
|
||||
check("model: picker lists both models with availability", (await phone.page.getByText("stub-vision").count()) >= 1 && (await phone.page.getByText("available").count()) >= 1);
|
||||
check("model: a model listed by the server's tags is selectable", (await phone.page.getByText("stub-extra").count()) >= 1);
|
||||
await phone.page.getByRole("button", { name: /stub-vision/ }).click();
|
||||
await phone.page.getByRole("dialog", { name: "Chat model" }).waitFor({ state: "detached", timeout: 4000 }).catch(() => {});
|
||||
await phone.page.waitForTimeout(300);
|
||||
check("model: choice updates the chip", (await phone.page.getByRole("button", { name: "stub-vision" }).count()) === 1);
|
||||
check("model: choice updates the chip", (await phone.page.getByRole("button", { name: "Stub Vision" }).count()) === 1);
|
||||
check(
|
||||
"model: choice is remembered",
|
||||
(await phone.page.evaluate(() => localStorage.getItem("freezmo.chat.model.v1"))) === "stub-vision",
|
||||
);
|
||||
await phone.page.reload({ waitUntil: "networkidle" });
|
||||
await phone.page.waitForTimeout(1200);
|
||||
check("model: remembered across reloads", (await phone.page.getByRole("button", { name: "stub-vision" }).count()) === 1);
|
||||
check("model: remembered across reloads", (await phone.page.getByRole("button", { name: "Stub Vision" }).count()) === 1);
|
||||
|
||||
await phone.page.getByRole("button", { name: "1 deck" }).click();
|
||||
await phone.page.waitForTimeout(400);
|
||||
|
|
|
|||
|
|
@ -287,6 +287,34 @@ const AXE = axeSource();
|
|||
await context.close();
|
||||
}
|
||||
|
||||
/* 6e. reduced motion: off by default, toggled in settings -------------- */
|
||||
{
|
||||
const { context, page } = await makePage(browser, { width: 390, height: 844 });
|
||||
await seed(page, { decks: [DECK], theme: "aero" });
|
||||
await page.goto(BASE, { waitUntil: "networkidle" });
|
||||
await page.waitForTimeout(900);
|
||||
check(
|
||||
"motion: reduced motion is off by default",
|
||||
!(await page.evaluate(() => document.documentElement.classList.contains("reduce-motion"))),
|
||||
);
|
||||
await page.locator("nav.tabbar").getByRole("button").click();
|
||||
await page.getByRole("dialog").waitFor({ timeout: 6000 });
|
||||
await page.waitForTimeout(500);
|
||||
await page.getByRole("switch", { name: "Reduce motion" }).click();
|
||||
await page.waitForTimeout(500);
|
||||
const motion = await page.evaluate(() => ({
|
||||
cls: document.documentElement.classList.contains("reduce-motion"),
|
||||
bubble: getComputedStyle(document.querySelector(".ice-bubble")).animationName,
|
||||
stored: localStorage.getItem("freezmo.reduce-motion.v1"),
|
||||
}));
|
||||
check(
|
||||
"motion: the toggle parks the scene and persists",
|
||||
motion.cls && motion.bubble === "none" && motion.stored === "1",
|
||||
JSON.stringify(motion),
|
||||
);
|
||||
await context.close();
|
||||
}
|
||||
|
||||
/* 6d. touch devices blur the real backdrop (the baked layer mispaints) - */
|
||||
{
|
||||
const { context, page } = await makePage(browser, { width: 390, height: 844, isMobile: true, hasTouch: true });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue