feat: restructure AppSidebar into two-domain nav with section headers and flywheel signal badges

This commit is contained in:
pyr0ball 2026-05-02 13:52:45 -07:00
parent 5bdb095235
commit 6ef6f06023
2 changed files with 385 additions and 36 deletions

View file

@ -0,0 +1,124 @@
import { mount, flushPromises } from '@vue/test-utils'
import { createRouter, createWebHashHistory } from 'vue-router'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import AppSidebar from './AppSidebar.vue'
// Minimal router so RouterLink renders without warnings
const router = createRouter({
history: createWebHashHistory(),
routes: [
{ path: '/', component: { template: '<div />' } },
{ path: '/fleet', component: { template: '<div />' } },
{ path: '/data/label', component: { template: '<div />' } },
{ path: '/data/fetch', component: { template: '<div />' } },
{ path: '/data/corrections', component: { template: '<div />' } },
{ path: '/data/imitate', component: { template: '<div />' } },
{ path: '/eval/benchmark', component: { template: '<div />' } },
{ path: '/eval/compare', component: { template: '<div />' } },
{ path: '/train/jobs', component: { template: '<div />' } },
{ path: '/train/results', component: { template: '<div />' } },
{ path: '/settings', component: { template: '<div />' } },
],
})
function makeFetch(signals: Record<string, boolean> = {}) {
return vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
labeled_since_last_eval: 0,
last_eval_timestamp: null,
last_eval_best_score: null,
active_jobs: [],
corrections_export_ready: 0,
signals,
}),
text: async () => '',
})
}
beforeEach(() => {
localStorage.clear()
vi.stubGlobal('fetch', makeFetch())
})
describe('AppSidebar structure', () => {
it('renders section headers for Data, Eval, Train', async () => {
const w = mount(AppSidebar, { global: { plugins: [router] } })
await flushPromises()
const text = w.text()
expect(text).toContain('Data')
expect(text).toContain('Eval')
expect(text).toContain('Train')
})
it('renders all sub-links', async () => {
const w = mount(AppSidebar, { global: { plugins: [router] } })
await flushPromises()
const anchors = w.findAll('a')
const hrefs = anchors.map(a => a.attributes('href') ?? '')
expect(hrefs.some(h => h.includes('/data/label'))).toBe(true)
expect(hrefs.some(h => h.includes('/data/fetch'))).toBe(true)
expect(hrefs.some(h => h.includes('/data/corrections'))).toBe(true)
expect(hrefs.some(h => h.includes('/data/imitate'))).toBe(true)
expect(hrefs.some(h => h.includes('/eval/benchmark'))).toBe(true)
expect(hrefs.some(h => h.includes('/eval/compare'))).toBe(true)
expect(hrefs.some(h => h.includes('/train/jobs'))).toBe(true)
expect(hrefs.some(h => h.includes('/train/results'))).toBe(true)
expect(hrefs.some(h => h.includes('/fleet'))).toBe(true)
expect(hrefs.some(h => h.includes('/settings'))).toBe(true)
})
it('does NOT render the old /benchmark or /models links', async () => {
const w = mount(AppSidebar, { global: { plugins: [router] } })
await flushPromises()
const anchors = w.findAll('a')
const hrefs = anchors.map(a => a.attributes('href') ?? '')
// Old paths must not appear as direct links (they're only redirects)
expect(hrefs.every(h => !h.endsWith('/#/benchmark'))).toBe(true)
expect(hrefs.every(h => !h.endsWith('/#/models'))).toBe(true)
expect(hrefs.every(h => !h.endsWith('/#/stats'))).toBe(true)
})
it('shows no signal badges when all signals are false', async () => {
vi.stubGlobal('fetch', makeFetch({ data_to_eval: false, eval_to_train: false, train_to_fleet: false }))
const w = mount(AppSidebar, { global: { plugins: [router] } })
await flushPromises()
expect(w.findAll('.signal-badge').length).toBe(0)
})
it('shows signal badge on Data section when data_to_eval is true', async () => {
vi.stubGlobal('fetch', makeFetch({ data_to_eval: true, eval_to_train: false, train_to_fleet: false }))
const w = mount(AppSidebar, { global: { plugins: [router] } })
await flushPromises()
const badges = w.findAll('.signal-badge')
expect(badges.length).toBe(1)
// It should be inside the Data section header
const dataHeader = w.find('[data-section="data"]')
expect(dataHeader.find('.signal-badge').exists()).toBe(true)
})
it('shows signal badge on Eval section when eval_to_train is true', async () => {
vi.stubGlobal('fetch', makeFetch({ data_to_eval: false, eval_to_train: true, train_to_fleet: false }))
const w = mount(AppSidebar, { global: { plugins: [router] } })
await flushPromises()
const evalHeader = w.find('[data-section="eval"]')
expect(evalHeader.find('.signal-badge').exists()).toBe(true)
})
it('shows signal badge on Train section when train_to_fleet is true', async () => {
vi.stubGlobal('fetch', makeFetch({ data_to_eval: false, eval_to_train: false, train_to_fleet: true }))
const w = mount(AppSidebar, { global: { plugins: [router] } })
await flushPromises()
const trainHeader = w.find('[data-section="train"]')
expect(trainHeader.find('.signal-badge').exists()).toBe(true)
})
it('stow toggle still works', async () => {
const w = mount(AppSidebar, { global: { plugins: [router] } })
await flushPromises()
const nav = w.find('nav')
expect(nav.classes()).not.toContain('stowed')
await w.find('.stow-btn').trigger('click')
expect(nav.classes()).toContain('stowed')
})
})

