- 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.
1107 lines
51 KiB
TypeScript
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>
|
|
);
|
|
}
|