feat: restructure AppSidebar into two-domain nav with section headers and flywheel signal badges
This commit is contained in:
parent
5bdb095235
commit
6ef6f06023
2 changed files with 385 additions and 36 deletions
124
web/src/components/AppSidebar.test.ts
Normal file
124
web/src/components/AppSidebar.test.ts
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -28,12 +28,59 @@
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Nav items -->
|
<!-- Nav -->
|
||||||
<ul class="nav-list" role="list">
|
<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
|
<RouterLink
|
||||||
:to="item.path"
|
:to="item.path"
|
||||||
class="nav-item"
|
class="nav-item nav-subitem"
|
||||||
:title="stowed ? item.label : ''"
|
:title="stowed ? item.label : ''"
|
||||||
@click="isMobile && stow()"
|
@click="isMobile && stow()"
|
||||||
>
|
>
|
||||||
|
|
@ -41,10 +88,94 @@
|
||||||
<span v-if="!stowed" class="nav-label">{{ item.label }}</span>
|
<span v-if="!stowed" class="nav-label">{{ item.label }}</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</li>
|
</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>
|
</ul>
|
||||||
</nav>
|
</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
|
<button
|
||||||
v-if="isMobile && stowed"
|
v-if="isMobile && stowed"
|
||||||
class="mobile-hamburger"
|
class="mobile-hamburger"
|
||||||
|
|
@ -61,25 +192,66 @@ import { RouterLink } from 'vue-router'
|
||||||
|
|
||||||
const LS_KEY = 'cf-avocet-nav-stowed'
|
const LS_KEY = 'cf-avocet-nav-stowed'
|
||||||
|
|
||||||
const navItems = [
|
interface NavItem {
|
||||||
{ path: '/', icon: '🃏', label: 'Label' },
|
path: string
|
||||||
{ path: '/fetch', icon: '📥', label: 'Fetch' },
|
icon: string
|
||||||
{ path: '/stats', icon: '📊', label: 'Stats' },
|
label: string
|
||||||
{ path: '/benchmark', icon: '🏁', label: 'Benchmark' },
|
}
|
||||||
{ path: '/models', icon: '🤗', label: 'Models' },
|
|
||||||
{ path: '/imitate', icon: '🪞', label: 'Imitate' },
|
interface DashboardSignals {
|
||||||
{ path: '/corrections', icon: '✍️', label: 'Corrections' },
|
data_to_eval: boolean
|
||||||
{ path: '/settings', icon: '⚙️', label: 'Settings' },
|
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 evalItems: NavItem[] = [
|
||||||
const winWidth = ref(window.innerWidth)
|
{ path: '/eval/benchmark', icon: '📊', label: 'Benchmark' },
|
||||||
const isMobile = computed(() => winWidth.value < 640)
|
{ 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() {
|
function toggle() {
|
||||||
stowed.value = !stowed.value
|
stowed.value = !stowed.value
|
||||||
localStorage.setItem(LS_KEY, String(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')
|
document.documentElement.style.setProperty('--sidebar-width', stowed.value ? '56px' : '200px')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -93,13 +265,12 @@ function onResize() { winWidth.value = window.innerWidth }
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
window.addEventListener('resize', onResize)
|
window.addEventListener('resize', onResize)
|
||||||
// Apply persisted sidebar width to :root on mount
|
|
||||||
document.documentElement.style.setProperty('--sidebar-width', stowed.value ? '56px' : '200px')
|
document.documentElement.style.setProperty('--sidebar-width', stowed.value ? '56px' : '200px')
|
||||||
// On mobile, default to stowed
|
|
||||||
if (isMobile.value && !localStorage.getItem(LS_KEY)) {
|
if (isMobile.value && !localStorage.getItem(LS_KEY)) {
|
||||||
stowed.value = true
|
stowed.value = true
|
||||||
document.documentElement.style.setProperty('--sidebar-width', '56px')
|
document.documentElement.style.setProperty('--sidebar-width', '56px')
|
||||||
}
|
}
|
||||||
|
loadSignals()
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => window.removeEventListener('resize', onResize))
|
onUnmounted(() => window.removeEventListener('resize', onResize))
|
||||||
|
|
@ -121,18 +292,15 @@ onUnmounted(() => window.removeEventListener('resize', onResize))
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar.stowed {
|
.sidebar.stowed { width: 56px; }
|
||||||
width: 56px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mobile: slide in/out from left */
|
|
||||||
.sidebar.mobile {
|
.sidebar.mobile {
|
||||||
box-shadow: 2px 0 16px rgba(0, 0, 0, 0.15);
|
box-shadow: 2px 0 16px rgba(0, 0, 0, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar.mobile.stowed {
|
.sidebar.mobile.stowed {
|
||||||
transform: translateX(-100%);
|
transform: translateX(-100%);
|
||||||
width: 200px; /* keep width so slide-in looks right */
|
width: 200px;
|
||||||
transition: transform 250ms ease, width 250ms ease;
|
transition: transform 250ms ease, width 250ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -165,10 +333,7 @@ onUnmounted(() => window.removeEventListener('resize', onResize))
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo-icon {
|
.logo-icon { font-size: 1.25rem; flex-shrink: 0; }
|
||||||
font-size: 1.25rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo-name {
|
.logo-name {
|
||||||
font-family: var(--font-display, var(--font-body, sans-serif));
|
font-family: var(--font-display, var(--font-body, sans-serif));
|
||||||
|
|
@ -193,16 +358,76 @@ onUnmounted(() => window.removeEventListener('resize', onResize))
|
||||||
transition: background 0.15s;
|
transition: background 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stow-btn:hover {
|
.stow-btn:hover { background: var(--color-border, #d0d7e8); }
|
||||||
background: var(--color-border, #d0d7e8);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-list {
|
.nav-list {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
padding: 0.5rem 0;
|
padding: 0.5rem 0;
|
||||||
flex: 1;
|
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 {
|
.nav-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -238,6 +463,9 @@ onUnmounted(() => window.removeEventListener('resize', onResize))
|
||||||
border-radius: 0 2px 2px 0;
|
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 {
|
.nav-icon {
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|
@ -245,12 +473,9 @@ onUnmounted(() => window.removeEventListener('resize', onResize))
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-label {
|
.nav-label { overflow: hidden; text-overflow: ellipsis; }
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mobile hamburger — visible when sidebar is stowed on mobile */
|
/* Mobile hamburger */
|
||||||
.mobile-hamburger {
|
.mobile-hamburger {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0.75rem;
|
top: 0.75rem;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue