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:
curo1305
2026-06-16 21:48:56 +02:00
parent deea237033
commit 2af5b7c313
21 changed files with 460 additions and 80 deletions
@@ -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()
})
})