feat(frontend): assemble accessible application shell

This commit is contained in:
Justin Visser 2026-08-10 15:04:17 +02:00
parent 96c69507ec
commit 036ef3cc6f
27 changed files with 1852 additions and 13 deletions

View file

@ -77,14 +77,14 @@ Wat ik deed:
- Domeinbasis: titel/artiest-matching (normalisatie, met tolerantie voor
Spotify's versie-suffixen zoals "- Remaster 2023"), compressie van het
Spotify-smaakprofiel naar prompttekst plus een set bekende track-ids,
Spotify taste profile naar prompttekst plus een set bekende track-ids,
prompts als data in een eigen module, en alle instelbare waarden
gesectioneerd in de config met per waarde het waarom.
- Twee LLM-calls achter een eigen interface: call 1 interpreteert de
vraag (stemming, activiteit, taal, bekendheid) en stelt 30-40 echte
vraag (mood, activity, language, familiarity) en stelt 30-40 echte
nummers voor als gestructureerde output; call 2 herordent uitsluitend
geverifieerde nummers en streamt per nummer een eerlijke onderbouwing.
Elke output wordt gevalideerd, met hooguit 1 herstelpoging.
geverifieerde nummers en streamt per nummer een eerlijke justification.
Elke output wordt gevalideerd, met hooguit 1 correctie-retry.
- Grounding: begrensde parallelle search fan-out met early stop, een deadline,
een naam-naar-id cache en twee aparte metrieken: niet gevonden versus
wel gevonden maar afgekeurd door de match-check. Een track-id dat niet in
@ -96,7 +96,7 @@ Wat ik deed:
Waarom:
- De LLM is hier de aanbeveler, maar mag alleen creatief zijn tussen twee
- De LLM is hier de recommender, maar mag alleen creatief zijn tussen twee
deterministische muren: alles wat hij ziet is echte data, alles wat de
gebruiker ziet is geverifieerd op Spotify. Een verzonnen nummer valt
stilletjes af en verschijnt nooit.
@ -165,7 +165,7 @@ Wat ik deed:
- Per unresolved kandidaat wordt titel, artiest en status gelogd, zodat
zichtbaar is WAT het model verzon in plaats van alleen hoeveel.
### Eerlijke gedeeltelijke resultaten
### Eerlijke partial results
Wat ik deed:
@ -201,3 +201,41 @@ Waarom:
- De frontend is de tweede consument van het bevroren contract: elke event
wordt tegen de TypeScript-union gevalideerd in plaats van los geparst;
wat niet valideert is een transport failure, geen gok.
### Frontend: componenten en app shell
Dit deel is grotendeels geautomatiseerde omzetting: het UI bouwpakket uit
Open Design heb ik door een cli agent laten converteren naar Vue single file components
tegen het bevroren contract. De transportlaag eronder (stream client,
validatie) komt uit de vorige stap. Review- en hardeningpassen op dit
resultaat volgen hierna als eigen stappen; wat hier staat is de ruwe
conversie.
Wat ik deed:
- Alle componenten uit het bouwpakket laten omzetten: chat view,
composer met suggestion chips, streamende track cards met album covers
en een justification per nummer, warning banners, error states, login-status,
mode banner en een dev panel.
- App shell met keyboard-navigatie, focus management en reduced-motion
support.
Waarom:
- Het bouwpakket was hierop ontworpen; de componenten volgen de tokens en
states daaruit, en elke regel gaat alsnog door review voordat die op
main landt.
- Styling is scoped CSS per component bovenop globale design tokens
(custom properties). Scoped omdat machine-geconverteerde componenten
om style leakage te voorkomen, en omdat het bouwpakket al gewone CSS
per component leverde: een utility
framework had een rewrite plus een extra dependency gekost. De
tokens houden het thema op 1 plek, dus het bekende scoped-CSS risico
(waarden die per component uiteenlopen) is afgedekt.
Wat ik heb laten vallen of uitgesteld:
- Een lijst met eerdere chats. Gesprekken leven bewust alleen client-side
en de server bewaart niets; een history-lijst kan later contract-schoon
via localStorage, zonder server-state. Potentiele extra als er tijd
over is, geen onderdeel van de kern.

View file

@ -4,6 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0b0c0b" />
<title>discovery-by-llm</title>
</head>
<body>

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 324 B

Before After
Before After

View file

@ -1,8 +1,7 @@
<script setup lang="ts"></script>
<script setup lang="ts">
import ChatView from './components/ChatView.vue'
</script>
<template>
<main>
<h1>discovery-by-llm</h1>
<p>Chat interface under construction.</p>
</main>
<ChatView />
</template>

View file

@ -0,0 +1,140 @@
<script setup lang="ts">
import type { AppMode, AuthState } from '../lib/models'
import AuthStatus from './AuthStatus.vue'
defineProps<{
auth: AuthState
mode: AppMode | null
healthFailed: boolean
}>()
defineEmits<{ logout: []; toggleDev: [] }>()
</script>
<template>
<header class="header">
<div class="inner">
<div class="brand">
<span class="wordmark">discovery<span class="accent">-by-</span>llm</span>
<span class="kicker">proof of concept</span>
</div>
<div class="controls">
<span class="mode" :class="{ failed: healthFailed }">
{{ mode ? `${mode} mode` : healthFailed ? 'mode unavailable' : 'checking mode' }}
</span>
<AuthStatus :auth="auth" @logout="$emit('logout')" />
<button class="button" type="button" @click="$emit('toggleDev')">
Dev panel <span class="key">Ctrl+D</span>
</button>
</div>
</div>
</header>
</template>
<style scoped>
.header {
z-index: 20;
flex: none;
padding: var(--s-5) var(--gutter);
background: var(--c-bg);
border-bottom: 1px solid var(--c-line);
}
.inner {
display: flex;
gap: var(--s-6);
align-items: center;
justify-content: space-between;
max-width: var(--measure);
margin: 0 auto;
}
.brand {
display: flex;
gap: var(--s-5);
align-items: baseline;
min-width: 0;
}
.wordmark {
font-family: var(--f-display);
font-size: var(--t-brand);
font-weight: 600;
letter-spacing: -0.02em;
}
.accent {
color: var(--c-accent);
}
.kicker,
.mode,
.button {
font-family: var(--f-mono);
font-size: var(--t-micro);
}
.kicker {
color: var(--c-text-faint);
font-size: var(--t-caps);
letter-spacing: var(--ls-caps);
text-transform: uppercase;
}
.controls {
display: flex;
flex: none;
gap: var(--s-4);
align-items: center;
white-space: nowrap;
}
.mode {
color: var(--c-accent-soft);
text-transform: uppercase;
}
.mode.failed {
color: var(--c-error);
}
.button {
min-height: 31px;
padding: 6px 11px;
color: var(--c-text-muted);
cursor: pointer;
background: transparent;
border: 1px solid var(--c-line);
border-radius: var(--r-md);
}
.button:hover {
color: var(--c-text);
border-color: var(--c-focus-border);
}
.key {
color: var(--c-text-ghost);
}
@media (max-width: 760px) {
.kicker,
.mode,
.key {
display: none;
}
}
@media (max-width: 560px) {
.button {
min-width: var(--tap-min);
min-height: var(--tap-min);
padding: 8px;
font-size: 0;
}
.button::after {
content: 'Dev';
font-size: var(--t-micro);
}
}
</style>

View file

@ -0,0 +1,132 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { AssistantTurn } from '../lib/models'
import EmptyResults from './EmptyResults.vue'
import PlaylistError from './PlaylistError.vue'
import PlaylistSaved from './PlaylistSaved.vue'
import RequestMetadata from './RequestMetadata.vue'
import ResultActions from './ResultActions.vue'
import ResultSet from './ResultSet.vue'
import StreamError from './StreamError.vue'
import StreamWarning from './StreamWarning.vue'
import ThinkingIndicator from './ThinkingIndicator.vue'
const props = defineProps<{ turn: AssistantTurn; canSave: boolean }>()
defineEmits<{ save: []; retry: [query: string] }>()
const thinkingLabel = computed(() => {
if (props.turn.requestId === null) return 'Reading the request'
if (props.turn.tracks.length === 0) {
return props.turn.candidateCount === null
? 'Checking candidates'
: `Checking ${props.turn.candidateCount} candidates`
}
return 'Streaming verified tracks'
})
const isEmptyResult = computed(
() =>
props.turn.status === 'done' &&
props.turn.completion?.track_count === 0 &&
props.turn.tracks.length === 0,
)
</script>
<template>
<div class="assistant">
<div class="avatar" aria-hidden="true">d</div>
<div class="column">
<p v-if="turn.intentSummary" class="intent">{{ turn.intentSummary }}</p>
<ResultSet v-if="turn.tracks.length" :tracks="turn.tracks" />
<ThinkingIndicator v-if="turn.status === 'streaming'" :label="thinkingLabel" />
<StreamWarning
v-for="(warning, index) in turn.warnings"
:key="`${warning.code}-${index}`"
:warning="warning"
/>
<StreamError
v-if="turn.error"
:code="turn.error.code"
:message="turn.error.message"
@retry="$emit('retry', turn.query)"
/>
<StreamError
v-else-if="turn.transportFailure"
:code="turn.transportFailure.kind"
:message="turn.transportFailure.message"
@retry="$emit('retry', turn.query)"
/>
<EmptyResults v-if="isEmptyResult" />
<RequestMetadata :turn="turn" />
<ResultActions
v-if="turn.status === 'done' && turn.tracks.length && turn.playlist.status !== 'saved'"
:state="turn.playlist"
:track-count="turn.tracks.length"
:can-save="canSave"
@save="$emit('save')"
/>
<PlaylistError
v-if="turn.playlist.status === 'error' && turn.playlist.message"
:message="turn.playlist.message"
/>
<PlaylistSaved
v-if="turn.playlist.status === 'saved' && turn.playlist.name && turn.playlist.url"
:name="turn.playlist.name"
:url="turn.playlist.url"
:track-count="turn.tracks.length"
/>
</div>
</div>
</template>
<style scoped>
.assistant {
display: flex;
gap: var(--s-5);
}
.avatar {
display: grid;
flex: none;
place-items: center;
width: 26px;
height: 26px;
margin-top: 1px;
color: var(--c-accent);
font-family: var(--f-display);
font-size: 13px;
font-weight: 600;
border: 1px solid var(--c-line);
border-radius: 50%;
}
.column {
display: flex;
flex: 1;
flex-direction: column;
gap: var(--s-7);
min-width: 0;
}
.intent {
margin: 0;
font-size: var(--t-lead);
text-wrap: pretty;
}
@media (max-width: 560px) {
.assistant {
gap: 0;
}
.avatar {
display: none;
}
}
</style>

View file

@ -0,0 +1,128 @@
<script setup lang="ts">
import type { AuthState } from '../lib/models'
defineProps<{ auth: AuthState }>()
defineEmits<{ logout: [] }>()
</script>
<template>
<span
v-if="auth.status === 'checking'"
class="checking"
aria-label="Checking Spotify connection"
/>
<a v-else-if="auth.status === 'anonymous'" class="button" href="/api/auth/login">
<span class="full-label">Connect Spotify</span><span class="short-label">Connect</span>
</a>
<span v-else-if="auth.status === 'failed'" class="failed" role="alert">
<span class="failure-message">{{ auth.message }}</span>
<a class="button" href="/api/auth/login">
<span class="full-label">Try Spotify again</span><span class="short-label">Retry</span>
</a>
</span>
<span v-else class="authenticated">
<span class="user">
<span class="dot" aria-hidden="true" />
<span class="connected">Connected as </span>{{ auth.user.display_name }}
</span>
<button
class="button"
type="button"
:disabled="auth.status === 'logging_out'"
@click="$emit('logout')"
>
{{ auth.status === 'logging_out' ? 'Logging out...' : 'Log out' }}
</button>
</span>
</template>
<style scoped>
.checking {
width: 116px;
height: 31px;
border: 1px solid var(--c-line);
border-radius: var(--r-pill);
opacity: 0.55;
}
.authenticated,
.failed {
display: inline-flex;
gap: var(--s-3);
align-items: center;
}
.user {
display: inline-flex;
gap: 7px;
align-items: center;
padding: 5px 11px;
color: var(--c-text-muted);
font-family: var(--f-mono);
font-size: var(--t-micro);
border: 1px solid var(--c-line);
border-radius: var(--r-pill);
}
.dot {
width: 6px;
height: 6px;
background: var(--c-accent);
border-radius: 50%;
}
.failure-message {
max-width: 24ch;
color: var(--c-error);
font-size: var(--t-micro);
white-space: normal;
}
.button {
display: inline-flex;
flex: none;
align-items: center;
min-height: 31px;
padding: 6px 11px;
color: var(--c-text-muted);
font-family: var(--f-mono);
font-size: var(--t-micro);
white-space: nowrap;
cursor: pointer;
background: transparent;
border: 1px solid var(--c-line);
border-radius: var(--r-md);
}
.short-label {
display: none;
}
.button:hover {
color: var(--c-text);
text-decoration: none;
border-color: var(--c-focus-border);
}
@media (max-width: 760px) {
.user,
.failure-message {
display: none;
}
}
@media (max-width: 560px) {
.checking,
.button {
min-height: var(--tap-min);
}
.full-label {
display: none;
}
.short-label {
display: inline;
}
}
</style>

View file

@ -0,0 +1,123 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
import { useApi } from '../composables/useApi'
import { useChatStream } from '../composables/useChatStream'
import { DISCOVERY_SUGGESTIONS } from '../lib/suggestions'
import AppHeader from './AppHeader.vue'
import DevPanel from './DevPanel.vue'
import MessageInput from './MessageInput.vue'
import MessageList from './MessageList.vue'
import ModeBanner from './ModeBanner.vue'
const draft = ref('')
const devOpen = ref(false)
const input = ref<InstanceType<typeof MessageInput> | null>(null)
const { auth, health, bootstrap, logout, createPlaylist } = useApi()
const { turns, eventLog, isStreaming, turnCount, latestAssistant, send, savePlaylist, reset } =
useChatStream(createPlaylist)
const mode = computed(() => (health.value.status === 'ready' ? health.value.mode : null))
const canSave = computed(() => auth.value.status === 'authenticated')
const turnCounter = computed(() => {
if (turnCount.value === 0) return 'no turns yet'
return `${turnCount.value} ${turnCount.value === 1 ? 'turn' : 'turns'} in this session`
})
async function focusInput(): Promise<void> {
await nextTick()
input.value?.focus()
}
function pickSuggestion(suggestion: string): void {
draft.value = suggestion
void focusInput()
}
function retryQuery(query: string): void {
draft.value = query
void focusInput()
}
function submit(query: string): void {
if (isStreaming.value) return
draft.value = ''
void send(query)
}
function onShortcut(event: KeyboardEvent): void {
if (event.ctrlKey && event.key.toLowerCase() === 'd') {
event.preventDefault()
devOpen.value = !devOpen.value
}
}
onMounted(() => {
void bootstrap()
window.addEventListener('keydown', onShortcut)
})
onUnmounted(() => window.removeEventListener('keydown', onShortcut))
</script>
<template>
<div class="chat-view">
<div class="shell" :inert="devOpen">
<AppHeader
:auth="auth"
:mode="mode"
:health-failed="health.status === 'failed'"
@logout="logout"
@toggle-dev="devOpen = true"
/>
<ModeBanner v-if="mode === 'demo'" />
<MessageList
:turns="turns"
:suggestions="DISCOVERY_SUGGESTIONS"
:can-save="canSave"
@pick="pickSuggestion"
@save="savePlaylist"
@retry="retryQuery"
/>
<MessageInput
ref="input"
v-model="draft"
:disabled="isStreaming"
:turn-counter="turnCounter"
@send="submit"
/>
</div>
<DevPanel
v-if="devOpen"
:latest="latestAssistant"
:log="eventLog"
@close="devOpen = false"
@reset="reset"
/>
</div>
</template>
<style scoped>
.chat-view,
.shell {
width: 100%;
height: 100dvh;
overflow: hidden;
}
.chat-view {
color: var(--c-text);
font-family: var(--f-body);
font-size: var(--t-body);
line-height: var(--lh-body);
background: var(--c-bg);
}
.shell {
display: flex;
flex-direction: column;
}
</style>

View file

@ -0,0 +1,285 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
import type { AssistantTurn, EventLogEntry } from '../lib/models'
const props = defineProps<{
latest: AssistantTurn | undefined
log: EventLogEntry[]
}>()
const emit = defineEmits<{ close: []; reset: [] }>()
const panel = ref<HTMLElement | null>(null)
const closeButton = ref<HTMLButtonElement | null>(null)
let returnFocus: HTMLElement | null = null
const rows = computed(() => [
{ label: 'request id', value: props.latest?.requestId ?? 'waiting' },
{
label: 'candidate count',
value: props.latest?.candidateCount?.toString() ?? 'waiting',
},
{
label: 'track count',
value:
props.latest?.completion?.track_count.toString() ??
props.latest?.tracks.length.toString() ??
'waiting',
},
{
label: 'total ms',
value: props.latest?.completion?.total_ms.toString() ?? 'waiting',
},
])
const entries = computed(() =>
props.log.length
? [...props.log].reverse()
: [{ timestamp: '--:--:--', type: 'idle', detail: 'waiting for a request' }],
)
function focusableElements(): HTMLElement[] {
if (!panel.value) return []
return Array.from(
panel.value.querySelectorAll<HTMLElement>(
'button:not(:disabled), a[href], input:not(:disabled), [tabindex]:not([tabindex="-1"])',
),
)
}
function onKeydown(event: KeyboardEvent): void {
if (event.key === 'Escape') {
event.preventDefault()
emit('close')
return
}
if (event.key !== 'Tab') return
const focusable = focusableElements()
if (!focusable.length) return
const first = focusable[0]
const last = focusable[focusable.length - 1]
if (event.shiftKey && document.activeElement === first) {
event.preventDefault()
last?.focus()
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault()
first?.focus()
}
}
onMounted(async () => {
returnFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null
await nextTick()
closeButton.value?.focus()
})
onUnmounted(() => returnFocus?.focus())
</script>
<template>
<div class="backdrop" @click.self="$emit('close')">
<aside
ref="panel"
class="panel"
role="dialog"
aria-modal="true"
aria-labelledby="dev-panel-title"
@keydown="onKeydown"
>
<div class="bar">
<h2 id="dev-panel-title" class="caps">Dev panel</h2>
<button
ref="closeButton"
class="close"
type="button"
aria-label="Close dev panel"
@click="$emit('close')"
>
<span aria-hidden="true">X</span>
</button>
</div>
<div class="body">
<section>
<h3 class="caps">Latest request</h3>
<p class="caption">Only counters supplied by the recommendation contract are shown.</p>
<div class="table">
<div v-for="row in rows" :key="row.label" class="row">
<span class="key">{{ row.label }}</span
><span class="value">{{ row.value }}</span>
</div>
</div>
</section>
<section>
<h3 class="caps section-heading">Conversation</h3>
<button class="reset" type="button" @click="$emit('reset')">Clear conversation</button>
</section>
<section>
<h3 class="caps section-heading">Event stream</h3>
<div class="log">
<div v-for="(entry, index) in entries" :key="index" class="entry">
<span class="time">{{ entry.timestamp }}</span>
<span class="type">{{ entry.type }}</span>
<span class="detail" :title="entry.detail">{{ entry.detail }}</span>
</div>
</div>
</section>
</div>
</aside>
</div>
</template>
<style scoped>
.backdrop {
position: fixed;
inset: 0;
z-index: 40;
background: rgba(0, 0, 0, 0.5);
}
.panel {
position: absolute;
inset: 0 0 0 auto;
display: flex;
flex-direction: column;
width: var(--dev-width);
max-width: 100vw;
overflow-y: auto;
background: var(--c-dev-bg);
border-left: 1px solid var(--c-line);
box-shadow: var(--shadow-drawer);
}
.bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--s-6) var(--s-7);
border-bottom: 1px solid var(--c-line);
}
.caps {
margin: 0;
color: var(--c-text-muted);
font-family: var(--f-mono);
font-size: var(--t-micro);
font-weight: 400;
letter-spacing: var(--ls-caps);
text-transform: uppercase;
}
.section-heading {
margin-bottom: var(--s-4);
}
.close {
min-width: var(--tap-min);
min-height: var(--tap-min);
color: var(--c-text-faint);
font-family: var(--f-mono);
font-size: var(--t-body);
line-height: 1;
cursor: pointer;
background: none;
border: 0;
}
.body {
display: flex;
flex-direction: column;
gap: var(--s-8);
padding: var(--s-7);
}
.caption {
margin: var(--s-1) 0 var(--s-5);
color: var(--c-text-dim);
font-size: 12.5px;
}
.table {
display: flex;
flex-direction: column;
gap: 1px;
overflow: hidden;
background: var(--c-line);
border: 1px solid var(--c-line);
border-radius: var(--r-md);
}
.row {
display: flex;
gap: var(--s-5);
justify-content: space-between;
padding: 9px var(--s-5);
font-family: var(--f-mono);
font-size: var(--t-meta);
background: var(--c-surface);
}
.key {
color: var(--c-text-dim);
}
.value {
max-width: 55%;
overflow: hidden;
color: var(--c-text);
text-overflow: ellipsis;
white-space: nowrap;
}
.reset {
width: 100%;
min-height: var(--tap-min);
padding: 11px var(--s-5);
color: var(--c-text-chip);
font-size: 13px;
text-align: left;
cursor: pointer;
background: var(--c-surface);
border: 1px solid var(--c-line);
border-radius: var(--r-md);
}
.reset:hover {
color: var(--c-text);
border-color: var(--c-accent);
}
.log {
display: flex;
flex-direction: column;
gap: 3px;
max-height: 260px;
overflow-y: auto;
font-family: var(--f-mono);
font-size: var(--t-micro);
}
.entry {
display: grid;
grid-template-columns: auto auto minmax(0, 1fr);
gap: var(--s-4);
}
.time {
color: var(--c-text-ghost);
}
.type {
color: var(--c-accent);
}
.detail {
min-width: 0;
overflow: hidden;
color: var(--c-text-dim);
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 560px) {
.panel {
width: 100vw;
}
}
</style>

