+
{t("voice.desc")}
+ {!supported && (
+
+ {t("voice.noVoices")}
+
+ )}
+
+ update({ lang: v, voiceURI: null })}
+ align="left"
+ options={VOICE_LANGS.map((l) => ({ value: l, label: l === "auto" ? t("voice.auto") : l }))}
+ />
+
+
+ update({ voiceURI: v || null })}
+ align="left"
+ groups={[{ group: "—", options: [{ value: "", label: t("voice.defaultVoice") }] }, ...groups]}
+ />
+
+
+
+ update({ rate: Number(e.target.value) })}
+ className="w-full accent-[rgb(var(--ka-accent-600))]"
+ aria-label={t("voice.rate")}
+ />
+
+
+ update({ pitch: Number(e.target.value) })}
+ className="w-full accent-[rgb(var(--ka-accent-600))]"
+ aria-label={t("voice.pitch")}
+ />
+
+
+ {previews.length > 0 && (
+
+
{t("voice.previews")}
+
+ {previews.map((line, i) => (
+ -
+
+ {line}
+
+ {prefs.lang === "auto" ? detectLang(line) : prefs.lang} · {prefs.rate.toFixed(2)}×
+
+
+ ))}
+
+
+ )}
+
+ );
+}
+
/* ---------- Security (settings): key rotation, TOTP, passkeys ---------- */
function SecuritySection({ onKeyRotated }: { onKeyRotated?: () => void }) {
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 2f51e11..c832db4 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -180,6 +180,54 @@ body {
}
}
+/* Voice: Siri-style glow on the bubble being read aloud. */
+.speaking-glow {
+ position: relative;
+ border-color: rgb(var(--ka-accent-500) / 0.55) !important;
+ box-shadow:
+ 0 0 0 1px rgb(var(--ka-accent-500) / 0.25),
+ 0 0 22px rgb(var(--ka-accent-500) / 0.28),
+ 0 8px 30px rgb(var(--ka-accent-500) / 0.12);
+ animation: speak-breathe 2.2s ease-in-out infinite;
+}
+@keyframes speak-breathe {
+ 0%, 100% { box-shadow: 0 0 0 1px rgb(var(--ka-accent-500) / 0.25), 0 0 14px rgb(var(--ka-accent-500) / 0.2), 0 8px 30px rgb(var(--ka-accent-500) / 0.1); }
+ 50% { box-shadow: 0 0 0 1px rgb(var(--ka-accent-500) / 0.4), 0 0 30px rgb(var(--ka-accent-500) / 0.38), 0 8px 30px rgb(var(--ka-accent-500) / 0.16); }
+}
+.speaking-orb {
+ animation: orb-pulse 1.1s ease-in-out infinite;
+}
+@keyframes orb-pulse {
+ 0%, 100% { opacity: 1; transform: scale(1); }
+ 50% { opacity: 0.55; transform: scale(0.86); }
+}
+
+/* Voice recorder: pulsing REC dot, buttery bars, soft entrance. */
+.rec-dot {
+ width: 10px;
+ height: 10px;
+ border-radius: 999px;
+ background: #ef4444;
+ box-shadow: 0 0 10px rgb(239 68 68 / 0.8);
+ animation: rec-blink 1.2s ease-in-out infinite;
+ flex-shrink: 0;
+}
+@keyframes rec-blink {
+ 0%, 100% { opacity: 1; transform: scale(1); }
+ 50% { opacity: 0.45; transform: scale(0.8); }
+}
+.wave-bar {
+ transition: height 90ms linear, opacity 90ms linear;
+ min-width: 2px;
+}
+.voice-recorder {
+ animation: recorder-in 0.25s cubic-bezier(0.21, 1.02, 0.73, 1) both;
+}
+@keyframes recorder-in {
+ from { opacity: 0; transform: translateY(10px) scale(0.98); }
+ to { opacity: 1; transform: translateY(0) scale(1); }
+}
+
/* Kill Chrome's yellow autofill background (login / password fields) */
input:-webkit-autofill,
input:-webkit-autofill:hover,
diff --git a/frontend/src/lib/accent.ts b/frontend/src/lib/accent.ts
index 408c871..1e27d97 100644
--- a/frontend/src/lib/accent.ts
+++ b/frontend/src/lib/accent.ts
@@ -99,6 +99,7 @@ export function applyAccent(state: AccentState): void {
const shades = shadesFor(base);
const root = document.documentElement;
for (const k of SHADE_KEYS) root.style.setProperty(`--ka-accent-${k}`, shades[k]);
+ paintFavicon(base);
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch { /* ignore */ }
@@ -109,6 +110,47 @@ export function previewAccent(baseHex: string): void {
const shades = shadesFor(baseHex);
const root = document.documentElement;
for (const k of SHADE_KEYS) root.style.setProperty(`--ka-accent-${k}`, shades[k]);
+ paintFavicon(baseHex);
+}
+
+function mixHex(hex: string, target: number, amount: number): string {
+ const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
+ const n = m ? parseInt(m[1]!, 16) : 0x10b981;
+ const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
+ const mix = (v: number) => Math.round(v + (target - v) * amount);
+ const to = (v: number) => mix(v).toString(16).padStart(2, "0");
+ return `#${to(r)}${to(g)}${to(b)}`;
+}
+
+/**
+ * Favicon follows the accent: a mini flower (petals in the accent ramp,
+ * amber core like the logo) swapped in as an SVG data URL, so the tab icon
+ * always matches the platform dress.
+ */
+export function paintFavicon(baseHex: string): void {
+ if (typeof document === "undefined") return;
+ const base = /^#?[0-9a-f]{6}$/i.test(baseHex.trim()) ? baseHex.trim().replace(/^#?/, "#") : "#10b981";
+ const dark = mixHex(base, 0, 0.35);
+ const light = mixHex(base, 255, 0.55);
+ const petals = [8, 80, 151, 223, 295].map(
+ (a) => `