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

@ -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',
},
},
})