View file

@ -0,0 +1,28 @@
<template>
<div class="empty-results">
<strong>No verified tracks matched this request.</strong>
<span>Try broadening the moment or removing one constraint.</span>
</div>
</template>
<style scoped>
.empty-results {
display: flex;
flex-direction: column;
gap: var(--s-1);
padding: var(--s-6);
color: var(--c-text-muted);
background: var(--c-surface);
border: 1px solid var(--c-line);
border-radius: var(--r-md);
}
.empty-results strong {
color: var(--c-text);
font-weight: 500;
}
.empty-results span {
font-size: var(--t-small);
}
</style>

View file

@ -0,0 +1,72 @@
<script setup lang="ts">
import SuggestionChips from './SuggestionChips.vue'
defineProps<{ suggestions: readonly string[] }>()
defineEmits<{ pick: [suggestion: string] }>()
</script>
<template>
<div class="empty">
<h1>What should be playing<span class="accent"> right now?</span></h1>
<p class="lede">
Describe the moment, not the genre. The assistant reads the request, proposes candidates,
verifies each one on Spotify and explains why it picked them.
</p>
<template v-if="suggestions.length">
<div class="label">Try one of these</div>
<SuggestionChips :suggestions="suggestions" @pick="$emit('pick', $event)" />
</template>
</div>
</template>
<style scoped>
.empty {
padding: var(--s-5) 0 var(--s-3);
}
h1 {
max-width: 16ch;
margin: 0 0 var(--s-5);
font-family: var(--f-display);
font-size: var(--t-hero);
font-weight: 600;
line-height: var(--lh-hero);
letter-spacing: var(--ls-hero);
}
.accent {
color: var(--c-accent);
}
.lede {
max-width: 52ch;
margin: 0 0 var(--s-9);
color: var(--c-text-muted);
text-wrap: pretty;
}
.label {
margin-bottom: var(--s-5);
color: var(--c-text-faint);
font-family: var(--f-mono);
font-size: var(--t-caps);
letter-spacing: var(--ls-caps);
text-transform: uppercase;
}
@media (max-width: 760px) {
h1 {
font-size: 38px;
}
}
@media (max-width: 560px) {
.empty {
padding-top: var(--s-1);
}
h1 {
font-size: 32px;
}
}
</style>

