freezmo/components/SettingsDialog.tsx
Martin Dvorak 6635b5d405 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.
2026-10-14 05:06:02 +00:00

1107 lines
51 KiB
TypeScript

"use client";
import Link from "next/link";
import { AnimatePresence, motion, useDragControls, useReducedMotion } from "framer-motion";
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
import { LOCALES, useI18n, type Locale } from "@/lib/i18n";
import { useVersion } from "@/lib/use-version";
import { haptic } from "@/lib/haptics";
import { modelErrorCopy } from "@/lib/model-errors";
import { springSheet } from "@/lib/motion";
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";
import { cn } from "@/lib/utils";
import { openInstallSheet, useInstallAvailable } from "@/lib/install";
import { useHealth } from "@/lib/use-health";
import {
AlertTriangleIcon,
CheckCircleIcon,
ChevronDownIcon,
ChevronRightIcon,
DownloadIcon,
EyeIcon,
EyeOffIcon,
RefreshIcon,
XCircleIcon,
} from "@/components/icons";
import { Button, Spinner } from "@/components/ui";
import { SegmentedPill } from "@/components/SegmentedPill";
/** “Checked just now”, or the clock time once the result is older. */
function freshnessLabel(checkedAt: number, locale: Locale, t: (key: string, vars?: Record<string, string>) => string) {
if (Date.now() - checkedAt < 90_000) return t("health.checkedNow");
const time = new Date(checkedAt).toLocaleTimeString(locale === "cs" ? "cs-CZ" : "en-US", {
hour: "2-digit",
minute: "2-digit",
});
return t("health.checkedAt", { time });
}
export function SettingsDialog({
open,
onClose,
focusField,
}: {
open: boolean;
onClose: () => void;
/** Set by openSettings("key") — focuses the key input once the sheet opens. */
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();
const keyInputRef = useRef<HTMLInputElement>(null);
const settings = useSyncExternalStore(
settingsStore.subscribe,
settingsStore.getSnapshot,
settingsStore.getServerSnapshot,
);
/* The system Back gesture closes the sheet instead of leaving the page. */
useBackClose(open, onClose);
/* "Open Settings" from a usage-limit error lands right on the key field. */
useEffect(() => {
if (!open || focusField?.field !== "key") return;
const timer = window.setTimeout(() => {
keyInputRef.current?.scrollIntoView({ block: "center", behavior: "smooth" });
keyInputRef.current?.focus({ preventScroll: true });
}, 380);
return () => window.clearTimeout(timer);
}, [open, focusField]);
const [keyDraft, setKeyDraft] = useState(apiKey);
const [reveal, setReveal] = useState(false);
const [saved, setSaved] = useState(false);
const [confirmRemove, setConfirmRemove] = useState(false);
const [canScroll, setCanScroll] = useState(false);
const [phone, setPhone] = useState(false);
const dragControls = useDragControls();
const reduceMotion = useReducedMotion();
const scrollRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
// Swipe-to-dismiss is a phone gesture; the desktop dialog is unchanged.
useEffect(() => {
const query = window.matchMedia("(max-width: 639px)");
const sync = () => setPhone(query.matches);
sync();
query.addEventListener("change", sync);
return () => query.removeEventListener("change", sync);
}, []);
useEffect(() => {
if (!open) return;
setKeyDraft(apiKey);
setSaved(false);
setConfirmRemove(false);
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, apiKey, onClose]);
// The scroll cue: a small chevron that fades in whenever more content is
// below the fold, and out once the bottom is reached.
const updateCue = () => {
const element = scrollRef.current;
if (!element) return;
setCanScroll(element.scrollHeight - element.scrollTop - element.clientHeight > 24);
};
useEffect(() => {
if (!open) return;
const element = scrollRef.current;
const content = contentRef.current;
if (!element) return;
updateCue();
const observer = new ResizeObserver(updateCue);
if (content) observer.observe(content);
return () => observer.disconnect();
}, [open]);
// Drag only from the handle/header, and only while the body is at the top.
const startSheetDrag = (event: React.PointerEvent) => {
if (!phone) return;
if ((event.target as HTMLElement).closest("button, a, input, summary")) return;
const element = scrollRef.current;
if (element && element.scrollTop > 2) return;
dragControls.start(event);
};
const save = () => {
saveSettings({ apiKey: keyDraft.trim() });
setSaved(true);
window.setTimeout(() => setSaved(false), 2500);
};
const forget = () => {
clearApiKey();
setKeyDraft("");
setConfirmRemove(false);
};
const needsKeyMode = health?.authMode === "user-key";
/* Phones: one status row instead of the whole health panel. */
const modelsReady = health ? [health.vision.available, health.text.available].filter((value) => value === true).length : 0;
const statusText = !health
? t("settings.checking")
: health.ok
? `${t("settings.connected")} · ${plural(modelsReady, "settings.modelsReady")}`
: health.needsKey
? t("health.needsKey")
: health.keyAccepted === false
? t("health.rejected")
: health.reachable
? t("health.modelMissing")
: t("health.unreachable");
const statusDot = !health
? "bg-[var(--text-subtle)]"
: health.ok
? "bg-emerald-500"
: health.needsKey || health.reachable
? "bg-amber-500"
: "bg-rose-500";
const modelBadge = (model: { available: boolean | null } | undefined) =>
!model || model.available === null
? { text: t("health.unknown"), className: "bg-[var(--surface-sunken)] text-[var(--text-muted)]" }
: model.available
? { text: t("health.available"), className: "bg-emerald-100 text-emerald-900 dark:bg-emerald-400/20 dark:text-emerald-200" }
: { text: t("health.notFound"), className: "bg-rose-50 text-rose-800 dark:bg-rose-500/15 dark:text-rose-200" };
return (
<AnimatePresence>
{open ? (
<motion.div
className="fixed inset-0 z-50 flex items-end justify-center bg-frost-950/35 p-0 backdrop-blur-md sm:items-center sm:p-6"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
>
<motion.div
role="dialog"
aria-modal="true"
aria-labelledby="settings-title"
drag={phone ? "y" : false}
dragControls={dragControls}
dragListener={false}
dragMomentum={false}
dragConstraints={{ top: 0, bottom: 0 }}
dragElastic={{ top: 0, bottom: 0.55 }}
onDragEnd={(_, gesture) => {
if (gesture.offset.y > 90 || gesture.delta.y > 140 || gesture.velocity.y > 700) onClose();
}}
initial={reduceMotion ? { opacity: 0 } : phone ? { y: "100%", opacity: 0 } : { y: 48, opacity: 0 }}
animate={reduceMotion ? { opacity: 1 } : { y: 0, opacity: 1 }}
exit={reduceMotion ? { opacity: 0 } : phone ? { y: "100%", opacity: 0 } : { y: 32, opacity: 0 }}
transition={phone ? springSheet : { type: "spring", stiffness: 340, damping: 32 }}
onClick={(event) => event.stopPropagation()}
className="glass-panel glass-sheet relative flex max-h-[90dvh] w-full max-w-lg flex-col overflow-hidden rounded-t-[1.6rem] shadow-2xl sm:rounded-[1.35rem]"
>
{/* handle + header: the drag strip. Fixed, so the body scrolls. */}
<div
onPointerDown={startSheetDrag}
className={cn("shrink-0 px-5 pt-2", phone && "cursor-grab touch-none active:cursor-grabbing")}
>
<div aria-hidden className="mx-auto flex h-6 w-full items-center justify-center">
<span className="h-1 w-9 rounded-full bg-[var(--text-subtle)] opacity-40" />
</div>
<div className="mb-3 flex items-start justify-between gap-4">
<div>
<h2 id="settings-title" className="text-lg font-semibold tracking-[-0.02em] text-frost-950 dark:text-frost-50">
{t("settings.title")}
</h2>
{!phone ? (
<p className="text-sm text-frost-600 dark:text-frost-300">
{t("settings.subtitle.before")}{" "}
<code className="rounded bg-frost-100 px-1 dark:bg-white/10">.env.local</code>{" "}
{t("settings.subtitle.after")}
</p>
) : null}
</div>
<Button
variant="ghost"
size="sm"
className="touch-target -mr-1 shrink-0"
onClick={onClose}
aria-label={t("settings.close")}
>
✕
</Button>
</div>
</div>
{/* scrollable body: the safe area comes from pb-safe + a spacer */}
<div
ref={scrollRef}
onScroll={updateCue}
className="sheet-scroll thin-scroll min-h-0 flex-1 overflow-y-auto px-5 pb-safe"
>
<div ref={contentRef}>
{phone ? (
<>
{/* --------------------------------------------- language */}
<section className="mb-4 space-y-2 rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] p-3">
<h3 className="text-sm font-semibold text-frost-950 dark:text-frost-50">
{t("language.label")}
</h3>
<div className="segmented" role="group" aria-label={t("language.label")}>
{LOCALES.map((entry) => (
<button
key={entry.id}
type="button"
aria-pressed={locale === entry.id}
onClick={() => {
haptic("toggle");
setLocale(entry.id);
}}
className={cn("relative", phone && "seg-slide")}
>
{phone && locale === entry.id ? <SegmentedPill layoutId="language-pill" /> : null}
<span className="seg-label relative z-10">{entry.label}</span>
</button>
))}
</div>
</section>
{/* ------------------------------------------- connection */}
<section className="mb-4 space-y-3 rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] p-3">
<div className="flex items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-frost-950 dark:text-frost-50">
{t("settings.connection")}
</h3>
<button
type="button"
onClick={() => void refresh()}
aria-label={t("settings.recheck")}
title={t("settings.recheck")}
className="touch-target grid size-9 place-items-center rounded-full text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
>
<RefreshIcon className={cn("size-4", loading && "animate-spin")} />
</button>
</div>
<div className="flex items-center gap-2 text-[13px] font-medium" role="status">
<span aria-hidden className={cn("size-2.5 shrink-0 rounded-full", statusDot)} />
<span className="min-w-0 flex-1 text-[var(--text)]">{statusText}</span>
</div>
{/* monthly usage used up / short-term throttle */}
{health?.kind === "usage_limit" || health?.kind === "throttled" ? (
<div className="rounded-xl border border-amber-300 bg-amber-50 p-2.5 text-[12px] leading-relaxed text-amber-900 dark:border-amber-400/40 dark:bg-amber-400/10 dark:text-amber-100">
<p className="font-bold">{modelErrorCopy(health.kind)?.title}</p>
<p className="mt-0.5">{modelErrorCopy(health.kind)?.body}</p>
<div className="mt-2 flex flex-wrap gap-2">
<ModelErrorActions kind={health.kind} />
</div>
</div>
) : null}
<div className="relative">
<label className="sr-only" htmlFor="ollama-api-key-phone">
{t("settings.apiKey")}
</label>
<input
id="ollama-api-key-phone"
ref={keyInputRef}
type={reveal ? "text" : "password"}
value={keyDraft}
onChange={(event) => setKeyDraft(event.target.value)}
onKeyDown={(event) => {
event.stopPropagation();
if (event.key === "Enter") save();
}}
placeholder={t("settings.pasteKey")}
autoComplete="off"
spellCheck={false}
className="w-full rounded-xl border border-[var(--hairline)] bg-[var(--surface-strong)] py-3 pl-3 pr-12 font-mono text-xs text-frost-950 outline-none transition placeholder:font-sans placeholder:text-[var(--text-subtle)] focus:border-accent-500 dark:text-frost-50"
/>
<button
type="button"
onClick={() => setReveal((value) => !value)}
aria-label={reveal ? t("settings.hide") : t("settings.show")}
title={reveal ? t("settings.hide") : t("settings.show")}
className="absolute right-0.5 top-1/2 grid size-11 -translate-y-1/2 place-items-center rounded-lg text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
>
{reveal ? <EyeOffIcon className="size-4" /> : <EyeIcon className="size-4" />}
</button>
</div>
{keyDraft.trim() !== apiKey ? (
<Button size="sm" className="touch-target w-full" onClick={save}>
{t("common.save")}
</Button>
) : null}
<details className="group">
<summary className="touch-target flex cursor-pointer list-none flex-wrap items-center gap-1.5 text-[11px] font-medium text-[var(--text-muted)] [&::-webkit-details-marker]:hidden">
{t("settings.privacyLine")}
<span className="inline-flex items-center gap-0.5 font-semibold text-[var(--text)] underline underline-offset-2">
{t("common.more")}
<ChevronDownIcon className="size-3 transition-transform group-open:rotate-180" />
</span>
</summary>
<div className="space-y-1.5 pt-1.5">
<p className="text-[11px] leading-relaxed text-[var(--text-subtle)]">{t("settings.keyNote")}</p>
<p className="text-[11px] leading-relaxed text-[var(--text-subtle)]">
{t("settings.localTipTitle")} {t("settings.localTipSet")}{" "}
<code>OLLAMA_AUTH=local</code> {t("settings.keyNoteAfter")} <code>.env.local</code>{" "}
{t("settings.keyNoteEnd")} <code>ollama signin</code>
{t("settings.keyNoteEnd2")}
</p>
</div>
</details>
{/* haptics: best-effort vibration feedback */}
<div className="flex items-center justify-between gap-3 border-t border-[var(--hairline-soft)] pt-3">
<span className="min-w-0">
<span className="block text-[13px] font-semibold text-[var(--text)]">
{t("settings.haptics")}
</span>
<span className="block text-[11.5px] leading-snug text-[var(--text-subtle)]">
{t("settings.hapticsNote")}
</span>
</span>
<button
type="button"
role="switch"
aria-checked={settings.haptics}
aria-label={t("settings.haptics")}
onClick={() => {
saveSettings({ haptics: !settings.haptics });
haptic("toggle");
}}
className={cn(
"touch-target relative h-7 w-12 shrink-0 rounded-full border transition-colors",
settings.haptics
? "grad-ice border-transparent"
: "border-[var(--hairline)] bg-[var(--surface-sunken)]",
)}
>
<span
aria-hidden
className={cn(
"absolute top-1/2 size-5 -translate-y-1/2 rounded-full bg-white shadow transition-[left] duration-200",
settings.haptics ? "left-6" : "left-0.5",
)}
/>
</button>
</div>
{/* chat grounding: may chat read the notes text? */}
<ToggleRow
label={t("settings.chatNotes")}
note={t("settings.chatNotesNote")}
checked={settings.chatNotes}
onChange={() => {
saveSettings({ chatNotes: !settings.chatNotes });
haptic("toggle");
}}
/>
</section>
{/* ------------------------------------------- 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} />
{/* ------------------------------------------- data & sync */}
<DataSection />
<SyncSection />
{/* ----------------------------------------------- install */}
{installAvailable ? (
<section className="mb-4 rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] p-1.5">
<button
type="button"
onClick={() => {
onClose();
openInstallSheet();
}}
className="flex w-full items-center gap-3 rounded-xl px-2.5 py-2 text-left transition hover:bg-[var(--hover)]"
>
<span
aria-hidden
className="grid size-9 shrink-0 place-items-center rounded-xl border border-[var(--hairline)] bg-[var(--surface)] text-[var(--text-muted)]"
>
<DownloadIcon className="size-4" />
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-semibold text-frost-950 dark:text-frost-50">
{t("settings.installApp")}
</span>
<span className="block text-[12px] text-[var(--text-subtle)]">
{t("settings.addToHome")}
</span>
</span>
<ChevronRightIcon className="size-4 shrink-0 text-[var(--text-subtle)]" />
</button>
</section>
) : null}
{error ? (
<div className="mb-4 rounded-2xl bg-rose-100 p-3 text-sm text-rose-900 dark:bg-rose-500/15 dark:text-rose-200">
{error}
</div>
) : null}
{/* ----------------------------------------------- details */}
<details className="group mb-4 rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] p-3">
<summary className="touch-target flex cursor-pointer list-none items-center justify-between gap-2 text-sm font-semibold text-frost-950 dark:text-frost-50 [&::-webkit-details-marker]:hidden">
{t("settings.details")}
<ChevronDownIcon className="size-4 text-[var(--text-subtle)] transition-transform group-open:rotate-180" />
</summary>
<div className="pt-2">
{loading ? (
<p className="flex items-center gap-2 pb-2 text-[12px] text-[var(--text-muted)]">
<Spinner className="size-3.5" /> {t("settings.checking")}
</p>
) : checkedAt ? (
<p className="pb-2 text-[11px] font-medium text-[var(--text-subtle)]">
{freshnessLabel(checkedAt, locale, t)}
</p>
) : null}
<dl>
<DetailRow
label={t("health.auth")}
value={
health
? health.authMode === "user-key"
? t("health.authUserKey")
: health.authMode === "server"
? t("health.authServer")
: t("health.authLocal")
: "—"
}
/>
<DetailRow label={t("health.endpoint")} value={health?.baseUrl ?? "—"} mono />
<DetailRow
label={t("health.mode")}
value={
health
? health.mode === "direct-cloud"
? t("health.modeCloud")
: t("health.modeLocal")
: "—"
}
/>
<DetailRow
label={t("health.key")}
value={
health
? health.needsKey
? t("health.keyMissing")
: health.keyAccepted === true
? t("health.keyAccepted")
: health.keyAccepted === false
? t("health.keyRejected")
: health.authMode === "local"
? t("health.keyNotNeeded")
: t("health.keyUnknown")
: "—"
}
/>
<DetailRow
label={t("health.visionModel")}
value={health?.vision.resolved ?? "—"}
mono
badge={modelBadge(health?.vision)}
/>
<DetailRow
label={t("health.textModel")}
value={health?.text.resolved ?? "—"}
mono
badge={modelBadge(health?.text)}
/>
{version ? (
<DetailRow
label={t("settings.version")}
value={[
version.version ? `v${version.version}` : "",
version.short,
version.builtAt ? new Date(version.builtAt).toLocaleString() : "",
]
.filter(Boolean)
.join(" · ")}
mono
/>
) : null}
</dl>
{/* Legal: the same three documents the cookie banner
links to, always reachable from Settings. */}
<div className="pt-3">
<p className="text-[11px] font-semibold uppercase tracking-wider text-[var(--text-subtle)]">
{t("settings.legal")}
</p>
<div className="mt-1.5 flex flex-wrap gap-x-3 gap-y-1 text-[12.5px] font-semibold">
<Link href="/privacy" className="text-[var(--text)] underline decoration-dotted">
{t("legal.privacy")}
</Link>
<Link href="/terms" className="text-[var(--text)] underline decoration-dotted">
{t("legal.terms")}
</Link>
<Link href="/cookies" className="text-[var(--text)] underline decoration-dotted">
{t("legal.cookies")}
</Link>
</div>
</div>
{health?.mode === "direct-cloud" && health.needsKey ? (
<p className="pt-2 text-[11px] leading-relaxed text-[var(--text-subtle)]">
{t("health.createKey.before")}{" "}
<a
className="font-semibold underline underline-offset-2"
href="https://ollama.com/settings/keys"
target="_blank"
rel="noreferrer"
>
ollama.com/settings/keys
</a>{" "}
{t("health.createKey.after")} <code>ollama signin</code> {t("health.createKey.end")}{" "}
<code>OLLAMA_AUTH=local</code>.
</p>
) : null}
</div>
</details>
{/* ------------------------------- remove key (very bottom) */}
{apiKey ? (
confirmRemove ? (
<div className="rounded-xl border border-rose-300/70 bg-rose-50 p-3 dark:border-rose-400/25 dark:bg-rose-500/10">
<p className="text-[12px] leading-relaxed text-rose-900 dark:text-rose-100">
{t("settings.removeKeyConfirm")}
</p>
<div className="mt-1.5 flex flex-wrap gap-2">
<Button size="sm" variant="danger" className="touch-target" onClick={forget}>
{t("settings.removeKeyYes")}
</Button>
<Button
size="sm"
variant="ghost"
className="touch-target"
onClick={() => setConfirmRemove(false)}
>
{t("common.cancel")}
</Button>
</div>
</div>
) : (
<button
type="button"
onClick={() => setConfirmRemove(true)}
className="touch-target mx-auto block px-3 text-center text-[12.5px] font-medium text-rose-700 underline-offset-2 hover:underline dark:text-rose-300"
>
{t("settings.removeKey")}
</button>
)
) : null}
<div className="h-9" aria-hidden />
</>
) : (
<>
{/* --------------------------------------------- language */}
<section className="mb-4 space-y-2 rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] p-3">
<h3 className="text-sm font-semibold text-frost-950 dark:text-frost-50">
{t("language.label")}
</h3>
<div className="segmented" role="group" aria-label={t("language.label")}>
{LOCALES.map((entry) => (
<button
key={entry.id}
type="button"
aria-pressed={locale === entry.id}
onClick={() => setLocale(entry.id)}
>
{entry.label}
</button>
))}
</div>
<p className="text-[11px] leading-relaxed text-[var(--text-subtle)]">
{t("language.note")}
</p>
</section>
{/* ------------------------------------------------ API key */}
<section className="mb-4 space-y-3 rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] p-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-frost-950 dark:text-frost-50">
{t("settings.apiKey")}
</h3>
{apiKey ? (
<span className="rounded-full bg-emerald-100 px-2 py-0.5 text-[11px] font-bold text-emerald-900 dark:bg-emerald-400/20 dark:text-emerald-200">
{t("settings.savedInBrowser")}
</span>
) : needsKeyMode ? (
<span className="rounded-full bg-amber-50 px-2 py-0.5 text-[11px] font-semibold text-amber-800 dark:bg-amber-500/15 dark:text-amber-200">
{t("settings.required")}
</span>
) : (
<span className="rounded-full bg-[var(--surface-sunken)] px-2 py-0.5 text-[11px] font-semibold text-[var(--text-muted)]">
{t("settings.notNeeded")}
</span>
)}
</div>
{/* Phones: the field gets its own row, the two actions stack
underneath at full touch-target size. */}
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
<label className="sr-only" htmlFor="ollama-api-key">
{t("settings.apiKey")}
</label>
<input
id="ollama-api-key"
ref={keyInputRef}
type={reveal ? "text" : "password"}
value={keyDraft}
onChange={(event) => setKeyDraft(event.target.value)}
onKeyDown={(event) => {
event.stopPropagation();
if (event.key === "Enter") save();
}}
placeholder={t("settings.pasteKey")}
autoComplete="off"
spellCheck={false}
className="w-full min-w-0 rounded-xl border border-[var(--hairline)] bg-[var(--surface-strong)] px-3 py-2 font-mono text-xs text-frost-950 outline-none transition placeholder:font-sans placeholder:text-[var(--text-subtle)] focus:border-accent-500 dark:text-frost-50 sm:min-w-40 sm:flex-1"
/>
<div className="flex items-center gap-2 sm:contents">
<Button
size="sm"
variant="secondary"
className="touch-target flex-1 sm:flex-none"
onClick={() => setReveal((value) => !value)}
>
{reveal ? t("settings.hide") : t("settings.show")}
</Button>
<Button
size="sm"
className="touch-target flex-1 sm:flex-none"
onClick={save}
disabled={keyDraft.trim() === apiKey}
>
{t("common.save")}
</Button>
</div>
</div>
<div className="space-y-2">
<p className="text-[11px] leading-relaxed text-[var(--text-subtle)]">
{t("settings.keyNote")}
</p>
<details className="group">
<summary className="touch-target -mt-1 flex cursor-pointer list-none items-center gap-1.5 text-[11px] font-medium text-[var(--text-muted)] transition hover:text-[var(--text)] [&::-webkit-details-marker]:hidden">
<ChevronDownIcon className="size-3.5 shrink-0 transition-transform group-open:rotate-180" />
{t("settings.localTipTitle")}
</summary>
<p className="pt-0.5 text-[11px] leading-relaxed text-[var(--text-subtle)]">
{t("settings.localTipSet")} <code>OLLAMA_AUTH=local</code>{" "}
{t("settings.keyNoteAfter")} <code>.env.local</code> {t("settings.keyNoteEnd")}{" "}
<code>ollama signin</code>
{t("settings.keyNoteEnd2")}
</p>
</details>
</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<Button
size="sm"
variant="secondary"
className="touch-target"
onClick={() => void refresh()}
loading={loading}
>
{t("settings.testConnection")}
</Button>
{saved ? (
<span className="text-xs font-semibold text-emerald-700 dark:text-emerald-300">
{t("settings.savedTesting")}
</span>
) : null}
{apiKey && !confirmRemove ? (
<Button
size="sm"
variant="ghost"
className="touch-target -ml-2.5 text-rose-700 hover:bg-rose-500/10 dark:text-rose-300"
onClick={() => setConfirmRemove(true)}
>
{t("settings.removeKey")}
</Button>
) : null}
</div>
{apiKey && confirmRemove ? (
<div className="rounded-xl border border-rose-300/70 bg-rose-50 p-3 dark:border-rose-400/25 dark:bg-rose-500/10">
<p className="text-[12px] leading-relaxed text-rose-900 dark:text-rose-100">
{t("settings.removeKeyConfirm")}
</p>
<div className="mt-1.5 flex flex-wrap gap-2">
<Button size="sm" variant="danger" className="touch-target" onClick={forget}>
{t("settings.removeKeyYes")}
</Button>
<Button
size="sm"
variant="ghost"
className="touch-target"
onClick={() => setConfirmRemove(false)}
>
{t("common.cancel")}
</Button>
</div>
</div>
) : null}
</section>
{/* ------------------------------------------- chat grounding */}
<section className="mb-4 rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] p-3">
<ToggleRow
label={t("settings.chatNotes")}
note={t("settings.chatNotesNote")}
checked={settings.chatNotes}
onChange={() => saveSettings({ chatNotes: !settings.chatNotes })}
/>
</section>
{/* ------------------------------------------- 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} />
{/* ------------------------------------------- data & sync */}
<DataSection />
<SyncSection />
{/* ------------------------------------------------ install */}
{installAvailable ? (
<section className="mb-4 rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] p-1.5">
<button
type="button"
onClick={() => {
onClose();
openInstallSheet();
}}
className="flex w-full items-center gap-3 rounded-xl px-2.5 py-2 text-left transition hover:bg-[var(--hover)]"
>
<span
aria-hidden
className="grid size-9 shrink-0 place-items-center rounded-xl border border-[var(--hairline)] bg-[var(--surface)] text-[var(--text-muted)]"
>
<DownloadIcon className="size-4" />
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-semibold text-frost-950 dark:text-frost-50">
{t("settings.installApp")}
</span>
<span className="block text-[12px] text-[var(--text-subtle)]">
{t("settings.addToHome")}
</span>
</span>
<ChevronRightIcon className="size-4 shrink-0 text-[var(--text-subtle)]" />
</button>
</section>
) : null}
{error ? (
<div className="rounded-2xl bg-rose-100 p-3 text-sm text-rose-900 dark:bg-rose-500/15 dark:text-rose-200">
{error}
</div>
) : null}
{loading ? (
<div className="flex items-center gap-3 py-2 text-sm text-[var(--text-muted)]">
<Spinner className="size-4" /> {t("settings.checking")}
</div>
) : checkedAt ? (
<p className="py-1 text-[11px] font-medium text-[var(--text-subtle)]">
{freshnessLabel(checkedAt, locale, t)}
</p>
) : null}
{health ? <HealthPanel health={health} /> : null}
{version ? (
<p className="mt-2 text-[11px] text-[var(--text-subtle)]">
{t("settings.version")}:{" "}
<span className="font-mono">
{version.version ? `v${version.version} · ` : ""}
{version.short}
</span>
{version.builtAt ? ` · ${new Date(version.builtAt).toLocaleString()}` : ""}
</p>
) : null}
{/* Legal: the same three documents the cookie banner links to. */}
<div className="mt-3 flex flex-wrap gap-x-3 gap-y-1 text-[12px] font-semibold">
<Link href="/privacy" className="text-[var(--text-muted)] underline decoration-dotted">
{t("legal.privacy")}
</Link>
<Link href="/terms" className="text-[var(--text-muted)] underline decoration-dotted">
{t("legal.terms")}
</Link>
<Link href="/cookies" className="text-[var(--text-muted)] underline decoration-dotted">
{t("legal.cookies")}
</Link>
</div>
{/* keeps real content out of the 36px fade strip at the bottom */}
<div className="h-9" aria-hidden />
</>
)}
</div>
</div>
{/* scroll cue: a bare chevron inside the fade strip, so it never
covers text (the .sheet-scroll mask fades what is underneath) */}
<div
aria-hidden
className={cn(
"pointer-events-none absolute inset-x-0 bottom-0 flex h-9 items-end justify-center pb-1.5 transition-opacity duration-200",
canScroll ? "opacity-100" : "opacity-0",
)}
>
<ChevronDownIcon className="size-4 text-[var(--text-subtle)]" />
</div>
</motion.div>
</motion.div>
) : null}
</AnimatePresence>
);
}
function HealthPanel({ health }: { health: ReturnType<typeof useHealth>["health"] }) {
const { t } = useI18n();
if (!health) return null;
const tone = health.ok ? "emerald" : health.needsKey ? "amber" : health.reachable ? "amber" : "rose";
const toneClasses: Record<string, string> = {
emerald: "bg-emerald-50 text-emerald-800 dark:bg-emerald-500/15 dark:text-emerald-200",
amber: "bg-amber-50 text-amber-800 dark:bg-amber-500/15 dark:text-amber-200",
rose: "bg-rose-50 text-rose-800 dark:bg-rose-500/15 dark:text-rose-200",
};
return (
<div className="space-y-3">
<div className={cn("flex items-start gap-2 rounded-2xl p-3 text-sm font-medium", toneClasses[tone])}>
<span aria-hidden className="mt-0.5 shrink-0">
{tone === "emerald" ? (
<CheckCircleIcon className="size-5" />
) : tone === "amber" ? (
<AlertTriangleIcon className="size-5" />
) : (
<XCircleIcon className="size-5" />
)}
</span>
<span>
{health.ok
? t("health.ok")
: health.needsKey
? t("health.needsKey")
: health.keyAccepted === false
? t("health.rejected")
: health.reachable
? t("health.modelMissing")
: t("health.unreachable")}
{health.error ? <p className="mt-1 font-normal">{health.error}</p> : null}
{health.kind === "usage_limit" || health.kind === "throttled" ? (
<span className="mt-2 flex flex-wrap gap-2">
<ModelErrorActions kind={health.kind} />
</span>
) : null}
</span>
</div>
<dl className="grid grid-cols-2 gap-2 text-sm">
<Info
label={t("health.auth")}
value={
health.authMode === "user-key"
? t("health.authUserKey")
: health.authMode === "server"
? t("health.authServer")
: t("health.authLocal")
}
/>
<Info label={t("health.endpoint")} value={health.baseUrl} mono />
<Info
label={t("health.mode")}
value={health.mode === "direct-cloud" ? t("health.modeCloud") : t("health.modeLocal")}
/>
<Info
label={t("health.key")}
value={
health.needsKey
? t("health.keyMissing")
: health.keyAccepted === true
? t("health.keyAccepted")
: health.keyAccepted === false
? t("health.keyRejected")
: health.authMode === "local"
? t("health.keyNotNeeded")
: t("health.keyUnknown")
}
/>
</dl>
<div className="space-y-2">
<ModelRow title={t("health.visionModel")} model={health.vision} />
<ModelRow title={t("health.textModel")} model={health.text} />
</div>
{health.mode === "direct-cloud" && health.needsKey ? (
<p className="rounded-2xl bg-[var(--surface-sunken)] p-3 text-xs text-[var(--text-muted)]">
{t("health.createKey.before")}{" "}
<a className="font-semibold underline underline-offset-2" href="https://ollama.com/settings/keys" target="_blank" rel="noreferrer">
ollama.com/settings/keys
</a>{" "}
{t("health.createKey.after")} <code>ollama signin</code> {t("health.createKey.end")}{" "}
<code>OLLAMA_AUTH=local</code>.
</p>
) : null}
</div>
);
}
function DetailRow({
label,
value,
mono,
badge,
}: {
label: string;
value: string;
mono?: boolean;
badge?: { text: string; className: string };
}) {
return (
<div className="flex items-center justify-between gap-3 border-b border-[var(--hairline-soft)] py-1.5 last:border-0">
<dt className="shrink-0 text-[12px] text-[var(--text-subtle)]">{label}</dt>
<dd className={cn("flex min-w-0 items-center justify-end gap-1.5 text-right font-medium text-[var(--text)]", mono && "font-mono text-[11px]")}>
<span className="truncate">{value}</span>
{badge ? (
<span className={cn("shrink-0 rounded-full px-1.5 py-0.5 text-[10px] font-bold", badge.className)}>
{badge.text}
</span>
) : null}
</dd>
</div>
);
}
function Info({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return (
<div className="rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] p-3">
<dt className="text-xs uppercase tracking-wide text-[var(--text-subtle)]">{label}</dt>
<dd className={cn("mt-0.5 break-words font-semibold", mono && "font-mono text-xs")}>{value}</dd>
</div>
);
}
function ModelRow({
title,
model,
}: {
title: string;
model: { configured: string; resolved: string; available: boolean | null; note?: string };
}) {
const { t } = useI18n();
const badge =
model.available === true
? { text: t("health.available"), className: "bg-emerald-100 text-emerald-900 dark:bg-emerald-400/20 dark:text-emerald-200" }
: model.available === false
? { text: t("health.notFound"), className: "bg-rose-50 text-rose-800 dark:bg-rose-500/15 dark:text-rose-200" }
: { text: t("health.unknown"), className: "bg-[var(--surface-sunken)] text-[var(--text-muted)]" };
return (
<div className="rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] p-3">
<div className="flex items-center justify-between gap-2">
<p className="text-xs uppercase tracking-wide text-[var(--text-subtle)]">{title}</p>
<span className={cn("rounded-full px-2 py-0.5 text-[11px] font-bold", badge.className)}>
{badge.text}
</span>
</div>
<p className="mt-1 font-mono text-sm font-semibold">{model.resolved}</p>
{model.configured !== model.resolved ? (
<p className="text-xs text-[var(--text-subtle)]">
{t("health.configuredAs", { model: model.configured })}
</p>
) : null}
{model.note ? <p className="mt-1 text-xs text-amber-700 dark:text-amber-300">{model.note}</p> : null}
</div>
);
}
/** Boolean toggle row used by the chat-grounding setting. */
function ToggleRow({
label,
note,
checked,
onChange,
}: {
label: string;
note: string;
checked: boolean;
onChange: () => void;
}) {
return (
<div className="flex items-center justify-between gap-3 border-t border-[var(--hairline-soft)] pt-3">
<span className="min-w-0">
<span className="block text-[13px] font-semibold text-[var(--text)]">{label}</span>
<span className="block text-[11.5px] leading-snug text-[var(--text-subtle)]">{note}</span>
</span>
<button
type="button"
role="switch"
aria-checked={checked}
aria-label={label}
onClick={onChange}
className={cn(
"touch-target relative h-7 w-12 shrink-0 rounded-full border transition-colors",
checked ? "grad-ice border-transparent" : "border-[var(--hairline)] bg-[var(--surface-sunken)]",
)}
>
<span
aria-hidden
className={cn(
"absolute top-1/2 size-5 -translate-y-1/2 rounded-full bg-white shadow transition-[left] duration-200",
checked ? "left-6" : "left-0.5",
)}
/>
</button>
</div>
);
}