freezmo/components/SettingsDialog.tsx
Martin Dvorak df34337b4a Accessibility pass, report control for homework exercises, clearer rail actions
- axe is clean across every screen in both themes, phone and desktop. Fixed:
  the hidden file inputs (aria-hidden + tabIndex -1 — they are opened by
  labelled buttons, so they should not be in the accessibility tree), the
  heading order (deck/homework/smart-review cards are h2 under the page h1;
  LearningSection takes a level so it stays h2 on the library and h3 inside
  the settings dialog) and landmarks around the study and review feeds.
- tests/contrast.mjs (new diagnostic): samples rendered pixels, because axe
  cannot judge text on blurred glass — it reports those nodes as incomplete.
  It skips disabled controls (1.4.3 exempts them) and content behind an open
  sheet. Result: no below-minimum text on any scanned screen.
- The report control exists in the homework guide too: under each "Try it
  yourself" exercise, labelled "Report exercise", without a skip (there is no
  queue to skip). lib/question-reports now takes deckId/deckTitle directly.
- The rail's two creation actions are buttons now (.nav-action) rather than
  quiet nav rows — once the library is full they were the only way to start
  something on desktop and read as just more navigation. "Make my homework"
  is "New homework" wherever it is an action (rail, empty state, share
  chooser, manifest shortcut); the page title and mode switch keep their
  wording.

Version 0.1.6 (three task bumps), SW -> freezmo-v52.
2026-10-12 21:15:56 +00:00

1036 lines
48 KiB
TypeScript

"use client";
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 { 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 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 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>
{/* ------------------------------------------- data & sync */}
<LearningSection level={3} />
<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>
{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>
{/* ------------------------------------------- data & sync */}
<LearningSection level={3} />
<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}
{/* 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>
);
}