Skip to content

Commit 8604bd6

Browse files
feat(genius): refresh chat-style UI layout
Remove header block, add background glasses watermark at 3% opacity, move composer to bottom, replace static prompt buttons with 29 shuffled example prompts that cycle with slow fade transitions. Enter generates, Shift+Enter inserts newline. Enlarge submit button and center vertically. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 13bd3f5 commit 8604bd6

4 files changed

Lines changed: 421 additions & 138 deletions

File tree

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
/**
2+
* Unit tests for the Genius Browser component.
3+
*
4+
* Verifies:
5+
* - Animated prompt cycling (fade in/out through example prompts)
6+
* - Shift+Enter keybinding (not Cmd+Enter)
7+
* - Onboarding state transitions
8+
* - Playlist generation flow
9+
*/
10+
11+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
12+
13+
// Mock agent API
14+
const mockAgent = {
15+
getOnboardingState: vi.fn(),
16+
checkOllama: vi.fn(),
17+
pullModel: vi.fn(),
18+
setOnboardingComplete: vi.fn(),
19+
generatePlaylist: vi.fn(),
20+
};
21+
22+
vi.mock('../js/api/agent.js', () => ({ agent: mockAgent }));
23+
24+
// Minimal Alpine mock
25+
const registeredComponents = {};
26+
const mockAlpine = {
27+
data: vi.fn((name, factory) => {
28+
registeredComponents[name] = factory;
29+
}),
30+
};
31+
32+
// Import component
33+
const { createGeniusBrowser } = await import('../js/components/genius-browser.js');
34+
35+
function createInstance(overrides = {}) {
36+
createGeniusBrowser(mockAlpine);
37+
const factory = registeredComponents['geniusBrowser'];
38+
const instance = factory();
39+
40+
// Provide Alpine reactive context stubs
41+
instance.$store = { ui: { view: 'genius', toast: vi.fn() } };
42+
instance.$watch = vi.fn();
43+
44+
Object.assign(instance, overrides);
45+
return instance;
46+
}
47+
48+
describe('Genius Browser', () => {
49+
beforeEach(() => {
50+
vi.useFakeTimers();
51+
vi.clearAllMocks();
52+
});
53+
54+
afterEach(() => {
55+
vi.useRealTimers();
56+
});
57+
58+
describe('animated prompt cycling', () => {
59+
it('initializes with example prompts list', () => {
60+
const instance = createInstance();
61+
expect(instance._promptExamples.length).toBeGreaterThanOrEqual(3);
62+
expect(instance._promptExamples[0]).toBe('make me a chill playlist from my library');
63+
});
64+
65+
it('starts cycling on init and becomes visible', () => {
66+
const instance = createInstance();
67+
mockAgent.getOnboardingState.mockResolvedValue({ completed: true });
68+
69+
instance.init();
70+
71+
expect(instance._animatedText).toBe(instance._promptExamples[0]);
72+
expect(instance._animatedVisible).toBe(true);
73+
});
74+
75+
it('fades out after hold period and advances to next prompt', () => {
76+
const instance = createInstance();
77+
instance._startPromptCycle();
78+
79+
expect(instance._animatedVisible).toBe(true);
80+
expect(instance._animatedText).toBe(instance._promptExamples[0]);
81+
82+
// After 3.9s hold, fades out
83+
vi.advanceTimersByTime(3900);
84+
expect(instance._animatedVisible).toBe(false);
85+
86+
// After 1.3s fade-out, advances to next
87+
vi.advanceTimersByTime(1300);
88+
expect(instance._animatedText).toBe(instance._promptExamples[1]);
89+
expect(instance._animatedVisible).toBe(true);
90+
});
91+
92+
it('wraps around to first prompt after cycling through all', () => {
93+
const instance = createInstance();
94+
instance._startPromptCycle();
95+
96+
// Capture the shuffled order
97+
const firstPrompt = instance._promptExamples[0];
98+
const count = instance._promptExamples.length;
99+
// Advance through all prompts (3.9s hold + 1.3s fade each)
100+
for (let i = 0; i < count; i++) {
101+
vi.advanceTimersByTime(5200);
102+
}
103+
104+
expect(instance._animatedText).toBe(firstPrompt);
105+
});
106+
107+
it('shuffles prompt order on start', () => {
108+
// Run multiple starts and check that at least one produces a different first element
109+
const results = new Set();
110+
for (let i = 0; i < 20; i++) {
111+
const inst = createInstance();
112+
inst._startPromptCycle();
113+
results.add(inst._promptExamples[0]);
114+
inst._stopPromptCycle();
115+
}
116+
expect(results.size).toBeGreaterThan(1);
117+
});
118+
119+
it('stops cycling on destroy', () => {
120+
const instance = createInstance();
121+
instance._startPromptCycle();
122+
instance.destroy();
123+
expect(instance._animateTimer).toBeNull();
124+
});
125+
});
126+
127+
describe('keyboard shortcut', () => {
128+
it('generate() requires non-empty prompt', async () => {
129+
const instance = createInstance();
130+
instance.onboardingComplete = true;
131+
instance.prompt = '';
132+
133+
await instance.generate();
134+
135+
expect(mockAgent.generatePlaylist).not.toHaveBeenCalled();
136+
});
137+
138+
it('generate() calls agent with trimmed prompt', async () => {
139+
const instance = createInstance();
140+
instance.onboardingComplete = true;
141+
instance.prompt = ' chill vibes ';
142+
mockAgent.generatePlaylist.mockResolvedValue({
143+
status: 'success',
144+
playlist_name: 'Chill Vibes',
145+
playlist_id: 1,
146+
track_count: 10,
147+
});
148+
149+
// Stub dispatchEvent for playlist-updated event
150+
globalThis.window = { dispatchEvent: vi.fn() };
151+
globalThis.CustomEvent = class CustomEvent {
152+
constructor(type, opts) {
153+
this.type = type;
154+
this.detail = opts?.detail;
155+
}
156+
};
157+
158+
await instance.generate();
159+
160+
expect(mockAgent.generatePlaylist).toHaveBeenCalledWith('chill vibes');
161+
expect(instance.result.status).toBe('success');
162+
expect(instance.history).toHaveLength(1);
163+
expect(instance.prompt).toBe('');
164+
});
165+
});
166+
167+
describe('onboarding', () => {
168+
it('skips onboarding when already completed', async () => {
169+
const instance = createInstance();
170+
mockAgent.getOnboardingState.mockResolvedValue({ completed: true });
171+
172+
await instance._checkOnboarding();
173+
174+
expect(instance.onboardingComplete).toBe(true);
175+
expect(instance.onboardingStep).toBe('ready');
176+
});
177+
178+
it('shows install-ollama when not connected', async () => {
179+
const instance = createInstance();
180+
mockAgent.getOnboardingState.mockResolvedValue({ completed: false });
181+
mockAgent.checkOllama.mockResolvedValue({ connected: false, models: [] });
182+
183+
await instance._checkOnboarding();
184+
185+
expect(instance.onboardingStep).toBe('install-ollama');
186+
});
187+
188+
it('shows download-model when connected but model missing', async () => {
189+
const instance = createInstance();
190+
mockAgent.getOnboardingState.mockResolvedValue({ completed: false });
191+
mockAgent.checkOllama.mockResolvedValue({ connected: true, models: [] });
192+
193+
await instance._checkOnboarding();
194+
195+
expect(instance.onboardingStep).toBe('download-model');
196+
});
197+
198+
it('completes onboarding when model is present', async () => {
199+
const instance = createInstance();
200+
mockAgent.getOnboardingState.mockResolvedValue({ completed: false });
201+
mockAgent.checkOllama.mockResolvedValue({ connected: true, models: ['qwen3.5:9b'] });
202+
mockAgent.setOnboardingComplete.mockResolvedValue();
203+
204+
await instance._checkOnboarding();
205+
206+
expect(instance.onboardingComplete).toBe(true);
207+
expect(instance.onboardingStep).toBe('ready');
208+
});
209+
});
210+
});

app/frontend/js/components/genius-browser.js

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,43 @@ export function createGeniusBrowser(Alpine) {
2727
result: null, // { status, playlist_id, playlist_name, track_count, message }
2828
history: [], // past generations for this session
2929

30+
// Animated prompt cycling
31+
_animatedText: '',
32+
_animatedVisible: false,
33+
_animateTimer: null,
34+
_promptExamples: [
35+
'make me a chill playlist from my library',
36+
'something similar to what I listened to recently',
37+
"find me post-punk artists I don't usually listen to",
38+
'upbeat tracks for a morning run',
39+
'rainy day songs with acoustic guitars',
40+
"deep cuts I haven't played in months",
41+
'a late-night driving mix',
42+
'something moody and atmospheric',
43+
'high energy tracks for cleaning the house',
44+
'jazz and soul from the 60s and 70s',
45+
'songs that build slowly then explode',
46+
'artists similar to Radiohead in my library',
47+
'a Sunday morning coffee playlist',
48+
'tracks with heavy bass lines',
49+
'my most played songs from this year',
50+
'something dreamy and shoegaze-y',
51+
'a workout mix that keeps escalating',
52+
'underrated albums I barely touched',
53+
'folksy singer-songwriter vibes',
54+
"electronic music that isn't too intense",
55+
'songs to cook dinner to',
56+
'a road trip playlist from my collection',
57+
'melancholy but beautiful tracks',
58+
'hip-hop and R&B from the 90s',
59+
'everything by female vocalists',
60+
'instrumental tracks only',
61+
'songs under three minutes',
62+
'a party mix from what I already have',
63+
'blues and classic rock deep cuts',
64+
],
65+
_promptIndex: 0,
66+
3067
// Event cleanup
3168
_unlisten: null,
3269

@@ -41,19 +78,61 @@ export function createGeniusBrowser(Alpine) {
4178
if (this.$store.ui.view === 'genius') {
4279
this._checkOnboarding();
4380
}
81+
82+
this._startPromptCycle();
4483
},
4584

4685
destroy() {
4786
if (this._unlisten) {
4887
this._unlisten();
4988
this._unlisten = null;
5089
}
90+
this._stopPromptCycle();
5191
},
5292

5393
get ui() {
5494
return this.$store.ui;
5595
},
5696

97+
// --- Animated prompt cycling ---
98+
99+
_startPromptCycle() {
100+
// Fisher-Yates shuffle for a fresh order each session
101+
for (let i = this._promptExamples.length - 1; i > 0; i--) {
102+
const j = Math.floor(Math.random() * (i + 1));
103+
[this._promptExamples[i], this._promptExamples[j]] = [
104+
this._promptExamples[j],
105+
this._promptExamples[i],
106+
];
107+
}
108+
this._promptIndex = 0;
109+
this._showNextPrompt();
110+
},
111+
112+
_stopPromptCycle() {
113+
if (this._animateTimer) {
114+
clearTimeout(this._animateTimer);
115+
this._animateTimer = null;
116+
}
117+
},
118+
119+
_showNextPrompt() {
120+
// Fade in
121+
this._animatedText = this._promptExamples[this._promptIndex];
122+
this._animatedVisible = true;
123+
124+
// Hold visible for 3.9s, then fade out
125+
this._animateTimer = setTimeout(() => {
126+
this._animatedVisible = false;
127+
128+
// After fade-out completes (1.3s), advance and show next
129+
this._animateTimer = setTimeout(() => {
130+
this._promptIndex = (this._promptIndex + 1) % this._promptExamples.length;
131+
this._showNextPrompt();
132+
}, 1300);
133+
}, 3900);
134+
},
135+
57136
// --- Onboarding ---
58137

59138
async _checkOnboarding() {

0 commit comments

Comments
 (0)