diff --git a/frontend/src/components/Tooltip.tsx b/frontend/src/components/Tooltip.tsx
new file mode 100644
index 0000000..dfc3b75
--- /dev/null
+++ b/frontend/src/components/Tooltip.tsx
@@ -0,0 +1,38 @@
+import { useSyncExternalStore } from "react";
+import { hintsEnabled, subscribeHints } from "../lib/hints";
+
+type Side = "top" | "bottom" | "left";
+
+/** Wrap any element to show a short glass hint caption on hover — but only while the
+ * app-wide hints toggle (Settings → Appearance) is on. */
+export default function Tooltip({
+ hint,
+ side = "top",
+ children,
+}: {
+ hint: string;
+ side?: Side;
+ children: React.ReactNode;
+}) {
+ const enabled = useSyncExternalStore(subscribeHints, hintsEnabled, hintsEnabled);
+ if (!enabled || !hint) return <>{children}>;
+
+ const place =
+ side === "bottom"
+ ? "top-full mt-2 left-1/2 -translate-x-1/2"
+ : side === "left"
+ ? "right-full mr-2 top-1/2 -translate-y-1/2"
+ : "bottom-full mb-2 left-1/2 -translate-x-1/2";
+
+ return (
+
+ {children}
+
+ {hint}
+
+
+ );
+}
diff --git a/frontend/src/components/VideoCard.tsx b/frontend/src/components/VideoCard.tsx
index c364fdf..8a4f7d5 100644
--- a/frontend/src/components/VideoCard.tsx
+++ b/frontend/src/components/VideoCard.tsx
@@ -170,7 +170,7 @@ export default function VideoCard({
return (
diff --git a/frontend/src/index.css b/frontend/src/index.css
index e9e3683..e831bde 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -21,8 +21,77 @@ html {
body {
margin: 0;
- background: var(--bg);
color: var(--fg);
+ /* Ambient backdrop so translucent "glass" surfaces have soft color to refract. */
+ background:
+ radial-gradient(1100px 620px at 12% -8%, color-mix(in srgb, var(--accent) 14%, transparent), transparent 60%),
+ radial-gradient(1000px 700px at 112% 8%, color-mix(in srgb, var(--accent) 9%, transparent), transparent 55%),
+ var(--bg);
+ background-attachment: fixed;
+}
+
+/* ===== Liquid-glass surface system (theme-aware, GPU-light) ===== */
+.glass {
+ background: color-mix(in srgb, var(--surface) 72%, transparent);
+ backdrop-filter: blur(18px) saturate(1.6);
+ -webkit-backdrop-filter: blur(18px) saturate(1.6);
+ border: 1px solid color-mix(in srgb, var(--border) 65%, transparent);
+ box-shadow:
+ inset 0 1px 0 color-mix(in srgb, #fff 14%, transparent),
+ 0 18px 40px -16px rgba(0, 0, 0, 0.55);
+}
+.glass-card {
+ background: color-mix(in srgb, var(--card) 58%, transparent);
+ backdrop-filter: blur(10px) saturate(1.3);
+ -webkit-backdrop-filter: blur(10px) saturate(1.3);
+ border: 1px solid color-mix(in srgb, var(--border) 55%, transparent);
+ box-shadow:
+ inset 0 1px 0 color-mix(in srgb, #fff 9%, transparent),
+ 0 8px 22px -14px rgba(0, 0, 0, 0.45);
+}
+.glass-hover:hover {
+ border-color: color-mix(in srgb, var(--accent) 55%, transparent);
+ box-shadow:
+ inset 0 1px 0 color-mix(in srgb, #fff 12%, transparent),
+ 0 14px 30px -14px rgba(0, 0, 0, 0.5);
+}
+
+/* Performance mode (Settings → Appearance) drops the expensive blur + soft shadows. */
+html[data-perf="1"] .glass,
+html[data-perf="1"] .glass-card {
+ backdrop-filter: none;
+ -webkit-backdrop-filter: none;
+ background: var(--surface);
+ box-shadow: 0 2px 8px -4px rgba(0, 0, 0, 0.4);
+}
+html[data-perf="1"] body {
+ background: var(--bg);
+}
+
+/* ===== Motion ===== */
+@keyframes fadeIn {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+@keyframes overlayIn {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+@keyframes overlayOut {
+ from { opacity: 1; }
+ to { opacity: 0; }
+}
+@keyframes panelIn {
+ from { transform: translateX(100%); }
+ to { transform: translateX(0); }
+}
+@keyframes panelOut {
+ from { transform: translateX(0); }
+ to { transform: translateX(100%); }
+}
+@keyframes popIn {
+ from { opacity: 0; transform: translateY(-6px) scale(0.97); }
+ to { opacity: 1; transform: none; }
}
/* ===== Color schemes (accent + neutrals), each with dark + light ===== */
diff --git a/frontend/src/lib/hints.ts b/frontend/src/lib/hints.ts
new file mode 100644
index 0000000..4b8bd1e
--- /dev/null
+++ b/frontend/src/lib/hints.ts
@@ -0,0 +1,36 @@
+// App-wide "hints" toggle: when on, Tooltip components reveal short explanatory
+// captions on hover so the UI is somewhat self-documenting for new users. Experienced
+// users can turn it off in Settings. Persisted to localStorage (+ server preferences).
+const KEY = "subfeed.hints";
+
+let enabled = load();
+let listeners: Array<() => void> = [];
+
+function load(): boolean {
+ try {
+ return localStorage.getItem(KEY) !== "0"; // default ON
+ } catch {
+ return true;
+ }
+}
+
+export function hintsEnabled(): boolean {
+ return enabled;
+}
+
+export function setHintsEnabled(v: boolean): void {
+ enabled = v;
+ try {
+ localStorage.setItem(KEY, v ? "1" : "0");
+ } catch {
+ /* ignore */
+ }
+ listeners.forEach((l) => l());
+}
+
+export function subscribeHints(listener: () => void): () => void {
+ listeners.push(listener);
+ return () => {
+ listeners = listeners.filter((l) => l !== listener);
+ };
+}
diff --git a/frontend/src/lib/notifications.ts b/frontend/src/lib/notifications.ts
index c00e4ab..09aa4b8 100644
--- a/frontend/src/lib/notifications.ts
+++ b/frontend/src/lib/notifications.ts
@@ -83,6 +83,7 @@ function beep(): void {
try {
const Ctx = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
audioCtx = audioCtx || new Ctx();
+ if (audioCtx.state === "suspended") void audioCtx.resume();
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.connect(gain);
@@ -151,11 +152,14 @@ export function notify(input: NotifyInput): number {
const id = counter++;
const requiresInteraction = input.requiresInteraction ?? false;
const level = input.level ?? "info";
+ // config.durationMs === 0 means "don't auto-dismiss" (toast stays until dismissed).
const duration = requiresInteraction
? undefined
: level === "error" || level === "fatal"
? ERROR_TTL
- : config.durationMs;
+ : config.durationMs > 0
+ ? config.durationMs
+ : undefined;
items = [
...items,
{