View file

@ -0,0 +1,139 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
const model = defineModel<string>({ required: true })
const props = defineProps<{ disabled: boolean; turnCounter: string }>()
const emit = defineEmits<{ send: [query: string] }>()
const input = ref<HTMLInputElement | null>(null)
const narrow = ref(false)
const placeholder = 'Describe the moment: "something calm while I am programming, but not boring"'
let mediaQuery: MediaQueryList | null = null
const sendDisabled = computed(() => props.disabled || model.value.trim().length === 0)
function updateWidth(event?: MediaQueryListEvent): void {
narrow.value = event?.matches ?? mediaQuery?.matches ?? false
}
function submit(event?: KeyboardEvent): void {
if (event?.isComposing || props.disabled) return
const query = model.value.trim()
if (query) emit('send', query)
}
function focus(): void {
input.value?.focus()
}
onMounted(() => {
mediaQuery = window.matchMedia('(max-width: 559px)')
updateWidth()
mediaQuery.addEventListener('change', updateWidth)
})
onUnmounted(() => mediaQuery?.removeEventListener('change', updateWidth))
defineExpose({ focus })
</script>
<template>
<div class="composer">
<div class="inner">
<div class="field">
<input
ref="input"
v-model="model"
aria-label="Describe what you want to listen to"
:placeholder="narrow ? 'Describe the moment...' : placeholder"
@keydown.enter.prevent="submit($event)"
/>
<button class="send" type="button" :disabled="sendDisabled" @click="submit()">
{{ disabled ? 'Working...' : 'Send' }}
</button>
</div>
<div class="hints">
<span class="hint">
Enter to send / refine with follow-ups like "more electronic and drop number 3"
</span>
<span>{{ turnCounter }}</span>
</div>
</div>
</div>
</template>
<style scoped>
.composer {
flex: none;
padding: var(--s-5) var(--gutter) calc(20px + env(safe-area-inset-bottom));
background: var(--c-bg);
border-top: 1px solid var(--c-line);
}
.inner {
max-width: var(--measure);
margin: 0 auto;
}
.field {
display: flex;
gap: var(--s-4);
align-items: flex-end;
padding: var(--s-3) var(--s-3) var(--s-3) var(--s-6);
background: var(--c-surface);
border: 1px solid var(--c-line);
border-radius: var(--r-lg);
transition: border-color var(--dur-fast) ease;
}
.field:focus-within {
border-color: var(--c-focus-border);
}
input {
flex: 1;
min-width: 0;
padding: var(--s-3) 0;
color: var(--c-text);
background: transparent;
border: 0;
outline: none;
}
input::placeholder {
color: var(--c-text-faint);
}
.send {
flex: none;
min-height: var(--tap-min);
padding: var(--s-4) var(--s-7);
color: var(--c-on-accent);
font-size: 14px;
font-weight: 500;
cursor: pointer;
background: var(--c-accent);
border: 0;
border-radius: var(--r-md);
transition: background var(--dur-fast) ease;
}
.send:hover:not(:disabled) {
background: var(--c-accent-hover);
}
.hints {
display: flex;
gap: var(--s-6);
justify-content: space-between;
margin-top: 9px;
color: var(--c-text-ghost);
font-family: var(--f-mono);
font-size: 10.5px;
}
@media (max-width: 760px) {
.hint {
display: none;
}
}
</style>

