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' && }
- +
+ + +
- - {(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 }), }));