freezmo/components/chat/Composer.tsx
Martin Dvorak 6635b5d405 Design pass (all themes + chat UI), model picker, reduce-motion toggle
- Aero: blue gradients back on the active segment and every primary
  (Review due, the card's Study button, the composer's send button); a
  frosted backing behind the page heading and the All decks label; a
  bottom-weighted rail tint; chat header/composer/welcome panels; calmer
  orbs; stronger card veil + a stronger blur bake.
- Dark/Light: a dedicated --track token makes progress bars read (the
  first attempt shifted --surface-sunken and the pixel scan caught the
  sheet-contrast regression); brighter dark secondary text; darker light
  segmented labels and difficulty tints; stronger light card border and
  shadow.
- Chat UI: friendly model chip ("Gemma 4 31B") with the raw name in a
  tooltip, larger chips, plain centered disclaimer, vertically centered
  welcome panel, distinct chat-plus icon for new chat.
- Chat models: every chat-capable model from the server's /api/tags is
  selectable (favourites first, search field when long, live-list
  validation in the route); default CHAT_MODEL_DEFAULT or gemma4:31b.
- Reduce motion: Settings toggle (default off) that parks the scene,
  collapses CSS animations and drives framer-motion's calm mode.
- Folders hint, "New" badge on fresh decks.

Contrast: 0 findings, axe clean, mobile 126/126, chat 102/102, wizard
95/95, regression 44/44, i18n 13/13, native 33/33.
Version 0.4.6, SW -> freezmo-v71.
2026-10-14 05:06:02 +00:00

212 lines
7.6 KiB
TypeScript

"use client";
import { useEffect, useRef } from "react";
import { AiDisclaimer } from "@/components/AiDisclaimer";
import { ChatIcon, LayersIcon, PaperclipIcon, SendIcon, StopIcon, XIcon } from "@/components/icons";
import { Spinner } from "@/components/ui";
import { useI18n } from "@/lib/i18n";
import { useIsPhone } from "@/lib/use-phone";
import type { ChatAttachment } from "@/lib/chat";
import { cn } from "@/lib/utils";
export type PendingAttachment = { attachment: ChatAttachment; text: string };
/**
* The pinned composer: auto-growing 16px textarea, send/stop, attach, and the
* context + model chips. Enter sends on desktop, newline on phones; the
* keyboard inset is handled by the screen around it.
*/
export function Composer({
value,
onChange,
onSend,
onStop,
busy,
online,
scopeLabel,
modelLabel,
modelTitle,
scopeOpen,
modelOpen,
onOpenScope,
onOpenModel,
onAttachFile,
attachBusy,
attachments,
onRemoveAttachment,
}: {
value: string;
onChange: (value: string) => void;
onSend: () => void;
onStop: () => void;
busy: boolean;
online: boolean;
scopeLabel: string;
modelLabel: string;
/** The raw model name, shown as the chip's tooltip. */
modelTitle?: string;
scopeOpen: boolean;
modelOpen: boolean;
onOpenScope: () => void;
onOpenModel: () => void;
onAttachFile: (file: File) => void;
attachBusy: boolean;
attachments: PendingAttachment[];
onRemoveAttachment: (index: number) => void;
}) {
const { t } = useI18n();
const isPhone = useIsPhone();
const textarea = useRef<HTMLTextAreaElement>(null);
const fileInput = useRef<HTMLInputElement>(null);
useEffect(() => {
const element = textarea.current;
if (!element) return;
element.style.height = "auto";
element.style.height = `${Math.min(element.scrollHeight, 180)}px`;
}, [value]);
const canSend = online && !busy && value.trim().length > 0;
return (
<div className="border-t border-[var(--glass-border)] bg-[var(--glass-bg)] px-3 pb-safe pt-2 backdrop-blur-xl">
{attachments.length > 0 ? (
<ul className="mb-2 flex flex-wrap gap-1.5">
{attachments.map((entry, index) => (
<li
key={`${entry.attachment.name}-${index}`}
className="flex items-center gap-1.5 rounded-xl border border-[var(--hairline-soft)] bg-[var(--surface-sunken)] py-1 pl-2.5 pr-1 text-[12px] text-[var(--text-muted)]"
>
<span className="max-w-[160px] truncate">{entry.attachment.name}</span>
<span className="text-[var(--text-subtle)]">{entry.attachment.kind === "photo" ? t("chat.attachPhoto") : t("chat.attachDoc")}</span>
<button
type="button"
aria-label={t("chat.attachRemove")}
title={t("chat.attachRemove")}
onClick={() => onRemoveAttachment(index)}
className="grid size-6 place-items-center rounded-lg text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
>
<XIcon className="size-3.5" />
</button>
</li>
))}
</ul>
) : null}
<div className="flex items-end gap-2">
<button
type="button"
aria-label={t("chat.attach")}
title={t("chat.attach")}
disabled={attachBusy}
onClick={() => fileInput.current?.click()}
className="grid size-10 shrink-0 place-items-center rounded-2xl border border-[var(--glass-border)] bg-[var(--surface-strong)] text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)] disabled:opacity-60"
>
{attachBusy ? <Spinner className="size-4" /> : <PaperclipIcon className="size-5" />}
</button>
<input
ref={fileInput}
type="file"
aria-hidden="true"
tabIndex={-1}
accept="image/*,application/pdf,.docx,.pptx,.odt,.txt,.md,.csv"
className="sr-only"
onChange={(event) => {
const file = event.target.files?.[0];
if (file) onAttachFile(file);
event.target.value = "";
}}
/>
<textarea
ref={textarea}
value={value}
onChange={(event) => onChange(event.target.value)}
onKeyDown={(event) => {
if (event.key !== "Enter" || event.shiftKey || isPhone) return;
if (event.nativeEvent.isComposing) return;
event.preventDefault();
if (canSend) onSend();
}}
rows={1}
placeholder={t("chat.placeholder")}
aria-label={t("chat.placeholder")}
className="max-h-[180px] min-h-[44px] min-w-0 flex-1 resize-none rounded-2xl border border-[var(--glass-border)] bg-[var(--surface-strong)] px-3.5 py-2.5 text-[16px] leading-relaxed text-[var(--text)] outline-none transition-[height,border-color] duration-150 ease-out placeholder:text-[var(--text-subtle)] focus:border-accent-400/60 motion-reduce:transition-none"
/>
{busy ? (
<button
type="button"
onClick={onStop}
aria-label={t("chat.stop")}
title={t("chat.stop")}
className="grid size-10 shrink-0 place-items-center rounded-2xl border border-[var(--glass-border)] bg-[var(--surface-strong)] text-[var(--text)] transition hover:bg-[var(--hover)]"
>
<StopIcon className="size-5" />
</button>
) : (
<button
type="button"
onClick={onSend}
disabled={!canSend}
aria-label={t("chat.send")}
title={t("chat.send")}
className={cn(
"grid size-10 shrink-0 place-items-center rounded-2xl transition",
canSend ? "grad-ice text-[#06111f]" : "border border-[var(--glass-border)] bg-[var(--surface-strong)] text-[var(--text-subtle)] opacity-60",
)}
>
<SendIcon className="size-5" />
</button>
)}
</div>
<div className="mt-2 flex items-center gap-2 overflow-x-auto">
<button
type="button"
data-picker-trigger
aria-haspopup="dialog"
aria-expanded={scopeOpen}
onClick={onOpenScope}
className={pickerChipClass(scopeOpen)}
>
<LayersIcon className="size-4 shrink-0" />
<span className="max-w-[190px] truncate">{scopeLabel}</span>
</button>
<button
type="button"
data-picker-trigger
aria-haspopup="dialog"
aria-expanded={modelOpen}
onClick={onOpenModel}
title={modelTitle ?? modelLabel}
className={pickerChipClass(modelOpen)}
>
<ChatIcon className="size-4 shrink-0" />
<span className="max-w-[190px] truncate">{modelLabel}</span>
</button>
{!online ? (
<span role="status" className="ml-auto shrink-0 text-[11.5px] text-amber-600 dark:text-amber-300">
{t("chat.offlineNote")}
</span>
) : null}
</div>
<AiDisclaimer plain className="mt-1.5" />
</div>
);
}
/**
* Chip style for the two popover triggers (context, model). The open state
* reads pressed, because the same chip opens and closes its popover.
*/
function pickerChipClass(open: boolean): string {
return cn(
"flex min-w-0 items-center gap-1.5 rounded-xl border px-3 py-2 text-[12.5px] font-medium transition",
open
? "border-[var(--nav-active-border)] bg-[var(--nav-active-bg)] text-[var(--text)]"
: "border-[var(--hairline)] bg-[var(--surface-sunken)] text-[var(--text)] hover:bg-[var(--hover)]",
);
}