View file

@ -0,0 +1,130 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import type { AssistantTurn, ChatTurn } from '../lib/models'
import AssistantMessage from './AssistantMessage.vue'
import EmptyState from './EmptyState.vue'
import UserMessage from './UserMessage.vue'
const props = defineProps<{
turns: ChatTurn[]
suggestions: readonly string[]
canSave: boolean
}>()
defineEmits<{
pick: [suggestion: string]
save: [turnId: string]
retry: [query: string]
}>()
const list = ref<HTMLElement | null>(null)
const pinned = ref(true)
const fingerprint = computed(() =>
props.turns
.map((turn) =>
turn.role === 'user'
? turn.id
: `${turn.id}:${turn.tracks.length}:${turn.warnings.length}:${turn.status}`,
)
.join('|'),
)
const streaming = computed(() =>
props.turns.some((turn) => turn.role === 'assistant' && turn.status === 'streaming'),
)
const latestAssistant = computed(() =>
[...props.turns].reverse().find((turn): turn is AssistantTurn => turn.role === 'assistant'),
)
const announcement = computed(() => {
const turn = latestAssistant.value
if (!turn) return ''
if (turn.status === 'error') return 'The recommendation request failed.'
if (turn.status === 'done') {
return `${turn.completion?.track_count ?? turn.tracks.length} tracks ready.`
}
if (turn.requestId === null) return 'Reading the request.'
if (turn.tracks.length === 0) return 'Checking recommendation candidates.'
return `${turn.tracks.length} verified tracks received.`
})
function onScroll(): void {
const element = list.value
if (!element) return
pinned.value = element.scrollHeight - element.scrollTop - element.clientHeight < 48
}
async function scrollToLatest(): Promise<void> {
if (!pinned.value) return
await nextTick()
requestAnimationFrame(() => {
if (list.value) list.value.scrollTop = list.value.scrollHeight
})
}
watch(fingerprint, scrollToLatest, { flush: 'post' })
watch(streaming, (value) => {
if (value) pinned.value = true
})
</script>
<template>
<main ref="list" class="list" aria-label="Conversation" @scroll="onScroll">
<p class="visually-hidden" aria-live="polite" aria-atomic="true">
{{ announcement }}
</p>
<div class="column">
<EmptyState
v-if="turns.length === 0"
:suggestions="suggestions"
@pick="$emit('pick', $event)"
/>
<div v-for="turn in turns" :key="turn.id" class="turn">
<UserMessage v-if="turn.role === 'user'" :text="turn.text" />
<AssistantMessage
v-else
:turn="turn"
:can-save="canSave"
@save="$emit('save', turn.id)"
@retry="$emit('retry', $event)"
/>
</div>
</div>
</main>
</template>
<style scoped>
.list {
flex: 1 1 auto;
min-height: 0;
padding: 32px var(--gutter) 28px;
overflow-y: auto;
}
.column {
display: flex;
flex-direction: column;
gap: var(--s-9);
max-width: var(--measure);
margin: 0 auto;
}
.turn {
animation: message-in 0.3s ease both;
}
@keyframes message-in {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: none;
}
}
@media (max-width: 560px) {
.list {
padding-top: 24px;
}
}
</style>

View file

@ -0,0 +1,36 @@
<template>
<div class="banner" role="status">
<div class="inner">
<span class="dot" aria-hidden="true" />
<span>Demo mode: replaying recorded recommendation sessions</span>
</div>
</div>
</template>
<style scoped>
.banner {
flex: none;
padding: 9px var(--gutter);
background: var(--c-banner-bg);
border-bottom: 1px solid var(--c-banner-line);
}
.inner {
display: flex;
gap: var(--s-5);
align-items: center;
max-width: var(--measure);
margin: 0 auto;
color: var(--c-accent-soft);
font-family: var(--f-mono);
font-size: var(--t-meta);
}
.dot {
flex: none;
width: 6px;
height: 6px;
background: var(--c-accent);
border-radius: 50%;
}
</style>

View file

@ -0,0 +1,17 @@
<script setup lang="ts">
defineProps<{ message: string }>()
</script>
<template>
<p class="error" role="alert">{{ message }}</p>
</template>
<style scoped>
.error {
margin: 0;
padding-left: var(--s-5);
color: var(--c-error);
font-size: var(--t-small);
border-left: 2px solid var(--c-error);
}
</style>

View file

@ -0,0 +1,64 @@
<script setup lang="ts">
defineProps<{ name: string; url: string; trackCount: number }>()
</script>
<template>
<div class="saved" role="status">
<div>
<div class="line">
Saved <span class="name">{{ name }}</span>
</div>
<div class="meta">{{ trackCount }} tracks / private / created just now</div>
</div>
<a class="link" :href="url" target="_blank" rel="noreferrer">
Open in Spotify <span aria-hidden="true">&nearr;</span>
</a>
</div>
</template>
<style scoped>
.saved {
display: flex;
flex-wrap: wrap;
gap: var(--s-6);
align-items: center;
justify-content: space-between;
padding: 13px var(--s-6);
background: var(--c-saved-bg);
border: 1px solid var(--c-saved-line);
border-radius: var(--r-md);
animation: save-pop 0.26s ease both;
}
.line {
font-size: 14.5px;
}
.name,
.link {
color: var(--c-saved);
}
.meta,
.link {
font-family: var(--f-mono);
font-size: var(--t-micro);
}
.meta {
margin-top: 2px;
color: var(--c-text-faint);
}
@keyframes save-pop {
from {
opacity: 0;
transform: scale(0.97);
}
to {
opacity: 1;
transform: none;
}
}
</style>