View file

@ -28,12 +28,59 @@
</button>
</div>
<!-- Nav items -->
<!-- Nav -->
<ul class="nav-list" role="list">
<li v-for="item in navItems" :key="item.path">
<!-- Top-level links -->
<li>
<RouterLink
to="/"
class="nav-item"
:title="stowed ? 'Dashboard' : ''"
@click="isMobile && stow()"
>
<span class="nav-icon" aria-hidden="true">📊</span>
<span v-if="!stowed" class="nav-label">Dashboard</span>
</RouterLink>
</li>
<li>
<RouterLink
to="/fleet"
class="nav-item"
:title="stowed ? 'Fleet' : ''"
@click="isMobile && stow()"
>
<span class="nav-icon" aria-hidden="true"></span>
<span v-if="!stowed" class="nav-label">Fleet</span>
</RouterLink>
</li>
<!-- Data section -->
<li>
<div class="section-header" data-section="data" aria-hidden="true">
<template v-if="!stowed">
<span class="section-label"> Data</span>
<span
v-if="signals.data_to_eval"
class="signal-badge"
title="Enough new labels to run eval"
aria-label="Eval recommended"
/>
</template>
<template v-else>
<span class="section-icon"></span>
<span
v-if="signals.data_to_eval"
class="signal-badge signal-badge-stowed"
title="Eval recommended"
aria-label="Eval recommended"
/>
</template>
</div>
</li>
<li v-for="item in dataItems" :key="item.path">
<RouterLink
:to="item.path"
class="nav-item"
class="nav-item nav-subitem"
:title="stowed ? item.label : ''"
@click="isMobile && stow()"
>
@ -41,10 +88,94 @@
<span v-if="!stowed" class="nav-label">{{ item.label }}</span>
</RouterLink>
</li>
<!-- Eval section -->
<li>
<div class="section-header" data-section="eval" aria-hidden="true">
<template v-if="!stowed">
<span class="section-label"> Eval</span>
<span
v-if="signals.eval_to_train"
class="signal-badge"
title="Strong eval result — consider finetuning"
aria-label="Finetune recommended"
/>
</template>
<template v-else>
<span class="section-icon"></span>
<span
v-if="signals.eval_to_train"
class="signal-badge signal-badge-stowed"
title="Finetune recommended"
aria-label="Finetune recommended"
/>
</template>
</div>
</li>
<li v-for="item in evalItems" :key="item.path">
<RouterLink
:to="item.path"
class="nav-item nav-subitem"
:title="stowed ? item.label : ''"
@click="isMobile && stow()"
>
<span class="nav-icon" aria-hidden="true">{{ item.icon }}</span>
<span v-if="!stowed" class="nav-label">{{ item.label }}</span>
</RouterLink>
</li>
<!-- Train section -->
<li>
<div class="section-header" data-section="train" aria-hidden="true">
<template v-if="!stowed">
<span class="section-label"> Train</span>
<span
v-if="signals.train_to_fleet"
class="signal-badge"
title="Trained model ready for fleet registration"
aria-label="Fleet registration recommended"
/>
</template>
<template v-else>
<span class="section-icon"></span>
<span
v-if="signals.train_to_fleet"
class="signal-badge signal-badge-stowed"
title="Fleet registration recommended"
aria-label="Fleet registration recommended"
/>
</template>
</div>
</li>
<li v-for="item in trainItems" :key="item.path">
<RouterLink
:to="item.path"
class="nav-item nav-subitem"
:title="stowed ? item.label : ''"
@click="isMobile && stow()"
>
<span class="nav-icon" aria-hidden="true">{{ item.icon }}</span>
<span v-if="!stowed" class="nav-label">{{ item.label }}</span>
</RouterLink>
</li>
<!-- Divider + Settings -->
<li class="nav-divider" aria-hidden="true" />
<li>
<RouterLink
to="/settings"
class="nav-item"
:title="stowed ? 'Settings' : ''"
@click="isMobile && stow()"
>
<span class="nav-icon" aria-hidden="true"></span>
<span v-if="!stowed" class="nav-label">Settings</span>
</RouterLink>
</li>
</ul>
</nav>
<!-- Mobile hamburger button rendered outside the sidebar so it's visible when stowed -->
<!-- Mobile hamburger button visible when sidebar is stowed on mobile -->
<button
v-if="isMobile && stowed"
class="mobile-hamburger"
@ -61,25 +192,66 @@ import { RouterLink } from 'vue-router'
const LS_KEY = 'cf-avocet-nav-stowed'
const navItems = [
{ path: '/', icon: '🃏', label: 'Label' },
{ path: '/fetch', icon: '📥', label: 'Fetch' },
{ path: '/stats', icon: '📊', label: 'Stats' },
{ path: '/benchmark', icon: '🏁', label: 'Benchmark' },
{ path: '/models', icon: '🤗', label: 'Models' },
{ path: '/imitate', icon: '🪞', label: 'Imitate' },
{ path: '/corrections', icon: '✍️', label: 'Corrections' },
{ path: '/settings', icon: '⚙️', label: 'Settings' },
interface NavItem {
path: string
icon: string
label: string
}
interface DashboardSignals {
data_to_eval: boolean
eval_to_train: boolean
train_to_fleet: boolean
}
const dataItems: NavItem[] = [
{ path: '/data/label', icon: '🏷', label: 'Label' },
{ path: '/data/fetch', icon: '📬', label: 'Fetch' },
{ path: '/data/corrections', icon: '✏️', label: 'Corrections' },
{ path: '/data/imitate', icon: '🪞', label: 'Imitate' },
]
const stowed = ref(localStorage.getItem(LS_KEY) === 'true')
const winWidth = ref(window.innerWidth)
const isMobile = computed(() => winWidth.value < 640)
const evalItems: NavItem[] = [
{ path: '/eval/benchmark', icon: '📊', label: 'Benchmark' },
{ path: '/eval/compare', icon: '🔍', label: 'Compare' },
]
const trainItems: NavItem[] = [
{ path: '/train/jobs', icon: '🧠', label: 'Jobs' },
{ path: '/train/results', icon: '📈', label: 'Results' },
]
const stowed = ref(localStorage.getItem(LS_KEY) === 'true')
const winWidth = ref(window.innerWidth)
const isMobile = computed(() => winWidth.value < 640)
const signals = ref<DashboardSignals>({
data_to_eval: false,
eval_to_train: false,
train_to_fleet: false,
})
async function loadSignals() {
try {
const res = await fetch('/api/dashboard')
if (res.ok) {
const data = await res.json() as { signals?: DashboardSignals }
if (data.signals) {
signals.value = {
data_to_eval: data.signals.data_to_eval ?? false,
eval_to_train: data.signals.eval_to_train ?? false,
train_to_fleet: data.signals.train_to_fleet ?? false,
}
}
}
} catch {
// Non-fatal: badges simply stay hidden if API is unreachable
}
}
function toggle() {
stowed.value = !stowed.value
localStorage.setItem(LS_KEY, String(stowed.value))
// Update CSS variable on :root so .app-main margin-left syncs
document.documentElement.style.setProperty('--sidebar-width', stowed.value ? '56px' : '200px')
}
@ -93,13 +265,12 @@ function onResize() { winWidth.value = window.innerWidth }
onMounted(() => {
window.addEventListener('resize', onResize)
// Apply persisted sidebar width to :root on mount
document.documentElement.style.setProperty('--sidebar-width', stowed.value ? '56px' : '200px')
// On mobile, default to stowed
if (isMobile.value && !localStorage.getItem(LS_KEY)) {
stowed.value = true
document.documentElement.style.setProperty('--sidebar-width', '56px')
}
loadSignals()
})
onUnmounted(() => window.removeEventListener('resize', onResize))
@ -121,18 +292,15 @@ onUnmounted(() => window.removeEventListener('resize', onResize))
overflow: hidden;
}
.sidebar.stowed {
width: 56px;
}
.sidebar.stowed { width: 56px; }
/* Mobile: slide in/out from left */
.sidebar.mobile {
box-shadow: 2px 0 16px rgba(0, 0, 0, 0.15);
}
.sidebar.mobile.stowed {
transform: translateX(-100%);
width: 200px; /* keep width so slide-in looks right */
width: 200px;
transition: transform 250ms ease, width 250ms ease;
}
@ -165,10 +333,7 @@ onUnmounted(() => window.removeEventListener('resize', onResize))
white-space: nowrap;
}
.logo-icon {
font-size: 1.25rem;
flex-shrink: 0;
}
.logo-icon { font-size: 1.25rem; flex-shrink: 0; }
.logo-name {
font-family: var(--font-display, var(--font-body, sans-serif));
@ -193,16 +358,76 @@ onUnmounted(() => window.removeEventListener('resize', onResize))
transition: background 0.15s;
}
.stow-btn:hover {
background: var(--color-border, #d0d7e8);
}
.stow-btn:hover { background: var(--color-border, #d0d7e8); }
.nav-list {
list-style: none;
padding: 0.5rem 0;
flex: 1;
overflow-y: auto;
overflow-x: hidden;
}
/* ── Section headers ── */
.section-header {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.55rem 0.75rem 0.25rem;
margin-top: 0.5rem;
pointer-events: none;
user-select: none;
}
.section-label {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--color-text-muted, #4a5c7a);
white-space: nowrap;
flex: 1;
}
.section-icon {
font-size: 0.75rem;
color: var(--color-text-muted, #4a5c7a);
width: 24px;
text-align: center;
flex-shrink: 0;
}
/* ── Signal badges ── */
.signal-badge {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--color-warning, #d4891a);
flex-shrink: 0;
display: inline-block;
}
.signal-badge-stowed {
position: absolute;
top: 4px;
right: 4px;
}
/* Make the stowed section header container position:relative for the badge */
.sidebar.stowed .section-header {
position: relative;
justify-content: center;
padding: 0.55rem 0 0.25rem;
}
/* ── Nav divider ── */
.nav-divider {
height: 1px;
background: var(--color-border, #d0d7e8);
margin: 0.5rem 0.75rem;
}
/* ── Nav items ── */
.nav-item {
display: flex;
align-items: center;
@ -238,6 +463,9 @@ onUnmounted(() => window.removeEventListener('resize', onResize))
border-radius: 0 2px 2px 0;
}
/* Sub-items are indented slightly in expanded state */
.nav-subitem { padding-left: 1.1rem; font-size: 0.875rem; }
.nav-icon {
font-size: 1.1rem;
flex-shrink: 0;
@ -245,12 +473,9 @@ onUnmounted(() => window.removeEventListener('resize', onResize))
text-align: center;
}
.nav-label {
overflow: hidden;
text-overflow: ellipsis;
}
.nav-label { overflow: hidden; text-overflow: ellipsis; }
/* Mobile hamburger — visible when sidebar is stowed on mobile */
/* Mobile hamburger */
.mobile-hamburger {
position: fixed;
top: 0.75rem;