Mobile UI pass: sheet swipe, ice-scene layering, contrast, touch targets

Phones (<=639px) only for layout/gestures; the theme-level fixes apply
everywhere, as they are token/design changes.

Settings sheet
- Swipe-to-dismiss reusing the InstallSheet gesture: the sheet follows the
  finger, closes past ~90px or on a fast flick, snaps back otherwise. Drag
  starts only from the handle/header and only while the body is scrolled to
  the top. Backdrop tap still closes.
- Fixed header + scrollable body: pb-safe + spacer, so nothing is clipped;
  a scroll cue (chevron) fades in while more content is below and out at
  the end.
- Show/Save stack under the field at 44px on phones; close, Test connection
  and Remove key are 44px too. Remove key is now a rose destructive action
  with a confirmation that the full-screen key setup comes back.
- API key note split into a short privacy line plus a collapsible
  'Using the local daemon?' tip (OLLAMA_AUTH=local / .env.local / signin).
- Fixed the missing space in the English tip ('ollama signin instead.').
- Health: kept the auto-check and banner; added a loading row and a
  'Checked just now' / 'Checked at HH:MM' freshness label (also for errors).

Ice scene / readability
- Verified the orbs were never above content (fixed z-index:-1 layer,
  pointer-events:none); they were just shining through the glass. Phones get
  lower blob/orb opacity and orbs tucked to the edges; the bottom-right orb
  is moved out from under the tab bar and the tab bar paints a more opaque
  surface so its labels hold contrast.

Contrast / themes
- Dark --text-subtle raised to #9db1d0 (8.6:1 page, 7.2:1 glass); helper
  text now uses the tokens.
- Disabled buttons are a visible muted chip (.btn-disabled) instead of a
  40%-opacity ghost.
- One ice primary gradient in both themes; danger/success now actually show
  their colour (bg-none strips the primary background-image that used to
  paint over !bg-rose-600).
- Light mode: cool card rims instead of white-on-white, stronger shadow.

Redundancy / touch
- Removed only the header gear; the hamburger drawer (folders, Settings,
  theme) and the tab-bar Settings stay.
- Theme toggle is a bordered chip with an action aria-label (drawer row too).
- Empty library hides the folder pills, demotes Import to a quiet link and
  keeps a single primary CTA. 44px targets for nav rows, segmented control
  and folder pills on phones.
- Fixed a pre-existing 16px horizontal overflow (header -mx-4 vs a
  padding-less parent) and let info values wrap instead of truncating.

Verified: mobile suite 47/47 (EN+CS, 360/390px), pixel-sampled contrast
audit 0 runs below WCAG AA (8 runs, both themes/locales; axe marks the glass
'incomplete'), i18n 27/27, desktop regression 72/72, lint + build clean.
Service worker cache bumped to freezmo-v5.
This commit is contained in:
Martin Dvořák 2026-10-08 17:37:51 +00:00
commit 9fb728d735
12 changed files with 519 additions and 235 deletions

View file