View file

@ -0,0 +1,30 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { AssistantTurn } from '../lib/models'
const props = defineProps<{ turn: AssistantTurn }>()
const parts = computed(() => {
const values: string[] = []
if (props.turn.candidateCount !== null) {
values.push(`${props.turn.candidateCount} candidates considered`)
}
if (props.turn.completion) {
values.push(`${props.turn.completion.track_count} verified`)
values.push(`${props.turn.completion.total_ms} ms`)
}
return values
})
</script>
<template>
<p v-if="parts.length" class="metadata">{{ parts.join(' / ') }}</p>
</template>
<style scoped>
.metadata {
margin: 0;
color: var(--c-text-ghost);
font-family: var(--f-mono);
font-size: var(--t-micro);
}
</style>

View file

@ -0,0 +1,74 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { PlaylistState } from '../lib/models'
const props = defineProps<{
state: PlaylistState
trackCount: number
canSave: boolean
}>()
defineEmits<{ save: [] }>()
const buttonLabel = computed(() => {
if (props.state.status === 'saving') return 'Saving...'
if (props.state.status === 'error') return 'Try saving again'
return 'Save as playlist'
})
</script>
<template>
<div class="actions">
<button
v-if="canSave"
class="save"
type="button"
:disabled="state.status === 'saving'"
@click="$emit('save')"
>
{{ buttonLabel }}
</button>
<a v-else class="connect" href="/api/auth/login">Connect Spotify to save</a>
<span class="summary">{{ trackCount }} verified tracks ready for a private playlist</span>
</div>
</template>
<style scoped>
.actions {
display: flex;
flex-wrap: wrap;
gap: var(--s-5);
align-items: center;
}
.save,
.connect {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: var(--tap-min);
padding: 11px var(--s-6);
color: var(--c-on-accent);
font-size: var(--t-small);
font-weight: 500;
text-decoration: none;
cursor: pointer;
background: var(--c-accent);
border: 1px solid var(--c-accent);
border-radius: var(--r-md);
transition: all var(--dur-fast) ease;
}
.save:hover,
.connect:hover {
color: var(--c-on-accent);
text-decoration: none;
background: var(--c-accent-hover);
border-color: var(--c-accent-hover);
}
.summary {
color: var(--c-text-faint);
font-family: var(--f-mono);
font-size: var(--t-micro);
}
</style>

