diff --git a/docs/logboek.md b/docs/logboek.md index 139966d..e0782b2 100644 --- a/docs/logboek.md +++ b/docs/logboek.md @@ -273,3 +273,48 @@ Waarom: - Machine-geconverteerde UI-code krijgt dezelfde behandeling als de backend: een review-pass met concrete findings en een test-suite die de contractkritische randen vastzet, voordat het richting main gaat. + +## Dag 3 - avond +### Live eval run + +Wat ik deed: + +- Alle 8 scenario's in 1 run live gedraaid, plus de baseline arm (bare + search) voor de comparison table. +- Het Spotify-dagquotum was eerder al een keer vol; daarom vooraf + maatregelen genomen: scenario's sequentieel, geen retries, de server-boot + zelf als quota-check, en live meegekeken op 429's (niet gehit). +- Uitkomst: 4 scenario's pass, 3 vallen alleen op een te strenge + synonym-check in de eval zelf (gefixt), en discover-new faalt echt: + call 1 verzint bij familiarity=new titels die niet bestaan, dus er + overleeft niets de grounding. Fix volgt in de intent prompt. +- De refinement turn deed live 0 Spotify calls: pool reuse werkt. + +Waarom: + +- De comparison table in de README wil ik op echte metingen baseren, en het + quotum maakt herhalen duur. + +Wat ik heb laten vallen of uitgesteld: + +- discover-new opnieuw opnemen wacht op de prompt fix. + +### Frontend: presentatie + +Wat ik deed: + +- Cards komen rustig gestaggerd binnen (met reduced-motion pad), results + en chips scrollen in eigen panelen zodat de summary zichtbaar blijft, + klik op het logo start een nieuwe chat, fonts self-hosted. +- Een error die voor de metadata binnenkomt is nu een echte foutmelding + in de chat, geen transport failure. Playlist-namen hadden een dubbele + prefix; de frontend stuurt nu de kale query. 10 nieuwe tests. + +Waarom: + +- Dit is wat de reviewer als eerste ziet; rustige presentatie en eerlijke + foutmeldingen gaan voor extra features. + +Wat ik heb laten vallen of uitgesteld: + +- Verdere styling; de tijd gaat naar eval, demo mode en de README. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8052233..b8de0b4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,9 @@ "name": "frontend", "version": "0.0.0", "dependencies": { + "@fontsource/bricolage-grotesque": "^5.3.0", + "@fontsource/jetbrains-mono": "^5.3.0", + "@fontsource/public-sans": "^5.3.0", "vue": "^3.5.40" }, "devDependencies": { @@ -156,6 +159,30 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, + "node_modules/@fontsource/bricolage-grotesque": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/bricolage-grotesque/-/bricolage-grotesque-5.3.0.tgz", + "integrity": "sha512-MdOVb/5in11IfN/IQJOExMuu6AroCIQbirl0yX7NUvKk+h0HYIKfGaVAXRS/gCPil4aFxWhjZnOw28tUwv7tFw==", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/jetbrains-mono": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.3.0.tgz", + "integrity": "sha512-fqDfB5I9f1p1TV486aUgB9t8zP84P0O1FtQR5Ol9vjwPy+S+EIGlVYm1cvj2W5shcZMTg2nZFdVMoH5wFu8a1A==", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/public-sans": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/public-sans/-/public-sans-5.3.0.tgz", + "integrity": "sha512-kjODI0S3zdv0mBYCIQ8TbBayaiqszpc2UbhJiO3bjIqVVXzcWfHSt2o3WBCLOY3juaGaQoy4MoWCcgmfI5hCuA==", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index a47d400..a4e68a3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,6 +13,9 @@ "typecheck": "vue-tsc --noEmit -p tsconfig.app.json" }, "dependencies": { + "@fontsource/bricolage-grotesque": "^5.3.0", + "@fontsource/jetbrains-mono": "^5.3.0", + "@fontsource/public-sans": "^5.3.0", "vue": "^3.5.40" }, "devDependencies": { diff --git a/frontend/src/components/AppHeader.vue b/frontend/src/components/AppHeader.vue index 5e6d62f..786f1ff 100644 --- a/frontend/src/components/AppHeader.vue +++ b/frontend/src/components/AppHeader.vue @@ -9,7 +9,7 @@ const props = defineProps<{ mode: AppMode | null healthFailed: boolean }>() -const emit = defineEmits<{ logout: []; toggleDev: [opener: HTMLElement] }>() +const emit = defineEmits<{ logout: []; reset: []; toggleDev: [opener: HTMLElement] }>() function openDevPanel(event: MouseEvent): void { if (event.currentTarget instanceof HTMLElement) { @@ -29,11 +29,18 @@ const modeLabel = computed(() => {
- {{ messages.appHeaderBrandPrefix - }}{{ messages.appHeaderBrandAccent }}{{ messages.appHeaderBrandSuffix }} + {{ messages.appHeaderBrandPrefix + }}{{ messages.appHeaderBrandAccent }}{{ messages.appHeaderBrandSuffix }} + {{ messages.appHeaderKicker }}
@@ -78,6 +85,20 @@ const modeLabel = computed(() => { min-width: 0; } +.brand-control { + padding: 0; + color: inherit; + cursor: pointer; + background: transparent; + border: 0; + border-radius: var(--r-sm); +} + +.brand-control:focus-visible { + outline: 2px solid var(--c-accent); + outline-offset: 4px; +} + .wordmark { font-family: var(--f-display); font-size: var(--t-brand); diff --git a/frontend/src/components/AssistantMessage.vue b/frontend/src/components/AssistantMessage.vue index 85c3d02..b974c8b 100644 --- a/frontend/src/components/AssistantMessage.vue +++ b/frontend/src/components/AssistantMessage.vue @@ -42,8 +42,6 @@ const isEmptyResult = computed(

{{ turn.intentSummary }}

- - + +
diff --git a/frontend/src/components/ChatView.vue b/frontend/src/components/ChatView.vue index fb1f585..fa7a697 100644 --- a/frontend/src/components/ChatView.vue +++ b/frontend/src/components/ChatView.vue @@ -112,6 +112,7 @@ onUnmounted(() => window.removeEventListener('keydown', onShortcut)) :mode="mode" :health-failed="health.status === 'failed'" @logout="logout" + @reset="reset" @toggle-dev="openDevPanel" /> diff --git a/frontend/src/components/MessageList.vue b/frontend/src/components/MessageList.vue index d6676b3..50fc7e0 100644 --- a/frontend/src/components/MessageList.vue +++ b/frontend/src/components/MessageList.vue @@ -69,7 +69,13 @@ async function scrollToLatest(): Promise { if (!pinned.value) return await nextTick() requestAnimationFrame(() => { - if (list.value) list.value.scrollTop = list.value.scrollHeight + const element = list.value + if (!element) return + if (latestAssistant.value?.status === 'done') { + element.querySelector('.turn:last-child')?.scrollIntoView({ block: 'start' }) + return + } + element.scrollTop = element.scrollHeight }) } diff --git a/frontend/src/components/ResultSet.vue b/frontend/src/components/ResultSet.vue index 7e46790..8030cf9 100644 --- a/frontend/src/components/ResultSet.vue +++ b/frontend/src/components/ResultSet.vue @@ -1,14 +1,28 @@ @@ -17,5 +31,18 @@ defineProps<{ tracks: TrackEvent[] }>() display: flex; flex-direction: column; gap: var(--s-2); + max-height: min(42dvh, 480px); + padding: var(--s-2) var(--s-3) var(--s-2) 0; + overflow-y: auto; + overscroll-behavior-y: contain; + border-block: 1px solid var(--c-line); + scrollbar-color: var(--c-line-strong) transparent; + scrollbar-width: thin; +} + +@media (max-width: 560px) { + .set { + max-height: min(34dvh, 360px); + } } diff --git a/frontend/src/components/SuggestionChips.vue b/frontend/src/components/SuggestionChips.vue index 3252a6b..8f0dfdd 100644 --- a/frontend/src/components/SuggestionChips.vue +++ b/frontend/src/components/SuggestionChips.vue @@ -1,27 +1,60 @@ diff --git a/frontend/src/components/TrackCard.vue b/frontend/src/components/TrackCard.vue index 8244a9f..af8b3e3 100644 --- a/frontend/src/components/TrackCard.vue +++ b/frontend/src/components/TrackCard.vue @@ -53,6 +53,7 @@ const artworkAlt = computed(() => border-color var(--dur-fast) ease, background var(--dur-fast) ease; animation: card-in var(--dur-card) var(--ease) both; + animation-delay: calc(var(--card-order, 0) * var(--dur-card-stagger)); } .card:hover { diff --git a/frontend/src/composables/useChatStream.ts b/frontend/src/composables/useChatStream.ts index 1e9b2b1..71ac7cd 100644 --- a/frontend/src/composables/useChatStream.ts +++ b/frontend/src/composables/useChatStream.ts @@ -7,7 +7,7 @@ import { PRIOR_RECOMMENDATIONS_MAX_TRACKS, QUERY_MAX_LENGTH, } from '../lib/constants' -import { formatMessage, messages } from '../lib/messages' +import { messages } from '../lib/messages' import type { AssistantTurn, ChatTurn, EventLogEntry, TransportFailure } from '../lib/models' import { EMPTY_PLAYLIST_STATE } from '../lib/models' import { @@ -101,9 +101,7 @@ function reduceEvent(turn: AssistantTurn, event: StreamEvent): AssistantTurn { } function playlistName(query: string): string { - return formatMessage('useChatStreamPlaylistName', { query }) - .slice(0, PLAYLIST_NAME_MAX_LENGTH) - .trim() + return query.slice(0, PLAYLIST_NAME_MAX_LENGTH).trim() } function createTurnId(): string { diff --git a/frontend/src/lib/messages.ts b/frontend/src/lib/messages.ts index 11508c1..4add1fe 100644 --- a/frontend/src/lib/messages.ts +++ b/frontend/src/lib/messages.ts @@ -2,6 +2,7 @@ export const messages = { appHeaderBrandPrefix: 'discovery', + appHeaderBrandResetLabel: 'discovery-by-llm: start a new chat', appHeaderBrandAccent: '-by-', appHeaderBrandSuffix: 'llm', appHeaderKicker: 'proof of concept', @@ -90,10 +91,13 @@ export const messages = { resultActionsConnectSpotify: 'Connect Spotify to save', resultActionsSummary: '{count} verified tracks ready for a private playlist', - resultSetRecommendedTracksLabel: 'Recommended tracks', + resultSetRecommendedTrackLabel: '{count} recommended track', + resultSetRecommendedTracksLabel: '{count} recommended tracks', streamErrorRetry: 'Edit and retry', + suggestionChipsRegionLabel: 'Listening suggestions', + trackCardArtworkAlt: '{album} album artwork', trackCardOpenSpotify: 'Open in Spotify', @@ -110,7 +114,6 @@ export const messages = { useApiPlaylistCreationFailed: 'Playlist creation returned {status}.', useChatStreamTrackCountMismatch: 'The final track count did not match the streamed results.', - useChatStreamPlaylistName: '[discovery-by-llm] {query}', useChatStreamRequestReplaced: 'This request was replaced by a newer request.', useChatStreamUnexpectedFailure: 'The recommendation request failed unexpectedly.', useChatStreamTransportEvent: 'transport', diff --git a/frontend/src/lib/recommendationStream.ts b/frontend/src/lib/recommendationStream.ts index f8580d3..6c89a64 100644 --- a/frontend/src/lib/recommendationStream.ts +++ b/frontend/src/lib/recommendationStream.ts @@ -36,6 +36,7 @@ function parseLine(line: string): StreamEvent { function advancePhase(phase: StreamPhase, event: StreamEvent): StreamPhase { if (phase === 'metadata') { if (event.type === 'metadata') return 'events' + if (event.type === 'error') return 'terminal' throw new StreamTransportError('protocol', messages.recommendationStreamMissingMetadata) } if (phase === 'events') { diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 2425c0f..37bbb42 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -1,4 +1,8 @@ import { createApp } from 'vue' +import '@fontsource/bricolage-grotesque/600.css' +import '@fontsource/jetbrains-mono/400.css' +import '@fontsource/public-sans/400.css' +import '@fontsource/public-sans/500.css' import './style.css' import App from './App.vue' diff --git a/frontend/src/style.css b/frontend/src/style.css index fb429cb..a43b9a6 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -74,6 +74,11 @@ --shadow-drawer: -24px 0 60px rgba(0, 0, 0, 0.45); --ease: cubic-bezier(0.2, 0.8, 0.3, 1); + --dur-fast: 0.16s; + --dur-card: 0.4s; + --dur-card-stagger: 0.08s; + --dur-reduced-motion: 0.001s; + color-scheme: dark; color: var(--c-text); background: var(--c-bg); @@ -151,7 +156,9 @@ a:hover { *::before, *::after { scroll-behavior: auto !important; + animation-delay: var(--dur-reduced-motion) !important; animation-duration: var(--dur-reduced-motion) !important; + transition-delay: var(--dur-reduced-motion) !important; transition-duration: var(--dur-reduced-motion) !important; } } diff --git a/frontend/tests/recommendationStream.test.ts b/frontend/tests/recommendationStream.test.ts index f9bb7b4..436fe4b 100644 --- a/frontend/tests/recommendationStream.test.ts +++ b/frontend/tests/recommendationStream.test.ts @@ -128,8 +128,23 @@ describe('streamRecommendations', () => { expect(wasCancelled).toBe(true) }) + it('accepts a terminal error as the only event', async () => { + const error = { type: 'error', code: 'first_stage_failed', message: 'Planning failed.' } + stubResponse(streamResponse([encoder.encode(`${JSON.stringify(error)}\n`)])) + const events: StreamEvent[] = [] + + await streamRecommendations( + { schema_version: 1, query: 'focus', history: [], prior_recommendations: [] }, + new AbortController().signal, + (event) => events.push(event), + ) + + expect(events).toEqual([error]) + }) + it.each([ ['an event before metadata', [{ type: 'warning', code: 'early', message: 'No metadata.' }]], + ['done before metadata', [{ type: 'done', track_count: 0, total_ms: 4 }]], [ 'duplicate metadata', [ diff --git a/frontend/tests/resultPresentation.test.ts b/frontend/tests/resultPresentation.test.ts new file mode 100644 index 0000000..d46ca31 --- /dev/null +++ b/frontend/tests/resultPresentation.test.ts @@ -0,0 +1,186 @@ +import { createApp, defineComponent, h, nextTick, ref } from 'vue' +import type { Component } from 'vue' +import { afterEach, describe, expect, it, vi } from 'vitest' +import AppHeader from '../src/components/AppHeader.vue' +import AssistantMessage from '../src/components/AssistantMessage.vue' +import MessageList from '../src/components/MessageList.vue' +import SuggestionChips from '../src/components/SuggestionChips.vue' +import type { AssistantTurn, ChatTurn } from '../src/lib/models' +import type { TrackEvent } from '../src/lib/types' + +const unmountCallbacks: Array<() => void> = [] + +function track(rank: number): TrackEvent { + return { + type: 'track', + rank, + track: { + id: `track-${rank}`, + uri: `spotify:track:${rank}`, + title: `Track ${rank}`, + artists: ['Artist'], + album_name: 'Album', + album_art_url: null, + external_url: null, + }, + justification: 'It fits.', + } +} + +function doneTurn(): AssistantTurn { + return { + id: 'assistant-1', + role: 'assistant', + query: 'Focused listening', + status: 'done', + requestId: 'request-1', + intentSummary: 'Calm music for focused work.', + candidateCount: 20, + tracks: [track(1), track(2)], + warnings: [{ type: 'warning', code: 'limited_pool', message: 'The pool was limited.' }], + error: null, + transportFailure: null, + completion: { type: 'done', track_count: 2, total_ms: 12 }, + playlist: { status: 'idle', name: null, url: null, message: null }, + } +} + +function mountComponent(component: Component, props: Record): HTMLElement { + const root = document.createElement('div') + document.body.append(root) + const app = createApp( + defineComponent({ + render: () => h(component, props), + }), + ) + app.mount(root) + unmountCallbacks.push(() => { + app.unmount() + root.remove() + }) + return root +} + +afterEach(() => { + while (unmountCallbacks.length) unmountCallbacks.pop()?.() + vi.restoreAllMocks() +}) + +describe('result presentation', () => { + it('uses the app title as a fresh-chat control', () => { + const onReset = vi.fn() + const root = mountComponent(AppHeader, { + auth: { status: 'anonymous', user: null, message: null }, + mode: 'demo', + healthFailed: false, + onReset, + }) + const button = root.querySelector( + 'button[aria-label="discovery-by-llm: start a new chat"]', + ) + + expect(button?.type).toBe('button') + expect(button?.textContent).toContain('discovery-by-llm') + button?.click() + expect(onReset).toHaveBeenCalledOnce() + }) + + it('keeps completion context outside the focusable track region', () => { + const root = mountComponent(AssistantMessage, { turn: doneTurn(), canSave: true }) + const region = root.querySelector( + '[role="region"][aria-label="2 recommended tracks"]', + ) + const summary = root.querySelector('.intent') + const warning = root.querySelector('[data-warning-code="limited_pool"]') + const action = root.querySelector('button') + + expect(region?.tabIndex).toBe(0) + expect(region?.querySelectorAll('article')).toHaveLength(2) + expect(summary?.textContent).toContain('Calm music') + expect(warning?.textContent).toContain('pool was limited') + expect(action?.textContent).toContain('Save as playlist') + expect(region?.contains(summary ?? null)).toBe(false) + expect(region?.contains(warning ?? null)).toBe(false) + expect(region?.contains(action ?? null)).toBe(false) + }) + + it('assigns a deliberate stagger order to arriving cards', () => { + const root = mountComponent(AssistantMessage, { turn: doneTurn(), canSave: true }) + const cards = root.querySelectorAll('article') + + expect(cards[0]?.style.getPropertyValue('--card-order')).toBe('0') + expect(cards[1]?.style.getPropertyValue('--card-order')).toBe('1') + }) + + it('anchors a completed response at the top of the conversation viewport', async () => { + const scrollIntoView = vi + .spyOn(Element.prototype, 'scrollIntoView') + .mockImplementation(() => undefined) + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => { + callback(0) + return 1 + }) + const complete = doneTurn() + const turns = ref([ + { id: 'user-1', role: 'user', text: 'Focused listening' }, + { ...complete, status: 'streaming', completion: null }, + ]) + const root = document.createElement('div') + document.body.append(root) + const app = createApp( + defineComponent({ + render: () => + h(MessageList, { + turns: turns.value, + suggestions: [], + canSave: true, + }), + }), + ) + app.mount(root) + unmountCallbacks.push(() => { + app.unmount() + root.remove() + }) + + turns.value = [turns.value[0] as ChatTurn, complete] + await nextTick() + await nextTick() + + expect(scrollIntoView).toHaveBeenCalledWith({ block: 'start' }) + }) + + it('keeps suggestions in a labeled focusable row', () => { + const onPick = vi.fn() + const suggestions = ['Focus', 'Energy', 'Surprise'] + const root = mountComponent(SuggestionChips, { suggestions, onPick }) + const region = root.querySelector('[aria-label="Listening suggestions"]') + const buttons = root.querySelectorAll('button') + + expect(region?.tabIndex).toBe(0) + expect(buttons).toHaveLength(suggestions.length) + expect([...buttons].map((button) => button.textContent?.trim())).toEqual(suggestions) + + buttons[0]?.click() + expect(onPick).toHaveBeenCalledWith('Focus') + }) + + it('renders a first-stage stream error with its code and message', () => { + const turn: AssistantTurn = { + ...doneTurn(), + status: 'error', + requestId: null, + intentSummary: '', + tracks: [], + warnings: [], + error: { type: 'error', code: 'first_stage_failed', message: 'Planning failed.' }, + transportFailure: null, + completion: null, + } + const root = mountComponent(AssistantMessage, { turn, canSave: false }) + const alert = root.querySelector('[role="alert"]') + + expect(alert?.dataset.errorCode).toBe('first_stage_failed') + expect(alert?.textContent).toContain('Planning failed.') + }) +}) diff --git a/frontend/tests/useChatStream.test.ts b/frontend/tests/useChatStream.test.ts index 1a8fbf9..5adaf8d 100644 --- a/frontend/tests/useChatStream.test.ts +++ b/frontend/tests/useChatStream.test.ts @@ -13,14 +13,16 @@ vi.mock('../src/lib/recommendationStream', async (importOriginal) => { const streamMock = vi.mocked(streamRecommendations) const unmountCallbacks: Array<() => void> = [] -function mountChat() { +function mountChat( + createPlaylist: Parameters[0] = vi.fn(async () => ({ url: null })), +) { let chat: ReturnType | undefined const root = document.createElement('div') document.body.append(root) const app = createApp( defineComponent({ setup() { - chat = useChatStream(vi.fn(async () => ({ url: null }))) + chat = useChatStream(createPlaylist) return () => h('div') }, }), @@ -106,6 +108,22 @@ describe('useChatStream', () => { expect(turn?.transportFailure).toBeNull() }) + it('stores a metadata-free terminal error as a normal stream error', async () => { + streamMock.mockImplementation(async (_request, _signal, onEvent) => { + onEvent({ type: 'error', code: 'first_stage_failed', message: 'Planning failed.' }) + }) + const chat = mountChat() + + await chat.send('error') + + expect(assistantTurns(chat)[0]).toMatchObject({ + status: 'error', + requestId: null, + error: { code: 'first_stage_failed', message: 'Planning failed.' }, + transportFailure: null, + }) + }) + it('stores an incomplete stream as a transport failure', async () => { streamMock.mockRejectedValue( new StreamTransportError('unexpected_eof', 'The stream ended early.'), @@ -239,4 +257,26 @@ describe('useChatStream', () => { expect(receivedQuery).toHaveLength(1000) }) + + it('uses the bare query as the capped playlist name', async () => { + const createPlaylist = vi.fn(async () => ({ url: null })) + streamMock.mockImplementation(async (_request, _signal, onEvent) => { + onEvent(metadata('playlist')) + onEvent(track(1)) + onEvent({ type: 'done', track_count: 1, total_ms: 5 }) + }) + const chat = mountChat(createPlaylist) + const query = `Late-night instrumental focus ${'x'.repeat(100)}` + + await chat.send(query) + const turn = assistantTurns(chat)[0] + if (!turn) throw new Error('Assistant turn was not created.') + await chat.savePlaylist(turn.id) + + expect(createPlaylist).toHaveBeenCalledWith({ + schema_version: 1, + name: query.slice(0, 100), + track_uris: ['spotify:track:1'], + }) + }) })