From 092a43b981b45b2aec5f4338dbd3cae6efc905fb Mon Sep 17 00:00:00 2001
From: kami
Date: Mon, 3 Aug 2026 14:22:58 +0400
Subject: [PATCH] feat(vibe): visualize the listening profile
---
frontend/src/components/VibeAura.test.tsx | 12 +++++
frontend/src/components/VibeAura.tsx | 55 ++++++++++++++++++++
frontend/src/index.css | 62 +++++++++++++++++++++++
frontend/src/pages/Vibe.tsx | 36 +++++--------
frontend/src/services/vibeSession.ts | 4 ++
frontend/src/store/useVibeStore.ts | 5 ++
6 files changed, 150 insertions(+), 24 deletions(-)
create mode 100644 frontend/src/components/VibeAura.test.tsx
create mode 100644 frontend/src/components/VibeAura.tsx
diff --git a/frontend/src/components/VibeAura.test.tsx b/frontend/src/components/VibeAura.test.tsx
new file mode 100644
index 0000000..9e8ec9d
--- /dev/null
+++ b/frontend/src/components/VibeAura.test.tsx
@@ -0,0 +1,12 @@
+import { render, screen } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+import { VibeAura } from './VibeAura';
+
+describe('VibeAura', () => {
+ it('turns the live recommendation profile into an accessible ambient state', () => {
+ render( );
+
+ expect(screen.getByRole('img', { name: 'Current Vibe: charged energy and adventurous discovery' })).toBeInTheDocument();
+ expect(screen.getByText('Your Vibe is charged')).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/components/VibeAura.tsx b/frontend/src/components/VibeAura.tsx
new file mode 100644
index 0000000..78fcce2
--- /dev/null
+++ b/frontend/src/components/VibeAura.tsx
@@ -0,0 +1,55 @@
+import type { CSSProperties } from 'react';
+
+export interface VibeProfile {
+ energy?: number;
+ noveltyHunger?: number;
+ explorationCoefficient?: number;
+ discoveryRadius?: number;
+ sessionGoal?: { type?: string; progress?: number; target?: number };
+}
+
+const clamp = (value: number | undefined, fallback: number) =>
+ Math.min(1, Math.max(0, typeof value === 'number' ? value : fallback));
+
+function describeProfile(energy: number, novelty: number) {
+ const energyLabel = energy < 0.36 ? 'settled' : energy > 0.68 ? 'charged' : 'flowing';
+ const discoveryLabel = novelty < 0.36 ? 'familiar' : novelty > 0.64 ? 'adventurous' : 'balanced';
+ return { energyLabel, discoveryLabel };
+}
+
+/** A small, ambient representation of the live recommendation profile. */
+export function VibeAura({ profile }: { profile: VibeProfile }) {
+ const energy = clamp(profile.energy, 0.5);
+ const novelty = clamp(profile.noveltyHunger ?? profile.explorationCoefficient, 0.3);
+ const { energyLabel, discoveryLabel } = describeProfile(energy, novelty);
+ const hue = Math.round(205 + novelty * 115 - energy * 35);
+ const style = {
+ '--vibe-hue': String(hue),
+ '--vibe-pulse': `${(4.8 - energy * 2.3).toFixed(2)}s`,
+ '--vibe-orbit': `${(11 - energy * 4).toFixed(2)}s`,
+ '--vibe-scale': String((0.9 + energy * 0.16).toFixed(2)),
+ } as CSSProperties;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ Your Vibe is {energyLabel}
+ Leaning {discoveryLabel}; it shifts as you listen.
+
+
+ );
+}
diff --git a/frontend/src/index.css b/frontend/src/index.css
index d4222db..833a388 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -183,6 +183,68 @@ html { scroll-behavior: smooth; }
border-radius: 4px;
}
+/* Vibe's profile aura: a small, old-player-inspired creature whose color and
+ movement come from the active recommendation state. */
+@keyframes vibe-aura-pulse {
+ 0%, 100% { transform: scale(var(--vibe-scale)) rotate(-7deg); filter: brightness(.9); }
+ 50% { transform: scale(calc(var(--vibe-scale) * 1.12)) rotate(8deg); filter: brightness(1.28); }
+}
+@keyframes vibe-aura-orbit {
+ to { transform: rotate(360deg); }
+}
+@keyframes vibe-aura-spark {
+ 0%, 100% { transform: translateY(0) scale(.75); opacity: .38; }
+ 50% { transform: translateY(-5px) scale(1.15); opacity: 1; }
+}
+.vibe-aura-halo {
+ position: absolute;
+ inset: 7px;
+ border-radius: 9999px;
+ background: hsl(var(--vibe-hue) 88% 62% / .22);
+ filter: blur(12px);
+ animation: vibe-aura-pulse var(--vibe-pulse) ease-in-out infinite;
+}
+.vibe-aura-core {
+ position: relative;
+ width: 30px;
+ height: 36px;
+ border: 1px solid hsl(var(--vibe-hue) 95% 86% / .72);
+ border-radius: 46% 54% 58% 42% / 43% 45% 55% 57%;
+ background: radial-gradient(circle at 36% 28%, hsl(var(--vibe-hue) 100% 93%), hsl(var(--vibe-hue) 88% 61%) 38%, hsl(calc(var(--vibe-hue) + 35) 68% 31%) 100%);
+ box-shadow: inset 3px 2px 8px hsl(0 0% 100% / .34), 0 0 17px hsl(var(--vibe-hue) 92% 59% / .78);
+ animation: vibe-aura-pulse var(--vibe-pulse) ease-in-out infinite;
+}
+.vibe-aura-orbit {
+ position: absolute;
+ width: 47px;
+ height: 47px;
+ border: 1px solid hsl(var(--vibe-hue) 90% 76% / .36);
+ border-radius: 47% 53% 43% 57%;
+ animation: vibe-aura-orbit var(--vibe-orbit) linear infinite;
+}
+.vibe-aura-orbit-two {
+ width: 56px;
+ height: 37px;
+ border-color: hsl(calc(var(--vibe-hue) + 55) 90% 76% / .24);
+ animation-direction: reverse;
+ animation-duration: calc(var(--vibe-orbit) * 1.45);
+}
+.vibe-aura-heart,
+.vibe-aura-spark {
+ position: absolute;
+ display: block;
+ border-radius: 9999px;
+ background: hsl(0 0% 100% / .9);
+ box-shadow: 0 0 8px hsl(0 0% 100% / .92);
+}
+.vibe-aura-heart { width: 7px; height: 7px; left: 11px; top: 15px; }
+.vibe-aura-spark { width: 4px; height: 4px; animation: vibe-aura-spark 2.1s ease-in-out infinite; }
+.vibe-aura-spark-one { left: -4px; top: 5px; }
+.vibe-aura-spark-two { right: -3px; bottom: 7px; animation-delay: -1s; }
+@media (prefers-reduced-motion: reduce) {
+ .vibe-aura-halo, .vibe-aura-core, .vibe-aura-orbit, .vibe-aura-spark { animation: none; }
+}
+
/* ── Component base classes ────────────────────────────────────────────────── */
/* Card surface — standard container */
diff --git a/frontend/src/pages/Vibe.tsx b/frontend/src/pages/Vibe.tsx
index 32dc1bc..5dca92a 100644
--- a/frontend/src/pages/Vibe.tsx
+++ b/frontend/src/pages/Vibe.tsx
@@ -9,6 +9,7 @@ import { TrackRow } from '../components/TrackRow';
import { PageContainer } from '../components/PageContainer';
import type { Track } from '../types';
import { VibeTimeline } from '../components/VibeTimeline';
+import { VibeAura } from '../components/VibeAura';
import { toast } from '../store/useToastStore';
const SEED_LIST_SIZE = 50;
@@ -22,19 +23,6 @@ function sampleTracks(tracks: Track[], count: number): Track[] {
return sampled.slice(0, count);
}
-function VibeLearningHint() {
- return (
-
- How Vibe learns
-
-
Your seed track sets the first direction. From there, Vibe learns from what you finish, skip, dislike, or keep, alongside your library history and favourites.
-
It also avoids recent repeats and reshapes the upcoming queue as you listen.
-
It does not infer your location, weather, microphone input, or device activity.
-
-
- );
-}
-
export default function Vibe() {
const { currentTrack } = usePlaybackStore();
const {
@@ -42,6 +30,7 @@ export default function Vibe() {
buffer,
initialBatchStatus,
planVersion,
+ profile,
} = useVibeStore();
const [starting, setStarting] = useState(false);
@@ -132,8 +121,6 @@ export default function Vibe() {
-
-
{error && (
{error}
@@ -206,17 +193,18 @@ export default function Vibe() {
Vibing
{initialBatchStatus === 'loading' && }
-
-
- End Vibe
-
+
+
+
+
+ End Vibe
+
+
-
-
{(empty || initialBatchStatus === 'exhausted' || initialBatchStatus === 'failed') && (
{initialBatchStatus === 'failed'
diff --git a/frontend/src/services/vibeSession.ts b/frontend/src/services/vibeSession.ts
index 80d644a..7502872 100644
--- a/frontend/src/services/vibeSession.ts
+++ b/frontend/src/services/vibeSession.ts
@@ -109,6 +109,7 @@ async function reconcilePreview(sessionId: string, response: DurableVibeSessionR
if (!isCurrentVibeOwner(sessionId)) return [];
const preview = await hydratePreview(response.preview);
if (!isCurrentVibeOwner(sessionId) || !useVibeStore.getState().setPlan(response.planVersion, preview)) return [];
+ useVibeStore.getState().setProfile(response.state);
replaceUnplayedQueue(preview);
return preview;
}
@@ -321,6 +322,7 @@ export function advanceVibe(reason: VibeAdvanceReason): Promise {
return;
}
if (!useVibeStore.getState().setPlan(served.response.planVersion, served.preview)) return;
+ useVibeStore.getState().setProfile(served.response.state);
useVibeStore.getState().setCurrentPlanItem(served.response.now);
replaceUnplayedQueue([served.now, ...served.preview]);
usePlaybackStore.getState().advance();
@@ -367,6 +369,7 @@ export function advancePastUnplayableVibeTrack(trackId: string): Promise {
return;
}
if (!useVibeStore.getState().setPlan(served.response.planVersion, served.preview)) return;
+ useVibeStore.getState().setProfile(served.response.state);
useVibeStore.getState().setCurrentPlanItem(served.response.now);
replaceUnplayedQueue([served.now, ...served.preview]);
playback.advance();
@@ -403,6 +406,7 @@ export async function startVibeSession(seed: Track): Promise
vibe.setActiveSession({ sessionId: started.sessionId, seedTrackId: seed.id });
vibe.setCenterTrack(seed);
vibe.setPlan(served.response.planVersion, served.preview);
+ vibe.setProfile(served.response.state);
vibe.setCurrentPlanItem(served.response.now);
vibe.setInitialBatchStatus('idle');
diff --git a/frontend/src/store/useVibeStore.ts b/frontend/src/store/useVibeStore.ts
index e079da3..22ea313 100644
--- a/frontend/src/store/useVibeStore.ts
+++ b/frontend/src/store/useVibeStore.ts
@@ -1,6 +1,7 @@
import { create } from 'zustand';
import type { Track, VibeSession } from '../types';
import type { VibePlanItem } from '../services/vibeService';
+import type { VibeProfile } from '../components/VibeAura';
// The durable plan is authoritative. `buffer` is only its currently
// uncommitted, hydrated preview; it may be replaced at any feedback boundary.
@@ -10,6 +11,7 @@ interface VibeState {
planVersion: number | null;
/** Durable cursor for the track currently in Vibe playback. */
currentPlanItem: VibePlanItem | null;
+ profile: VibeProfile;
centerTrack: Track | null;
buffer: Track[];
initialBatchStatus: 'idle' | 'loading' | 'exhausted' | 'failed';
@@ -20,6 +22,7 @@ interface VibeState {
/** Returns false when a response belongs to an older plan revision. */
setPlan: (planVersion: number | null, preview: Track[]) => boolean;
setCurrentPlanItem: (item: VibePlanItem | null) => void;
+ setProfile: (profile: VibeProfile) => void;
setInitialBatchStatus: (status: VibeState['initialBatchStatus']) => void;
reset: () => void;
}
@@ -29,6 +32,7 @@ const initialState = {
seedTrackId: null as string | null,
planVersion: null as number | null,
currentPlanItem: null as VibePlanItem | null,
+ profile: {} as VibeProfile,
centerTrack: null as Track | null,
buffer: [] as Track[],
initialBatchStatus: 'idle' as const,
@@ -51,6 +55,7 @@ export const useVibeStore = create((set, get) => ({
return true;
},
setCurrentPlanItem: (currentPlanItem) => set({ currentPlanItem }),
+ setProfile: (profile) => set({ profile }),
setInitialBatchStatus: (initialBatchStatus) => set({ initialBatchStatus }),
reset: () => set({ ...initialState }),
}));