View file

@ -0,0 +1,20 @@
<script setup lang="ts">
import type { TrackEvent } from '../lib/types'
import TrackCard from './TrackCard.vue'
defineProps<{ tracks: TrackEvent[] }>()
</script>
<template>
<div class="set" aria-label="Recommended tracks">
<TrackCard v-for="event in tracks" :key="event.track.id" :event="event" />
</div>
</template>
<style scoped>
.set {
display: flex;
flex-direction: column;
gap: var(--s-2);
}
</style>

View file

@ -0,0 +1,37 @@
<script setup lang="ts">
defineProps<{ code: string; message: string }>()
defineEmits<{ retry: [] }>()
</script>
<template>
<div class="error" role="alert" :data-error-code="code">
<p>{{ message }}</p>
<button type="button" @click="$emit('retry')">Edit and retry</button>
</div>
</template>
<style scoped>
.error {
padding-left: var(--s-5);
color: var(--c-error);
border-left: 2px solid var(--c-error);
}
.error p {
margin: 0;
text-wrap: pretty;
}
.error button {
min-height: var(--tap-min);
margin-top: var(--s-3);
padding: 8px var(--s-4);
color: var(--c-error);
font-family: var(--f-mono);
font-size: var(--t-micro);
background: transparent;
border: 1px solid currentcolor;
border-radius: var(--r-md);
cursor: pointer;
}
</style>

View file

@ -0,0 +1,20 @@
<script setup lang="ts">
import type { WarningEvent } from '../lib/types'
defineProps<{ warning: WarningEvent }>()
</script>
<template>
<p class="warning" :data-warning-code="warning.code">{{ warning.message }}</p>
</template>
<style scoped>
.warning {
margin: 0;
padding-left: var(--s-5);
color: var(--c-accent-soft);
font-size: var(--t-small);
text-wrap: pretty;
border-left: 2px solid var(--c-accent-line);
}
</style>

View file

@ -0,0 +1,59 @@
<script setup lang="ts">
defineProps<{ suggestions: readonly string[] }>()
defineEmits<{ pick: [suggestion: string] }>()
</script>
<template>
<div class="chips">
<button
v-for="suggestion in suggestions"
:key="suggestion"
class="chip"
type="button"
@click="$emit('pick', suggestion)"
>
{{ suggestion }}
</button>
</div>
</template>
<style scoped>
.chips {
display: flex;
flex-wrap: wrap;
gap: var(--s-3);
}
.chip {
flex: none;
padding: var(--s-3) 15px;
color: var(--c-text-chip);
font-size: var(--t-small);
white-space: nowrap;
cursor: pointer;
background: var(--c-surface-raised);
border: 1px solid var(--c-line);
border-radius: var(--r-pill);
transition: all var(--dur-fast) ease;
}
.chip:hover {
color: var(--c-text);
border-color: var(--c-accent);
transform: translateY(-1px);
}
@media (max-width: 560px) {
.chips {
flex-wrap: nowrap;
padding: 0 var(--gutter);
margin: 0 calc(-1 * var(--gutter));
overflow-x: auto;
scrollbar-width: none;
}
.chips::-webkit-scrollbar {
display: none;
}
}
</style>

