feat(11-05): visual consistency pass — typography, focus-visible, hover/active states, skeleton cleanup
- Normalize page titles to text-2xl font-semibold (was font-bold in TopicsView, DocumentView, SharedView) - Normalize section titles to text-lg font-semibold (was text-xl in SettingsPreferencesTab, SettingsAiTab, SettingsCloudTab, BackupCodesDisplay, CloudCredentialModal) - Normalize panel headings to text-sm font-semibold (SettingsAccountTab, DocumentView) - Normalize AdminOverviewView page title from text-xl to text-2xl font-semibold - Replace decorative sidebar skeleton inline styles with static Tailwind width classes (w-12/w-16/w-20) - Add focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 to all interactive buttons across the app - Add active:bg-* states to primary and secondary buttons for coherent press feedback - Update nav-link scoped CSS in AppSidebar and AdminSidebar to include active:bg-gray-200 and focus-visible ring - Add 7 new tests: VISUAL-01 skeleton class invariant, VISUAL-04 focus-visible invariant, VISUAL-03 typography invariant (36 test files, 270 tests pass)
This commit is contained in:
@@ -36,7 +36,7 @@
|
||||
<div class="flex items-center gap-0.5">
|
||||
<button
|
||||
@click="foldersExpanded = !foldersExpanded"
|
||||
class="p-1 rounded hover:bg-gray-100 text-gray-400 hover:text-gray-600 transition-colors shrink-0"
|
||||
class="p-1 rounded hover:bg-gray-100 active:bg-gray-200 text-gray-400 hover:text-gray-600 transition-colors shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
|
||||
:title="foldersExpanded ? 'Collapse folders' : 'Expand folders'"
|
||||
>
|
||||
<AppIcon
|
||||
@@ -60,7 +60,7 @@
|
||||
<div v-if="loadingRoots" class="pl-7 py-1 space-y-1">
|
||||
<div v-for="n in 3" :key="`sk-f-${n}`" class="flex items-center gap-2 py-1">
|
||||
<div class="w-4 h-4 bg-gray-100 rounded animate-pulse shrink-0"></div>
|
||||
<div class="h-3 bg-gray-100 rounded animate-pulse" :style="{ width: (50 + n * 15) + 'px' }"></div>
|
||||
<div class="h-3 bg-gray-100 rounded animate-pulse" :class="n === 1 ? 'w-12' : n === 2 ? 'w-16' : 'w-20'"></div>
|
||||
</div>
|
||||
</div>
|
||||
<EmptyState
|
||||
@@ -83,7 +83,7 @@
|
||||
<div class="flex items-center gap-0.5">
|
||||
<button
|
||||
@click="cloudExpanded = !cloudExpanded"
|
||||
class="p-1 rounded hover:bg-gray-100 text-gray-400 hover:text-gray-600 transition-colors shrink-0"
|
||||
class="p-1 rounded hover:bg-gray-100 active:bg-gray-200 text-gray-400 hover:text-gray-600 transition-colors shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
|
||||
:title="cloudExpanded ? 'Collapse cloud storage' : 'Expand cloud storage'"
|
||||
>
|
||||
<AppIcon
|
||||
@@ -107,7 +107,7 @@
|
||||
<div v-if="loadingCloudConnections" class="pl-7 py-1 space-y-1">
|
||||
<div v-for="n in 3" :key="`sk-c-${n}`" class="flex items-center gap-2 py-1">
|
||||
<div class="w-4 h-4 bg-gray-100 rounded animate-pulse shrink-0"></div>
|
||||
<div class="h-3 bg-gray-100 rounded animate-pulse" :style="{ width: (50 + n * 15) + 'px' }"></div>
|
||||
<div class="h-3 bg-gray-100 rounded animate-pulse" :class="n === 1 ? 'w-12' : n === 2 ? 'w-16' : 'w-20'"></div>
|
||||
</div>
|
||||
</div>
|
||||
<EmptyState
|
||||
@@ -135,7 +135,7 @@
|
||||
<div v-if="topicsStore.loading" class="px-3 py-1 space-y-1">
|
||||
<div v-for="n in 3" :key="`sk-t-${n}`" class="flex items-center gap-2 py-1">
|
||||
<div class="w-4 h-4 bg-gray-100 rounded animate-pulse shrink-0"></div>
|
||||
<div class="h-3 bg-gray-100 rounded animate-pulse" :style="{ width: (50 + n * 15) + 'px' }"></div>
|
||||
<div class="h-3 bg-gray-100 rounded animate-pulse" :class="n === 1 ? 'w-12' : n === 2 ? 'w-16' : 'w-20'"></div>
|
||||
</div>
|
||||
</div>
|
||||
<EmptyState
|
||||
@@ -192,7 +192,7 @@
|
||||
<button
|
||||
@click="signOut"
|
||||
aria-label="Sign out"
|
||||
class="text-gray-400 hover:text-gray-600 transition-colors"
|
||||
class="text-gray-400 hover:text-gray-600 active:text-gray-800 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 rounded"
|
||||
>
|
||||
<AppIcon name="logout" class="w-4 h-4" />
|
||||
</button>
|
||||
@@ -257,7 +257,7 @@ async function signOut() {
|
||||
|
||||
<style scoped>
|
||||
.nav-link {
|
||||
@apply flex items-center px-3 py-2 rounded-lg text-gray-600 hover:bg-gray-100 hover:text-gray-900 transition-colors text-sm font-medium;
|
||||
@apply flex items-center px-3 py-2 rounded-lg text-gray-600 hover:bg-gray-100 hover:text-gray-900 active:bg-gray-200 transition-colors text-sm font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1;
|
||||
}
|
||||
.nav-link-active {
|
||||
@apply bg-indigo-50 text-indigo-700;
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* VISUAL-01 / VISUAL-03 / VISUAL-04: Visual consistency invariants for AppSidebar
|
||||
*
|
||||
* Tests that:
|
||||
* 1. Skeleton items use Tailwind width classes, not data-driven inline styles (VISUAL-01)
|
||||
* 2. Nav-link buttons have focus-visible ring classes for keyboard accessibility (VISUAL-04)
|
||||
* 3. Expand/collapse toggle buttons have focus-visible ring classes (VISUAL-04)
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||
|
||||
vi.mock('../../../api/client.js', () => ({
|
||||
listFolders: vi.fn().mockResolvedValue({ items: [] }),
|
||||
getSharedWithMe: vi.fn().mockResolvedValue([]),
|
||||
listCloudConnections: vi.fn().mockResolvedValue({ items: [] }),
|
||||
listTopics: vi.fn().mockResolvedValue({ topics: [] }),
|
||||
getMyQuota: vi.fn().mockResolvedValue({ used_bytes: 0, limit_bytes: 104857600 }),
|
||||
}))
|
||||
|
||||
const STUBS = {
|
||||
FolderTreeItem: true,
|
||||
CloudProviderTreeItem: true,
|
||||
QuotaBar: true,
|
||||
RouterLink: true,
|
||||
EmptyState: true,
|
||||
}
|
||||
|
||||
function makeRouter() {
|
||||
return createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/', component: { template: '<div/>' } },
|
||||
{ path: '/settings', component: { template: '<div/>' } },
|
||||
{ path: '/topics', component: { template: '<div/>' } },
|
||||
{ path: '/shared', component: { template: '<div/>' } },
|
||||
{ path: '/cloud', component: { template: '<div/>' } },
|
||||
{ path: '/admin', component: { template: '<div/>' } },
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
describe('VISUAL-01: Sidebar skeleton items use class-based widths (no inline style)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
it('skeleton width divs have w-12/w-16/w-20 class, not style attribute', async () => {
|
||||
vi.doMock('../../../api/client.js', () => ({
|
||||
listFolders: vi.fn().mockResolvedValue({ items: [] }),
|
||||
getSharedWithMe: vi.fn().mockResolvedValue([]),
|
||||
listCloudConnections: vi.fn().mockReturnValue(new Promise(() => {})),
|
||||
listTopics: vi.fn().mockResolvedValue({ topics: [] }),
|
||||
getMyQuota: vi.fn().mockResolvedValue({ used_bytes: 0, limit_bytes: 104857600 }),
|
||||
}))
|
||||
|
||||
setActivePinia(createPinia())
|
||||
const { useCloudConnectionsStore } = await import('../../../stores/cloudConnections.js')
|
||||
const cloudStore = useCloudConnectionsStore()
|
||||
cloudStore.loading = true
|
||||
cloudStore.connections = []
|
||||
|
||||
const router = makeRouter()
|
||||
await router.push('/')
|
||||
await router.isReady()
|
||||
|
||||
const AppSidebar = (await import('../AppSidebar.vue')).default
|
||||
const wrapper = mount(AppSidebar, {
|
||||
global: { plugins: [router], stubs: STUBS },
|
||||
})
|
||||
|
||||
cloudStore.loading = true
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
// All skeleton width divs should use Tailwind class, not inline style width
|
||||
const skeletonWidthDivs = wrapper
|
||||
.findAll('.animate-pulse')
|
||||
.filter(el => {
|
||||
const cls = el.classes()
|
||||
return cls.includes('h-3') // width skeleton lines (not icon squares)
|
||||
})
|
||||
|
||||
expect(skeletonWidthDivs.length).toBeGreaterThan(0)
|
||||
|
||||
for (const el of skeletonWidthDivs) {
|
||||
// Must NOT have an inline style with width
|
||||
const style = el.attributes('style') || ''
|
||||
expect(style).not.toMatch(/width\s*:/)
|
||||
|
||||
// Must have a Tailwind width class (w-12, w-16, or w-20)
|
||||
const cls = el.classes().join(' ')
|
||||
const hasWidthClass = /\bw-\d+\b/.test(cls)
|
||||
expect(hasWidthClass).toBe(true)
|
||||
}
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('topics skeleton items use width classes, not inline style', async () => {
|
||||
vi.doMock('../../../api/client.js', () => ({
|
||||
listFolders: vi.fn().mockResolvedValue({ items: [] }),
|
||||
getSharedWithMe: vi.fn().mockResolvedValue([]),
|
||||
listCloudConnections: vi.fn().mockResolvedValue({ items: [] }),
|
||||
listTopics: vi.fn().mockResolvedValue({ topics: [] }),
|
||||
getMyQuota: vi.fn().mockResolvedValue({ used_bytes: 0, limit_bytes: 104857600 }),
|
||||
}))
|
||||
|
||||
setActivePinia(createPinia())
|
||||
const { useTopicsStore } = await import('../../../stores/topics.js')
|
||||
const topicsStore = useTopicsStore()
|
||||
topicsStore.loading = true
|
||||
topicsStore.topics = []
|
||||
|
||||
const router = makeRouter()
|
||||
await router.push('/')
|
||||
await router.isReady()
|
||||
|
||||
const AppSidebar = (await import('../AppSidebar.vue')).default
|
||||
const wrapper = mount(AppSidebar, {
|
||||
global: { plugins: [router], stubs: STUBS },
|
||||
})
|
||||
|
||||
topicsStore.loading = true
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const skeletonWidthDivs = wrapper
|
||||
.findAll('.animate-pulse')
|
||||
.filter(el => el.classes().includes('h-3'))
|
||||
|
||||
expect(skeletonWidthDivs.length).toBeGreaterThan(0)
|
||||
|
||||
for (const el of skeletonWidthDivs) {
|
||||
const style = el.attributes('style') || ''
|
||||
expect(style).not.toMatch(/width\s*:/)
|
||||
}
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('VISUAL-04: Sidebar interactive elements have focus-visible ring classes', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
it('expand/collapse folder toggle button has focus-visible:ring-2', async () => {
|
||||
vi.doMock('../../../api/client.js', () => ({
|
||||
listFolders: vi.fn().mockResolvedValue({ items: [] }),
|
||||
getSharedWithMe: vi.fn().mockResolvedValue([]),
|
||||
listCloudConnections: vi.fn().mockResolvedValue({ items: [] }),
|
||||
listTopics: vi.fn().mockResolvedValue({ topics: [] }),
|
||||
getMyQuota: vi.fn().mockResolvedValue({ used_bytes: 0, limit_bytes: 104857600 }),
|
||||
}))
|
||||
|
||||
setActivePinia(createPinia())
|
||||
const router = makeRouter()
|
||||
await router.push('/')
|
||||
await router.isReady()
|
||||
|
||||
const AppSidebar = (await import('../AppSidebar.vue')).default
|
||||
const wrapper = mount(AppSidebar, {
|
||||
global: { plugins: [router], stubs: STUBS },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
const toggleBtn = wrapper.find('button[title="Expand folders"]')
|
||||
expect(toggleBtn.exists()).toBe(true)
|
||||
const cls = toggleBtn.attributes('class') || ''
|
||||
expect(cls).toContain('focus-visible:ring-2')
|
||||
expect(cls).toContain('focus-visible:ring-indigo-500')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('sign-out button has focus-visible:ring-2', async () => {
|
||||
vi.doMock('../../../api/client.js', () => ({
|
||||
listFolders: vi.fn().mockResolvedValue({ items: [] }),
|
||||
getSharedWithMe: vi.fn().mockResolvedValue([]),
|
||||
listCloudConnections: vi.fn().mockResolvedValue({ items: [] }),
|
||||
listTopics: vi.fn().mockResolvedValue({ topics: [] }),
|
||||
getMyQuota: vi.fn().mockResolvedValue({ used_bytes: 0, limit_bytes: 104857600 }),
|
||||
}))
|
||||
|
||||
setActivePinia(createPinia())
|
||||
const { useAuthStore } = await import('../../../stores/auth.js')
|
||||
const authStore = useAuthStore()
|
||||
authStore.user = { email: 'test@example.com', role: 'user' }
|
||||
|
||||
const router = makeRouter()
|
||||
await router.push('/')
|
||||
await router.isReady()
|
||||
|
||||
const AppSidebar = (await import('../AppSidebar.vue')).default
|
||||
const wrapper = mount(AppSidebar, {
|
||||
global: { plugins: [router], stubs: STUBS },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
const signOutBtn = wrapper.find('button[aria-label="Sign out"]')
|
||||
expect(signOutBtn.exists()).toBe(true)
|
||||
const cls = signOutBtn.attributes('class') || ''
|
||||
expect(cls).toContain('focus-visible:ring-2')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user