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

@ -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>