View file

@ -0,0 +1,54 @@
<script setup lang="ts">
defineProps<{ label: string }>()
</script>
<template>
<div class="thinking" aria-hidden="true">
<span class="dot" /><span class="dot" /><span class="dot" />
<span class="label">{{ label }}</span>
</div>
</template>
<style scoped>
.thinking {
display: flex;
align-items: center;
gap: var(--s-3);
color: var(--c-text-faint);
font-family: var(--f-mono);
font-size: var(--t-micro);
letter-spacing: 0.1em;
text-transform: uppercase;
}
.dot {
width: 5px;
height: 5px;
background: var(--c-accent);
border-radius: 50%;
animation: pulse-dot 1.1s infinite;
}
.dot:nth-child(2) {
animation-delay: 0.18s;
}
.dot:nth-child(3) {
animation-delay: 0.36s;
}
.label {
margin-left: var(--s-1);
}
@keyframes pulse-dot {
0%,
100% {
opacity: 0.25;
}
50% {
opacity: 1;
}
}
</style>

View file

@ -0,0 +1,169 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { TrackEvent } from '../lib/types'
const props = defineProps<{ event: TrackEvent }>()
const artworkFailed = ref(false)
const artists = computed(() => props.event.track.artists.join(', '))
</script>
<template>
<article class="card">
<div class="rank">{{ event.rank }}</div>
<div class="art">
<img
v-if="event.track.album_art_url && !artworkFailed"
:src="event.track.album_art_url"
:alt="`${event.track.album_name} album artwork`"
@error="artworkFailed = true"
/>
</div>
<div class="track-meta">
<div class="head">
<span class="title">{{ event.track.title }}</span>
<span class="artist">{{ artists }}</span>
</div>
<div class="album">{{ event.track.album_name }}</div>
<div class="why">{{ event.justification }}</div>
</div>
<a
v-if="event.track.external_url"
class="link"
:href="event.track.external_url"
target="_blank"
rel="noreferrer"
>
Open in Spotify
<span aria-hidden="true">&nearr;</span>
</a>
</article>
</template>
<style scoped>
.card {
display: grid;
grid-template-columns: 26px 52px 1fr auto;
row-gap: var(--s-4);
column-gap: var(--s-6);
align-items: center;
padding: 11px var(--s-5);
background: var(--c-surface);
border: 1px solid var(--c-card-border);
border-radius: var(--r-md);
transition:
border-color var(--dur-fast) ease,
background var(--dur-fast) ease;
animation: card-in var(--dur-card) var(--ease) both;
}
.card:hover {
background: var(--c-surface-hover);
border-color: var(--c-line-strong);
}
.rank {
color: var(--c-text-faint);
font-family: var(--f-mono);
font-size: 12px;
text-align: right;
}
.art {
width: 52px;
height: 52px;
overflow: hidden;
background: repeating-linear-gradient(135deg, var(--c-art-a) 0 6px, var(--c-art-b) 6px 12px);
border: 1px solid var(--c-line);
border-radius: var(--r-sm);
}
.art img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.track-meta {
min-width: 0;
}
.head {
display: flex;
flex-wrap: wrap;
gap: 0 9px;
align-items: baseline;
}
.title {
font-size: var(--t-title);
font-weight: 500;
}
.artist,
.album,
.why {
color: var(--c-text-muted);
font-size: 13px;
}
.album {
margin-top: 1px;
color: var(--c-text-faint);
font-family: var(--f-mono);
font-size: var(--t-micro);
}
.why {
margin-top: 3px;
color: var(--c-text-dim);
font-size: var(--t-small);
text-wrap: pretty;
}
.link {
display: inline-flex;
gap: var(--s-1);
align-items: center;
justify-content: center;
justify-self: end;
padding: 6px var(--s-4);
color: var(--c-text-muted);
font-family: var(--f-mono);
font-size: var(--t-micro);
text-decoration: none;
white-space: nowrap;
border: 1px solid var(--c-line);
border-radius: var(--r-md);
}
.link:hover {
color: var(--c-accent);
text-decoration: none;
border-color: var(--c-accent);
}
@keyframes card-in {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: none;
}
}
@media (max-width: 560px) {
.card {
grid-template-columns: 26px 52px 1fr;
}
.link {
grid-column: 1 / -1;
justify-self: stretch;
min-height: var(--tap-min);
}
}
</style>

View file

@ -0,0 +1,26 @@
<script setup lang="ts">
defineProps<{ text: string }>()
</script>
<template>
<div class="row">
<div class="bubble">{{ text }}</div>
</div>
</template>
<style scoped>
.row {
display: flex;
justify-content: flex-end;
}
.bubble {
max-width: 78%;
padding: 11px var(--s-6);
color: var(--c-text);
overflow-wrap: anywhere;
background: var(--c-surface-user);
border: 1px solid var(--c-line-user);
border-radius: var(--r-md) var(--r-md) var(--r-md) 12px;
}
</style>

View file

@ -0,0 +1,9 @@
export const DISCOVERY_SUGGESTIONS = [
'Focus while coding',
'Energy for the gym',
'Dutch-language and relaxed',
'90s nostalgia',
'Surprise me with something new',
'Rainy Sunday',
'Background for dinner',
] as const

View file

@ -1,7 +1,12 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
server: {
proxy: {
'/api': 'http://127.0.0.1:8888',
'/callback': 'http://127.0.0.1:8888',
},
},
})