@ -1,28 +1,62 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
import { useEffect, useState } from "react";
import { AnimatePresence, motion, useDragControls, useReducedMotion } from "framer-motion";
import { useEffect, useRef, useState } from "react";
import { LOCALES, useI18n } from "@/lib/i18n";
import { LOCALES, useI18n, type Locale } from "@/lib/i18n";
import { cn } from "@/lib/utils";
import { openInstallSheet, useInstallAvailable } from "@/lib/install";
import { clearApiKey, saveSettings } from "@/lib/settings";
import { useHealth } from "@/lib/use-health";
import { AlertTriangleIcon, CheckCircleIcon, ChevronRightIcon, DownloadIcon, XCircleIcon } from "@/components/icons";
import {
AlertTriangleIcon,
CheckCircleIcon,
ChevronDownIcon,
ChevronRightIcon,
DownloadIcon,
XCircleIcon,
} from "@/components/icons";
import { Button, Spinner } from "@/components/ui";
/** “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 }: { open: boolean; onClose: () => void }) {
const { health, loading, error, refresh, apiKey } = useHealth();
const { health, loading, error, refresh, apiKey, checkedAt } = useHealth();
const installAvailable = useInstallAvailable();
const { locale, setLocale, t } = useI18n();
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();
};
@ -30,6 +64,34 @@ export function SettingsDialog({ open, onClose }: { open: boolean; onClose: () =
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);
@ -39,6 +101,7 @@ export function SettingsDialog({ open, onClose }: { open: boolean; onClose: () =
const forget = () => {
clearApiKey();
setKeyDraft("");
setConfirmRemove(false);
};
const needsKeyMode = health?.authMode === "user-key";
@ -57,163 +120,274 @@ export function SettingsDialog({ open, onClose }: { open: boolean; onClose: () =
role="dialog"
aria-modal="true"
aria-labelledby="settings-title"
initial={{ y: 48, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 32, opacity: 0 }}
transition={{ type: "spring", stiffness: 340, damping: 32 }}
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 ? { duration: 0.34, ease: [0.22, 1, 0.36, 1] } : { type: "spring", stiffness: 340, damping: 32 }}
onClick={(event) => event.stopPropagation()}
className="glass-panel thin-scroll max-h-[90dvh] w-full max-w-lg overflow-y-auto rounded-t-[1.6rem] p-5 pb-safe shadow-2xl sm:rounded-[1.35rem] sm:pb-5"
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]"
>
<div className="sheet-grabber sm:hidden" aria-hidden />
<div className="mb-4 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>
<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>
{/* 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>
<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>
</div>
<Button
variant="ghost"
size="sm"
className="touch-target -mr-1 shrink-0"
onClick={onClose}
aria-label={t("settings.close")}
>
✕
</Button>
</div>
<Button variant="ghost" size="sm" onClick={onClose} aria-label={t("settings.close")}>
✕
</Button>
</div>
{/* --------------------------------------------- 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-frost-500 dark:text-frost-400">
{t("language.note")}
</p>
</section>
{/* 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}>
{/* --------------------------------------------- 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-2 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.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-frost-600 dark:text-frost-300">
{t("settings.notNeeded")}
</span>
)}
</div>
{/* ------------------------------------------------ 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>
<div className="flex flex-wrap items-center gap-2">
<label className="sr-only" htmlFor="ollama-api-key">
{t("settings.apiKey")}
</label>
<input
id="ollama-api-key"
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="min-w-40 flex-1 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-frost-400 focus:border-accent-500 dark:text-frost-50"
/>
<Button size="sm" variant="secondary" onClick={() => setReveal((value) => !value)}>
{reveal ? t("settings.hide") : t("settings.show")}
</Button>
<Button size="sm" onClick={save} disabled={keyDraft.trim() === apiKey}>
{t("common.save")}
</Button>
</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"
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>
<p className="text-[11px] leading-relaxed text-frost-500 dark:text-frost-400">
{t("settings.keyNote")} <code>OLLAMA_AUTH=local</code> {t("settings.keyNoteAfter")}{" "}
<code>.env.local</code> {t("settings.keyNoteEnd")} <code>ollama signin</code>
{t("settings.keyNoteEnd2")}
</p>
<p className="text-[11px] leading-relaxed text-[var(--text-subtle)]">
{t("settings.keyNote")}
</p>
<div className="flex flex-wrap items-center gap-2 pt-1">
<Button size="sm" variant="secondary" onClick={() => void refresh()} loading={loading}>
{t("settings.testConnection")}
</Button>
{apiKey ? (
<Button size="sm" variant="ghost" onClick={forget}>
{t("settings.removeKey")}
</Button>
<details className="group">
<summary className="touch-target flex cursor-pointer list-none items-center gap-1.5 py-1 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-1 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 className="flex flex-wrap items-center gap-2 pt-0.5">
<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}
</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-2 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
size="sm"
variant="ghost"
className="touch-target -ml-3 text-rose-700 hover:bg-rose-500/10 dark:text-rose-300"
onClick={() => setConfirmRemove(true)}
>
{t("settings.removeKey")}
</Button>
)
) : null}
</section>
{/* ------------------------------------------------ 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}
{saved ? (
<span className="text-xs font-semibold text-emerald-700 dark:text-emerald-300">
{t("settings.savedTesting")}
</span>
{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}
{/* breathing room for the scroll cue + safe area */}
<div className="h-5" aria-hidden />
</div>
</section>
</div>
{/* ------------------------------------------------ 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}
{loading && !health ? (
<div className="flex items-center gap-3 py-6 text-sm text-frost-600 dark:text-frost-300">
<Spinner className="size-5" /> {t("settings.checking")}
</div>
) : 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}
{health ? <HealthPanel health={health} /> : null}
{/* visible scroll cue */}
<div
aria-hidden
className={cn(
"pointer-events-none absolute inset-x-0 bottom-2 flex justify-center transition-opacity duration-200",
canScroll ? "opacity-100" : "opacity-0",
)}
>
<span className="grid size-7 place-items-center rounded-full border border-[var(--hairline)] bg-[var(--surface-strong)] text-[var(--text-muted)] shadow-[0_4px_12px_-6px_rgb(14_21_40/0.5)] backdrop-blur-md">
<ChevronDownIcon className="size-3.5" />
</span>
</div>
</motion.div>
</motion.div>
) : null}
@ -295,7 +469,7 @@ function HealthPanel({ health }: { health: ReturnType<typeof useHealth>["health"
</div>
{health.mode === "direct-cloud" && health.needsKey ? (
<p className="rounded-2xl bg-[var(--surface-sunken)] p-3 text-xs text-frost-700 dark:text-frost-200">
<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
@ -311,8 +485,8 @@ function HealthPanel({ health }: { health: ReturnType<typeof useHealth>["health"
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-frost-500 dark:text-frost-400">{label}</dt>
<dd className={cn("mt-0.5 truncate font-semibold", mono && "font-mono text-xs")}>{value}</dd>
<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>
);
}
@ -330,19 +504,21 @@ function ModelRow({
? { 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-frost-600 dark:text-frost-300" };
: { 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-frost-500 dark:text-frost-400">{title}</p>
<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-frost-500 dark:text-frost-400">{t("health.configuredAs", { model: model.configured })}</p>
<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>