feat(vibe): visualize the listening profile
This commit is contained in:
@@ -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(<VibeAura profile={{ energy: 0.82, noveltyHunger: 0.78 }} />);
|
||||
|
||||
expect(screen.getByRole('img', { name: 'Current Vibe: charged energy and adventurous discovery' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Your Vibe is charged')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<div
|
||||
className="group relative grid h-16 w-16 shrink-0 place-items-center rounded-full focus:outline-none"
|
||||
style={style}
|
||||
role="img"
|
||||
tabIndex={0}
|
||||
aria-label={`Current Vibe: ${energyLabel} energy and ${discoveryLabel} discovery`}
|
||||
>
|
||||
<div className="vibe-aura-halo" />
|
||||
<div className="vibe-aura-orbit vibe-aura-orbit-one" />
|
||||
<div className="vibe-aura-orbit vibe-aura-orbit-two" />
|
||||
<div className="vibe-aura-core">
|
||||
<span className="vibe-aura-spark vibe-aura-spark-one" />
|
||||
<span className="vibe-aura-spark vibe-aura-spark-two" />
|
||||
<span className="vibe-aura-heart" />
|
||||
</div>
|
||||
<div className="pointer-events-none absolute right-0 top-[calc(100%+0.4rem)] z-10 w-44 rounded-md border border-border bg-surface1 px-3 py-2 text-xs leading-relaxed text-muted opacity-0 shadow-xl transition-opacity group-hover:opacity-100 group-focus:opacity-100">
|
||||
<span className="block font-medium text-text">Your Vibe is {energyLabel}</span>
|
||||
<span>Leaning {discoveryLabel}; it shifts as you listen.</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user