= {
+ trash: Trash2,
+ archive: Archive,
+ unarchive: ArchiveRestore,
+ model: Cpu,
+ theme: SunMoon,
+ accent: Palette,
+ check: Check,
+};
+
+/** Must match the .toast-exit animation duration in index.css. */
+const EXIT_MS = 260;
+
+function ToastPill({ toast }: { toast: Toast }) {
+ const [leaving, setLeaving] = useState(false);
+
+ // Full visibility window, then the exit animation. Restarted whenever a
+ // same-tag push replaces the text (nonce bump).
+ useEffect(() => {
+ setLeaving(false);
+ const t = window.setTimeout(() => setLeaving(true), TOAST_DISPLAY_MS);
+ return () => window.clearTimeout(t);
+ }, [toast.nonce]);
+
+ // Unmount once the exit animation has played out.
+ useEffect(() => {
+ if (!leaving) return;
+ const t = window.setTimeout(() => removeToast(toast.id), EXIT_MS);
+ return () => window.clearTimeout(t);
+ }, [leaving, toast.id]);
+
+ const Icon = ICONS[toast.icon];
+
+ return (
+ setLeaving(true)}
+ className={cn(
+ "pointer-events-auto flex cursor-pointer items-center gap-2.5 rounded-full border border-stone-200 bg-white/95 py-2 pl-2.5 pr-4 shadow-xl backdrop-blur",
+ "dark:border-zinc-700 dark:bg-zinc-900/95",
+ leaving ? "toast-exit" : "toast-enter",
+ )}
+ >
+
+
+
+ {toast.text}
+
+ );
+}
+
+/**
+ * Bottom-center pill stack. Fixed above the composer (bottom-24), over the
+ * modals (z-[70] > modal z-50) so settings actions toast visibly too.
+ */
+export function Toasts() {
+ const toasts = useToasts();
+ if (toasts.length === 0) return null;
+ return (
+
+ {toasts.map((t) => (
+
+ ))}
+
+ );
+}
diff --git a/frontend/src/components/dialogs.tsx b/frontend/src/components/dialogs.tsx
index c7cca09..f8974e8 100644
--- a/frontend/src/components/dialogs.tsx
+++ b/frontend/src/components/dialogs.tsx
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from "react";
import { Archive, ArchiveRestore, Eye, Fingerprint, Globe, FileText, ImagePlus, KeyRound, Link2, Mail, Palette, Pencil, Plug, ScanText, Search, Settings2, ShieldCheck, Sparkles, Tag, Trash2, Unplug, User as UserIcon, X } from "lucide-react";
import { api, type UserProvider } from "../lib/api.ts";
+import { pushToast } from "../lib/toasts.ts";
import { cn } from "../lib/cn.ts";
import { ACCENT_PRESETS, applyAccent, currentAccent, previewAccent, type AccentState } from "../lib/accent.ts";
import { FEATURES, setFeature, useFeatures } from "../lib/features.ts";
@@ -616,6 +617,7 @@ function ArchivedSection({ onView, onChanged }: { onView: (c: Conversation) => v
await api.unarchiveConv(c.id);
await refresh();
onChanged();
+ pushToast(t("toast.chatUnarchived", { title: c.title }), { icon: "unarchive" });
} catch (err) {
setError(err instanceof Error ? err.message : t("common.saveFailed"));
} finally {
@@ -631,6 +633,7 @@ function ArchivedSection({ onView, onChanged }: { onView: (c: Conversation) => v
setDeleteTarget(null);
await refresh();
onChanged(c.id);
+ pushToast(t("toast.chatDeleted", { title: c.title }), { icon: "trash" });
} catch (err) {
setError(err instanceof Error ? err.message : t("common.deleteFailed"));
} finally {
@@ -716,12 +719,14 @@ function AccentSection() {
const next = { ...state, presetId };
setState(next);
applyAccent(next);
+ pushToast(t("toast.accentChanged"), { icon: "accent", tag: "accent" });
}
function pickCustom(hex: string) {
const next = { presetId: "custom", customHex: hex };
setState(next);
previewAccent(hex);
+ pushToast(t("toast.accentChanged"), { icon: "accent", tag: "accent" });
}
return (
diff --git a/frontend/src/components/ui.tsx b/frontend/src/components/ui.tsx
index 813a12d..72f70da 100644
--- a/frontend/src/components/ui.tsx
+++ b/frontend/src/components/ui.tsx
@@ -1,4 +1,4 @@
-import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
+import React, { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Check, ChevronDown, Copy, Loader2, X, type LucideIcon } from "lucide-react";
import { cn } from "../lib/cn.ts";
@@ -483,13 +483,33 @@ export function Avatar({ name, url, size = 36 }: { name: string; url?: string; s
/* ---------- Brand logo (single declaration — import anywhere) ---------- */
/**
- * The KisAssistant flower mark. Petals follow the runtime accent color,
- * heart stays amber — same artwork as the loading screen and favicon.
+ * The KisAssistant flower mark: a soft five-petal blossom with a lighter
+ * inner ring, petals following the runtime accent color (gradients read
+ * the --ka-accent-* triplets), heart in amber radial with a highlight —
+ * same artwork as the loading screen and favicon. Petals are deliberately
+ * slightly uneven (sizes/angles) so it reads organic, not geometric.
* `dynamic` controls the bloom animation: pass false for a static,
* full-bloom mark (e.g. thread bars and headers).
*/
export function FlowerMark({ size, className, dynamic = true }: { size?: number; className?: string; dynamic?: boolean }) {
- const petals = [0, 60, 120, 180, 240, 300];
+ const uid = useId().replace(/[^a-zA-Z0-9]/g, "");
+ const petalId = `ka-petal-${uid}`;
+ const petalInId = `ka-petalin-${uid}`;
+ const coreId = `ka-core-${uid}`;
+ const outer = [
+ { r: 8, ry: 20 },
+ { r: 80, ry: 19 },
+ { r: 151, ry: 20.5 },
+ { r: 223, ry: 19.5 },
+ { r: 294, ry: 20 },
+ ];
+ const inner = [
+ { r: 44, ry: 12 },
+ { r: 116, ry: 11.5 },
+ { r: 188, ry: 12 },
+ { r: 259, ry: 11.5 },
+ { r: 331, ry: 12 },
+ ];
return (
);
}
@@ -605,9 +659,13 @@ export function LoadingScreen() {
return (
-
-
KisAssistant
+
+
+
+
+
KisAssistant
{t("loading.tagline")}
+
);
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 382092f..f2a87bf 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -90,24 +90,32 @@ body {
50% { opacity: 0; }
}
-/* Loading flower: petals bloom one after another, core breathes. */
-.flower-petal {
+/* Loading flower: outer petals bloom in turn, inner petals follow softer,
+ core breathes with an amber glow. */
+.flower-petal, .flower-petal-inner, .flower-core {
transform-box: fill-box;
transform-origin: center;
- animation: bloom 1.8s ease-in-out infinite;
+}
+.flower-petal {
+ animation: bloom 2.4s ease-in-out infinite;
+}
+.flower-petal-inner {
+ animation: bloom-inner 2.4s ease-in-out infinite;
}
@keyframes bloom {
- 0%, 100% { transform: scale(0.55); opacity: 0.3; }
+ 0%, 100% { transform: scale(0.62); opacity: 0.35; }
50% { transform: scale(1); opacity: 1; }
}
+@keyframes bloom-inner {
+ 0%, 100% { transform: scale(0.55); opacity: 0.2; }
+ 50% { transform: scale(1); opacity: 0.95; }
+}
.flower-core {
- transform-box: fill-box;
- transform-origin: center;
- animation: core-breathe 1.8s ease-in-out infinite;
+ animation: core-breathe 2.4s ease-in-out infinite;
}
@keyframes core-breathe {
- 0%, 100% { transform: scale(0.85); }
- 50% { transform: scale(1.15); }
+ 0%, 100% { transform: scale(0.9); }
+ 50% { transform: scale(1.1); }
}
/* Smooth entrance for full-screen loaders. */
@keyframes loading-in {
@@ -118,6 +126,81 @@ body {
animation: loading-in 0.45s ease-out both;
}
+/* Loader halo: expanding accent glow + revolving dashed orbit with a dot. */
+.loader-halo {
+ position: relative;
+ display: grid;
+ place-items: center;
+ padding: 20px;
+}
+.loader-halo::before {
+ content: "";
+ position: absolute;
+ inset: 0;
+ border-radius: 9999px;
+ background: radial-gradient(circle, rgb(var(--ka-accent-500) / 0.28) 0%, transparent 65%);
+ animation: halo-ping 2.6s ease-out infinite;
+}
+@keyframes halo-ping {
+ 0% { transform: scale(0.72); opacity: 0.6; }
+ 70% { transform: scale(1.14); opacity: 0; }
+ 100% { transform: scale(1.14); opacity: 0; }
+}
+.loader-orbit {
+ position: absolute;
+ inset: 8px;
+ border-radius: 9999px;
+ border: 1.5px dashed rgb(var(--ka-accent-500) / 0.45);
+ animation: orbit-spin 5s linear infinite;
+}
+.loader-orbit::after {
+ content: "";
+ position: absolute;
+ top: -3.5px;
+ left: 50%;
+ width: 7px;
+ height: 7px;
+ margin-left: -3.5px;
+ border-radius: 9999px;
+ background: rgb(var(--ka-accent-500));
+ box-shadow: 0 0 8px rgb(var(--ka-accent-500));
+}
+@keyframes orbit-spin {
+ to { transform: rotate(1turn); }
+}
+/* Loader title: soft accent glow pulse. */
+.loader-title {
+ animation: title-glow 2.6s ease-in-out infinite;
+}
+@keyframes title-glow {
+ 0%, 100% { opacity: 0.92; }
+ 50% { opacity: 1; text-shadow: 0 0 18px rgb(var(--ka-accent-500) / 0.35); }
+}
+/* Indeterminate shimmer bar. */
+.loader-bar {
+ display: block;
+ width: 11rem;
+ height: 3px;
+ border-radius: 9999px;
+ background: rgb(0 0 0 / 0.1);
+ overflow: hidden;
+}
+.dark .loader-bar {
+ background: rgb(255 255 255 / 0.12);
+}
+.loader-bar > span {
+ display: block;
+ height: 100%;
+ width: 35%;
+ border-radius: inherit;
+ background: linear-gradient(90deg, rgb(var(--ka-accent-400)), rgb(var(--ka-accent-600)));
+ animation: bar-slide 1.5s ease-in-out infinite;
+}
+@keyframes bar-slide {
+ 0% { transform: translateX(-110%); }
+ 100% { transform: translateX(320%); }
+}
+
.loading-dots::after {
content: "";
animation: dots 1.4s steps(4) infinite;
@@ -129,8 +212,25 @@ body {
75% { content: "..."; }
}
+/* Bottom pills: springy rise on appear, soft sink on dismiss. */
+@keyframes toast-in {
+ from { opacity: 0; transform: translateY(18px) scale(0.94); }
+ to { opacity: 1; transform: translateY(0) scale(1); }
+}
+.toast-enter {
+ animation: toast-in 0.38s cubic-bezier(0.21, 1.02, 0.73, 1) both;
+}
+@keyframes toast-out {
+ from { opacity: 1; transform: translateY(0) scale(1); }
+ to { opacity: 0; transform: translateY(12px) scale(0.95); }
+}
+.toast-exit {
+ animation: toast-out 0.26s ease-in both;
+}
+
@media (prefers-reduced-motion: reduce) {
- .flower-petal, .flower-core, .loading-dots::after, .loading-enter, .typing-cursor::after {
+ .flower-petal, .flower-core, .loading-dots::after, .loading-enter, .typing-cursor::after,
+ .toast-enter, .toast-exit {
animation: none;
}
}
diff --git a/frontend/src/lib/i18n.ts b/frontend/src/lib/i18n.ts
index 33ca5e3..a02ff50 100644
--- a/frontend/src/lib/i18n.ts
+++ b/frontend/src/lib/i18n.ts
@@ -94,6 +94,12 @@ const en = {
"chat.noModels": "No models",
"chat.selectModel": "Select model…",
"chat.personalTag": "personal key",
+ "toast.chatDeleted": "Chat “{title}” deleted",
+ "toast.chatArchived": "Chat “{title}” archived",
+ "toast.chatUnarchived": "Chat “{title}” unarchived",
+ "toast.modelChanged": "Model: {label}",
+ "toast.themeChanged": "Theme changed",
+ "toast.accentChanged": "Accent color changed",
"chat.you": "You",
"chat.greet": "How can I help, {name}?",
"chat.greetSub": "Pick a model, then type below. Markdown is supported.",
@@ -635,6 +641,12 @@ const es: Dict = {
"chat.noModels": "Sin modelos",
"chat.selectModel": "Elegir modelo…",
"chat.personalTag": "clave personal",
+ "toast.chatDeleted": "Chat “{title}” eliminado",
+ "toast.chatArchived": "Chat “{title}” archivado",
+ "toast.chatUnarchived": "Chat “{title}” desarchivado",
+ "toast.modelChanged": "Modelo: {label}",
+ "toast.themeChanged": "Tema cambiado",
+ "toast.accentChanged": "Color de acento cambiado",
"chat.you": "Tú",
"chat.greet": "¿En qué te ayudo, {name}?",
"chat.greetSub": "Elige un modelo y escribe abajo. Markdown soportado.",
@@ -1166,6 +1178,12 @@ const fr: Dict = {
"chat.noModels": "Aucun modèle",
"chat.selectModel": "Choisir un modèle…",
"chat.personalTag": "clé personnelle",
+ "toast.chatDeleted": "Chat “{title}” supprimé",
+ "toast.chatArchived": "Chat “{title}” archivé",
+ "toast.chatUnarchived": "Chat “{title}” désarchivé",
+ "toast.modelChanged": "Modèle : {label}",
+ "toast.themeChanged": "Thème modifié",
+ "toast.accentChanged": "Couleur d’accent modifiée",
"chat.you": "Vous",
"chat.greet": "Comment puis-je aider, {name} ?",
"chat.greetSub":
@@ -1697,6 +1715,12 @@ const ru: Dict = {
"chat.noModels": "Нет моделей",
"chat.selectModel": "Выбрать модель…",
"chat.personalTag": "свой ключ",
+ "toast.chatDeleted": "Чат “{title}” удалён",
+ "toast.chatArchived": "Чат “{title}” архивирован",
+ "toast.chatUnarchived": "Чат “{title}” разархивирован",
+ "toast.modelChanged": "Модель: {label}",
+ "toast.themeChanged": "Тема изменена",
+ "toast.accentChanged": "Акцентный цвет изменён",
"chat.you": "Вы",
"chat.greet": "Чем помочь, {name}?",
"chat.greetSub": "Выберите модель и пишите ниже. Поддерживается Markdown.",
@@ -2215,6 +2239,12 @@ const it: Dict = {
"chat.noModels": "Nessun modello",
"chat.selectModel": "Scegli modello…",
"chat.personalTag": "chiave personale",
+ "toast.chatDeleted": "Chat “{title}” eliminata",
+ "toast.chatArchived": "Chat “{title}” archiviata",
+ "toast.chatUnarchived": "Chat “{title}” disarchiviata",
+ "toast.modelChanged": "Modello: {label}",
+ "toast.themeChanged": "Tema cambiato",
+ "toast.accentChanged": "Colore accento cambiato",
"chat.you": "Tu",
"chat.greet": "Come posso aiutare, {name}?",
"chat.greetSub": "Scegli un modello e scrivi qui sotto. Markdown supportato.",
@@ -2739,6 +2769,12 @@ const pt: Dict = {
"chat.noModels": "Sem modelos",
"chat.selectModel": "Escolher modelo…",
"chat.personalTag": "chave pessoal",
+ "toast.chatDeleted": "Chat “{title}” eliminado",
+ "toast.chatArchived": "Chat “{title}” arquivado",
+ "toast.chatUnarchived": "Chat “{title}” desarquivado",
+ "toast.modelChanged": "Modelo: {label}",
+ "toast.themeChanged": "Tema alterado",
+ "toast.accentChanged": "Cor de destaque alterada",
"chat.you": "Tu",
"chat.greet": "Como posso ajudar, {name}?",
"chat.greetSub": "Escolhe um modelo e escreve abaixo. Markdown suportado.",
diff --git a/frontend/src/lib/toasts.ts b/frontend/src/lib/toasts.ts
new file mode 100644
index 0000000..5015f2f
--- /dev/null
+++ b/frontend/src/lib/toasts.ts
@@ -0,0 +1,74 @@
+import { useSyncExternalStore } from "react";
+
+/**
+ * Bottom-pill notifications (toasts): one-line confirmations when the user
+ * takes an action (chat deleted/archived, model/theme/accent changed…).
+ * Same subscribe pattern as features.ts / i18n.ts — callable from anywhere
+ * (App, settings sections) without prop drilling.
+ */
+
+export type ToastIcon =
+ | "trash"
+ | "archive"
+ | "unarchive"
+ | "model"
+ | "theme"
+ | "accent"
+ | "check";
+
+export interface Toast {
+ id: number;
+ text: string;
+ icon: ToastIcon;
+ /** Same-tag pushes replace the pill instead of stacking (accent, model). */
+ tag: string | null;
+ /** Bumped on replace so the pill restarts its enter/dismiss timers. */
+ nonce: number;
+}
+
+/** How long a pill stays fully visible before its exit animation. */
+export const TOAST_DISPLAY_MS = 3000;
+
+let nextId = 1;
+let toasts: Toast[] = [];
+const listeners = new Set<() => void>();
+
+function emit(): void {
+ listeners.forEach((l) => l());
+}
+
+export function pushToast(text: string, opts: { icon?: ToastIcon; tag?: string } = {}): void {
+ const icon = opts.icon ?? "check";
+ const tag = opts.tag ?? null;
+ if (tag) {
+ const existing = toasts.find((t) => t.tag === tag);
+ if (existing) {
+ toasts = toasts.map((t) =>
+ t.id === existing.id ? { ...t, text, icon, nonce: t.nonce + 1 } : t,
+ );
+ emit();
+ return;
+ }
+ }
+ // Cap the stack so rapid actions never flood the bottom of the screen.
+ toasts = [...toasts.slice(-2), { id: nextId++, text, icon, tag, nonce: 0 }];
+ emit();
+}
+
+export function removeToast(id: number): void {
+ if (!toasts.some((t) => t.id === id)) return;
+ toasts = toasts.filter((t) => t.id !== id);
+ emit();
+}
+
+export function useToasts(): Toast[] {
+ return useSyncExternalStore(
+ (cb) => {
+ listeners.add(cb);
+ return () => {
+ listeners.delete(cb);
+ };
+ },
+ () => toasts,
+ );
+}