freezmo/components/SettingsDialog.tsx
Martin Dvorak 84a94581dc Key fields: keep the browser's password UI away, fix the keyboard layout
"Trying to use a different provider on the web app glitches out completely" -
an iPhone photo of the first-run gate with iOS's saved-password sheet laid
over it ("password for proxmox...") and the provider field stuck under the
keyboard.

- The key fields (gate, Settings, provider rows) were type=password with
  autocomplete=off, which iOS/Safari and 1Password/LastPass read as a login.
  They now carry autoComplete="new-password", data-form-type="other",
  data-lpignore="true", autoCorrect/autoCapitalize=off and inputMode=text:
  the mask stays, the password overlay goes. The custom base URL gets
  inputMode=url and the same hints.
- The gate's keyboard handling scrolled to the Continue button instead of the
  field being edited, so with the provider rows open the keyboard covered the
  input and the locked wrapper could not be scrolled to it. The wrapper is
  user-scrollable again, alignment anchors on the focused element (Continue
  is the fallback), and the corner controls hide while the keyboard is up
  rather than landing on the card.

providers-test drives a fake visualViewport (340px, installed before mount)
and pins the three layout contracts plus the hardening attributes. All 16
suites green, contrast clean. Version 0.7.2, SW v94.
2026-10-16 10:12:20 +00:00

1123 lines
52 KiB
TypeScript

"use client";
import Link from "next/link";
import { AnimatePresence, motion, useDragControls } from "framer-motion";
import { useAppReducedMotion as useReducedMotion } from "@/lib/motion-pref";
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 { ProvidersSection } from "@/components/ProvidersSection";
import { SyncSection } from "@/components/SyncSection";
import { clearApiKey, saveSettings, settingsStore } from "@/lib/settings";
import { useBackClose } from "@/lib/use-back-close";
import { useIsPhone } from "@/lib/use-phone";
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);
// Swipe-to-dismiss is a phone gesture; the desktop dialog is unchanged.
const phone = useIsPhone();
const dragControls = useDragControls();
const reduceMotion = useReducedMotion();
const scrollRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
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="scene-pause 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={reduceMotion ? false : { opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={reduceMotion ? { duration: 0 } : { duration: 0.18 }}
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={reduceMotion ? { duration: 0 } : 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")}
/* Not a login: keep the browser's password UI away
(see the gate for the same treatment). */
autoComplete="new-password"
autoCorrect="off"
autoCapitalize="off"
spellCheck={false}
inputMode="text"
data-form-type="other"
data-lpignore="true"
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>
{/* --------------------------------------- providers */}
<ProvidersSection />
{/* ----------------------------------------- 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")}
/* Not a login: keep the browser's password UI away
(see the gate for the same treatment). */
autoComplete="new-password"
autoCorrect="off"
autoCapitalize="off"
spellCheck={false}
inputMode="text"
data-form-type="other"
data-lpignore="true"
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>
{/* --------------------------------------- providers */}
<ProvidersSection />
{/* ----------------------------------------- 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>
);
}