feat(frontend): assemble accessible application shell
This commit is contained in:
parent
96c69507ec
commit
036ef3cc6f
27 changed files with 1852 additions and 13 deletions
285
frontend/src/components/DevPanel.vue
Normal file
285
frontend/src/components/DevPanel.vue
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue