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
@@ -61,7 +61,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>
@@ -86,7 +86,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;
@@ -1,7 +1,7 @@
<template>
<div class="space-y-4">
<div>
<h3 class="text-xl font-semibold text-gray-900 mb-1">Save your backup codes</h3>
<h3 class="text-lg font-semibold text-gray-900 mb-1">Save your backup codes</h3>
<p class="text-sm text-gray-500">
Store these codes somewhere safe. Each can only be used once if you lose access to your authenticator app.
</p>
@@ -22,7 +22,7 @@
<button
type="button"
@click="copyAll"
class="flex items-center gap-2 text-sm font-semibold text-indigo-600 hover:text-indigo-700 transition-colors"
class="flex items-center gap-2 text-sm font-semibold text-indigo-600 hover:text-indigo-700 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 rounded"
>
<!-- Clipboard icon -->
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
@@ -49,7 +49,7 @@
type="button"
@click="acknowledged && $emit('acknowledged')"
:disabled="!acknowledged"
class="w-full px-6 py-3 bg-indigo-600 text-white rounded-lg text-sm font-semibold hover:bg-indigo-700 transition-colors min-h-[44px]"
class="w-full px-6 py-3 bg-indigo-600 text-white rounded-lg text-sm font-semibold hover:bg-indigo-700 active:bg-indigo-800 transition-colors min-h-[44px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
:class="!acknowledged ? 'opacity-50 cursor-not-allowed' : ''"
>
Enable two-factor authentication
@@ -8,13 +8,13 @@
<div data-test="cloud-credential-modal-panel" class="bg-white rounded-xl shadow-xl w-full max-w-md p-6 max-h-[90vh] overflow-y-auto">
<!-- Header -->
<div class="flex items-center justify-between mb-5">
<h3 class="text-xl font-semibold text-gray-900">
<h3 class="text-lg font-semibold text-gray-900">
{{ existing ? 'Edit' : 'Connect' }} {{ provider?.label }}
</h3>
<button
@click="close"
aria-label="Close modal"
class="text-gray-400 hover:text-gray-600 transition-colors"
class="text-gray-400 hover:text-gray-600 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 rounded"
>
<AppIcon name="x" class="w-5 h-5" />
</button>
@@ -62,7 +62,7 @@
ref="pickerTriggerEl"
@click.stop="toggleFolderPicker"
aria-label="Move to folder"
class="min-h-[44px] min-w-[44px] flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-indigo-600 hover:bg-indigo-50 transition-colors"
class="min-h-[44px] min-w-[44px] flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-indigo-600 hover:bg-indigo-50 active:bg-indigo-100 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
>
<AppIcon name="folderMove" class="w-4 h-4" />
</button>
@@ -71,7 +71,7 @@
<button
@click.stop="openShareModal"
aria-label="Share document"
class="min-h-[44px] min-w-[44px] flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-indigo-600 hover:bg-indigo-50 transition-colors"
class="min-h-[44px] min-w-[44px] flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-indigo-600 hover:bg-indigo-50 active:bg-indigo-100 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
>
<AppIcon name="share" class="w-4 h-4" />
</button>
@@ -98,13 +98,13 @@
@click.stop
>
<button
class="w-full text-left px-3 py-2 text-sm text-gray-600 hover:bg-gray-50"
class="w-full text-left px-3 py-2 text-sm text-gray-600 hover:bg-gray-50 active:bg-gray-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-500"
@click.stop="moveToFolder(null)"
>Root (no folder)</button>
<button
v-for="folder in allFolders"
:key="folder.id"
class="w-full text-left px-3 py-2 text-sm text-gray-700 hover:bg-indigo-50 hover:text-indigo-600 truncate"
class="w-full text-left px-3 py-2 text-sm text-gray-700 hover:bg-indigo-50 hover:text-indigo-600 active:bg-indigo-100 truncate focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-500"
@click.stop="moveToFolder(folder.id)"
>{{ folder.name }}</button>
<p v-if="!allFolders.length" class="px-3 py-2 text-xs text-gray-400">No folders yet</p>
@@ -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()
})
})
@@ -3,7 +3,7 @@
<!-- 1. Account information -->
<section class="bg-white border border-gray-200 rounded-xl p-6">
<h3 class="font-semibold text-gray-800 mb-4">Account information</h3>
<h3 class="text-sm font-semibold text-gray-800 mb-4">Account information</h3>
<div class="space-y-2 text-sm text-gray-700">
<div><span class="text-gray-500">Email:</span> {{ authStore.user?.email }}</div>
<div><span class="text-gray-500">Username:</span> @{{ authStore.user?.handle }}</div>
@@ -23,7 +23,7 @@
<!-- 2. Two-factor authentication -->
<section class="bg-white border border-gray-200 rounded-xl p-6">
<h3 class="font-semibold text-gray-800 mb-4">Two-factor authentication</h3>
<h3 class="text-sm font-semibold text-gray-800 mb-4">Two-factor authentication</h3>
<!-- TOTP enabled: show status + disable option -->
<template v-if="authStore.user?.totp_enabled">
@@ -39,7 +39,7 @@
<button
type="button"
@click="confirmDisable2fa = true"
class="text-sm px-4 py-2 border border-red-300 text-red-600 rounded-lg hover:bg-red-50 transition-colors"
class="text-sm px-4 py-2 border border-red-300 text-red-600 rounded-lg hover:bg-red-50 active:bg-red-100 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-1"
>
Disable 2FA
</button>
@@ -64,7 +64,7 @@
<!-- 3. Change password -->
<section class="bg-white border border-gray-200 rounded-xl p-6">
<h3 class="font-semibold text-gray-800 mb-4">Change password</h3>
<h3 class="text-sm font-semibold text-gray-800 mb-4">Change password</h3>
<form @submit.prevent="changePassword" class="space-y-4 max-w-sm">
<div>
@@ -125,13 +125,13 @@
<!-- 4. Sessions sign out all devices -->
<section class="bg-white border border-gray-200 rounded-xl p-6">
<h3 class="font-semibold text-gray-800 mb-4">Sessions</h3>
<h3 class="text-sm font-semibold text-gray-800 mb-4">Sessions</h3>
<template v-if="!confirmSignOutAll">
<button
type="button"
@click="confirmSignOutAll = true"
class="text-sm px-4 py-2 border border-red-300 text-red-600 rounded-lg hover:bg-red-50 transition-colors"
class="text-sm px-4 py-2 border border-red-300 text-red-600 rounded-lg hover:bg-red-50 active:bg-red-100 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-1"
>
Sign out all devices
</button>
@@ -1,6 +1,6 @@
<template>
<section class="bg-white border border-gray-200 rounded-xl p-6">
<h3 class="text-xl font-semibold text-gray-800 mb-2">AI configuration</h3>
<h3 class="text-lg font-semibold text-gray-800 mb-2">AI configuration</h3>
<p class="text-sm text-gray-600">
AI provider and model are managed by your administrator. Contact your admin
to request changes to which AI provider is used for your documents.
@@ -1,7 +1,7 @@
<template>
<div>
<section class="bg-white border border-gray-200 rounded-xl p-6">
<h3 class="text-xl font-semibold text-gray-800 mb-1">Cloud Storage</h3>
<h3 class="text-lg font-semibold text-gray-800 mb-1">Cloud Storage</h3>
<p class="text-sm text-gray-600 mb-5">Connect a cloud storage provider to use as a document destination.</p>
<!-- Loading state -->
@@ -52,7 +52,7 @@
<template v-if="!connectionFor(provider.key)">
<button
@click="handleConnect(provider)"
class="bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-semibold px-4 py-2 rounded-lg transition-colors min-w-[160px]"
class="bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800 text-white text-sm font-semibold px-4 py-2 rounded-lg transition-colors min-w-[160px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
>
Connect {{ provider.label }}
</button>
@@ -63,14 +63,14 @@
<button
v-if="!OAUTH_PROVIDERS.has(provider.key) && confirmRemoveId !== connectionFor(provider.key)?.id"
@click="handleEdit(provider)"
class="text-sm px-3 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 text-gray-700 transition-colors"
class="text-sm px-3 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 active:bg-gray-100 text-gray-700 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
>
Edit
</button>
<button
v-if="confirmRemoveId !== connectionFor(provider.key)?.id"
@click="confirmRemoveId = connectionFor(provider.key)?.id"
class="text-sm px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 text-gray-700 transition-colors"
class="text-sm px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 active:bg-gray-100 text-gray-700 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
>
Remove {{ provider.label }}
</button>
@@ -90,14 +90,14 @@
<template v-else-if="connectionFor(provider.key)?.status === 'REQUIRES_REAUTH'">
<button
@click="handleConnect(provider)"
class="bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-semibold px-4 py-2 rounded-lg transition-colors min-w-[160px]"
class="bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800 text-white text-sm font-semibold px-4 py-2 rounded-lg transition-colors min-w-[160px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
>
Reconnect {{ provider.label }}
</button>
<button
v-if="confirmRemoveId !== connectionFor(provider.key)?.id"
@click="confirmRemoveId = connectionFor(provider.key)?.id"
class="text-sm px-3 py-2 text-gray-500 hover:text-gray-700 transition-colors"
class="text-sm px-3 py-2 text-gray-500 hover:text-gray-700 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 rounded"
>
Remove {{ provider.label }}
</button>
@@ -118,14 +118,14 @@
<button
v-if="!OAUTH_PROVIDERS.has(provider.key) && confirmRemoveId !== connectionFor(provider.key)?.id"
@click="handleEdit(provider)"
class="text-sm px-3 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 text-gray-700 transition-colors"
class="text-sm px-3 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 active:bg-gray-100 text-gray-700 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
>
Edit
</button>
<button
v-if="confirmRemoveId !== connectionFor(provider.key)?.id"
@click="confirmRemoveId = connectionFor(provider.key)?.id"
class="text-sm px-4 py-2 border border-red-300 rounded-lg hover:bg-red-50 text-red-600 transition-colors"
class="text-sm px-4 py-2 border border-red-300 rounded-lg hover:bg-red-50 active:bg-red-100 text-red-600 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-1"
>
Remove {{ provider.label }}
</button>
@@ -162,7 +162,7 @@
<button
v-if="!showDisconnectAll"
@click="showDisconnectAll = true"
class="text-sm text-red-600 hover:text-red-700 hover:underline font-medium transition-colors"
class="text-sm text-red-600 hover:text-red-700 hover:underline font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-1 rounded"
>
Disconnect all cloud storage
</button>
@@ -1,6 +1,6 @@
<template>
<section class="bg-white border border-gray-200 rounded-xl p-6">
<h3 class="text-xl font-semibold text-gray-800 mb-2">Document Preferences</h3>
<h3 class="text-lg font-semibold text-gray-800 mb-2">Document Preferences</h3>
<p class="text-sm text-gray-600 mb-4">Choose how PDF documents open when you click on them.</p>
<div class="space-y-3">
@@ -19,7 +19,7 @@
<button
v-if="mode === 'local'"
@click="$emit('new-folder')"
class="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-indigo-600 border border-indigo-200 hover:bg-indigo-50 rounded-lg transition-colors"
class="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-indigo-600 border border-indigo-200 hover:bg-indigo-50 active:bg-indigo-100 rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
>
<AppIcon name="plus" class="w-4 h-4" />
New folder
@@ -100,11 +100,11 @@
<div class="flex justify-end gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity" @click.stop>
<template v-if="mode === 'local'">
<button @click.stop="startRename(folder)" title="Rename"
class="p-1.5 md:p-1.5 min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 rounded hover:bg-gray-200 text-gray-400 hover:text-gray-700 transition-colors flex items-center justify-center">
class="p-1.5 md:p-1.5 min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 rounded hover:bg-gray-200 active:bg-gray-300 text-gray-400 hover:text-gray-700 transition-colors flex items-center justify-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1">
<AppIcon name="pencil" class="w-3.5 h-3.5" />
</button>
<button @click.stop="$emit('folder-delete', folder)" title="Delete folder"
class="p-1.5 md:p-1.5 min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 rounded hover:bg-red-50 text-gray-400 hover:text-red-500 transition-colors flex items-center justify-center">
class="p-1.5 md:p-1.5 min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 rounded hover:bg-red-50 active:bg-red-100 text-gray-400 hover:text-red-500 transition-colors flex items-center justify-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-1">
<AppIcon name="trash" class="w-3.5 h-3.5" />
</button>
</template>
@@ -147,20 +147,20 @@
<div class="flex justify-end gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity" @click.stop>
<template v-if="mode === 'local'">
<button @click.stop="$emit('file-share', file)" title="Share"
class="p-1.5 min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 rounded hover:bg-gray-200 text-gray-400 hover:text-gray-700 transition-colors flex items-center justify-center">
class="p-1.5 min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 rounded hover:bg-gray-200 active:bg-gray-300 text-gray-400 hover:text-gray-700 transition-colors flex items-center justify-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1">
<AppIcon name="share" class="w-3.5 h-3.5" />
</button>
<div class="relative">
<button
@click.stop="openFolderPicker(file.id, $event)"
title="Move to folder"
class="p-1.5 min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 rounded hover:bg-gray-200 text-gray-400 hover:text-gray-700 transition-colors flex items-center justify-center"
class="p-1.5 min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 rounded hover:bg-gray-200 active:bg-gray-300 text-gray-400 hover:text-gray-700 transition-colors flex items-center justify-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
>
<AppIcon name="folderMove" class="w-3.5 h-3.5" />
</button>
</div>
<button @click.stop="$emit('file-delete', file.id)" title="Delete"
class="p-1.5 min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 rounded hover:bg-red-50 text-gray-400 hover:text-red-500 transition-colors flex items-center justify-center">
class="p-1.5 min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 rounded hover:bg-red-50 active:bg-red-100 text-gray-400 hover:text-red-500 transition-colors flex items-center justify-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-1">
<AppIcon name="trash" class="w-3.5 h-3.5" />
</button>
</template>
@@ -221,7 +221,7 @@
@click.stop
>
<button
class="w-full text-left px-3 py-2 text-sm text-gray-600 hover:bg-gray-50"
class="w-full text-left px-3 py-2 text-sm text-gray-600 hover:bg-gray-50 active:bg-gray-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-500"
@click.stop="$emit('file-move', { fileId: folderPickerFileId, folderId: null }); folderPickerFileId = null"
>
Root (no folder)
@@ -229,7 +229,7 @@
<button
v-for="f in rootFolders"
:key="f.id"
class="w-full text-left px-3 py-2 text-sm text-gray-700 hover:bg-indigo-50 hover:text-indigo-700 truncate"
class="w-full text-left px-3 py-2 text-sm text-gray-700 hover:bg-indigo-50 hover:text-indigo-700 active:bg-indigo-100 truncate focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-500"
@click.stop="$emit('file-move', { fileId: folderPickerFileId, folderId: f.id }); folderPickerFileId = null"
>{{ f.name }}</button>
<p v-if="!rootFolders.length" class="px-3 py-2 text-xs text-gray-400">No folders yet</p>
@@ -17,7 +17,7 @@
/>
<button
type="submit"
class="px-4 py-2 bg-indigo-600 text-white rounded-lg text-sm font-medium hover:bg-indigo-700 transition-colors"
class="px-4 py-2 bg-indigo-600 text-white rounded-lg text-sm font-medium hover:bg-indigo-700 active:bg-indigo-800 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
:disabled="saving"
>
{{ saving ? 'Adding…' : 'Add' }}
@@ -50,8 +50,8 @@
placeholder="Description"
class="flex-1 border border-gray-300 rounded px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
/>
<button @click="saveEdit(topic.id)" class="text-xs text-indigo-600 font-medium">Save</button>
<button @click="editing = null" class="text-xs text-gray-400">Cancel</button>
<button @click="saveEdit(topic.id)" class="text-xs text-indigo-600 font-medium hover:text-indigo-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 rounded">Save</button>
<button @click="editing = null" class="text-xs text-gray-400 hover:text-gray-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 rounded">Cancel</button>
</div>
<div v-else class="flex-1 min-w-0">
@@ -63,8 +63,8 @@
</div>
<div class="flex gap-2 shrink-0">
<button @click="startEdit(topic)" class="text-xs text-gray-500 hover:text-indigo-600">Edit</button>
<button @click="remove(topic)" class="text-xs text-gray-500 hover:text-red-500">Delete</button>
<button @click="startEdit(topic)" class="text-xs text-gray-500 hover:text-indigo-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 rounded">Edit</button>
<button @click="remove(topic)" class="text-xs text-gray-500 hover:text-red-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-1 rounded">Delete</button>
</div>
</div>
+10 -10
View File
@@ -1,7 +1,7 @@
<template>
<div class="p-8 max-w-4xl mx-auto">
<!-- Back -->
<button @click="$router.back()" class="text-sm text-indigo-600 hover:underline mb-6 flex items-center gap-1">
<button @click="$router.back()" class="text-sm text-indigo-600 hover:underline mb-6 flex items-center gap-1 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 rounded">
Back
</button>
@@ -12,7 +12,7 @@
<!-- Header -->
<div class="flex items-start justify-between gap-4 mb-6">
<div>
<h2 class="text-2xl font-bold text-gray-900 break-all">{{ doc.original_name }}</h2>
<h2 class="text-2xl font-semibold text-gray-900 break-all">{{ doc.original_name }}</h2>
<p class="text-sm text-gray-400 mt-1">
Uploaded {{ formatDate(doc.created_at) }} · {{ formatSize(doc.size_bytes) }} · {{ doc.mime_type }}
</p>
@@ -22,13 +22,13 @@
<button
v-if="isPdf"
@click="openPdf"
class="text-sm px-3 py-1.5 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors"
class="text-sm px-3 py-1.5 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 active:bg-indigo-800 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
>
{{ pdfOpenMode === 'in_app' ? 'Preview' : 'Open' }}
</button>
<button
@click="confirmDelete"
class="text-sm text-red-500 hover:text-red-700"
class="text-sm text-red-500 hover:text-red-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-1 rounded"
>Delete</button>
</div>
</div>
@@ -36,19 +36,19 @@
<!-- Topics -->
<div class="bg-white border border-gray-200 rounded-xl p-5 mb-5">
<div class="flex items-center justify-between mb-3">
<h3 class="font-semibold text-gray-800">Topics</h3>
<h3 class="text-sm font-semibold text-gray-800">Topics</h3>
<div class="flex gap-2">
<button
@click="reclassify"
:disabled="classifying"
class="text-xs px-3 py-1.5 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors disabled:opacity-50"
class="text-xs px-3 py-1.5 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 active:bg-indigo-800 transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
>
{{ classifying ? 'Classifying…' : 'Re-classify' }}
</button>
<button
@click="suggestTopics"
:disabled="suggesting"
class="text-xs px-3 py-1.5 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors disabled:opacity-50"
class="text-xs px-3 py-1.5 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 active:bg-gray-100 transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
>
{{ suggesting ? 'Suggesting…' : 'Suggest Topics' }}
</button>
@@ -97,7 +97,7 @@
<!-- Extracted text -->
<div class="bg-white border border-gray-200 rounded-xl p-5">
<h3 class="font-semibold text-gray-800 mb-3">Extracted Text</h3>
<h3 class="text-sm font-semibold text-gray-800 mb-3">Extracted Text</h3>
<pre class="text-xs text-gray-600 whitespace-pre-wrap font-mono bg-gray-50 rounded-lg p-4 max-h-96 overflow-y-auto">{{ doc.extracted_text || '(no text extracted)' }}</pre>
</div>
</template>
@@ -132,11 +132,11 @@
<div class="flex gap-2 justify-end">
<button
@click="cancelCloudDeleteWarning"
class="border border-gray-300 text-gray-700 text-sm px-4 py-2 rounded-lg hover:bg-gray-50 transition-colors"
class="border border-gray-300 text-gray-700 text-sm px-4 py-2 rounded-lg hover:bg-gray-50 active:bg-gray-100 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
>Cancel</button>
<button
@click="confirmRemoveOnly"
class="bg-red-600 hover:bg-red-700 text-white text-sm px-4 py-2 rounded-lg transition-colors"
class="bg-red-600 hover:bg-red-700 active:bg-red-800 text-white text-sm px-4 py-2 rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-1"
>Remove from app</button>
</div>
</div>
+3 -3
View File
@@ -18,7 +18,7 @@
<button
@click="oauthSuccessProvider = null"
aria-label="Dismiss notification"
class="text-gray-400 hover:text-gray-600 shrink-0"
class="text-gray-400 hover:text-gray-600 shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 rounded"
>
<AppIcon name="x" class="w-4 h-4" />
</button>
@@ -30,7 +30,7 @@
v-for="tab in tabs"
:key="tab.id"
@click="activeTab = tab.id"
class="px-4 py-2 text-sm font-semibold border-b-2 transition-colors"
class="px-4 py-2 text-sm font-semibold border-b-2 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 rounded-t"
:class="activeTab === tab.id
? 'text-indigo-600 border-indigo-600'
: 'text-gray-500 hover:text-gray-700 border-transparent'"
@@ -64,7 +64,7 @@
<button
@click="oauthError = null"
aria-label="Dismiss error"
class="text-red-400 hover:text-red-600 shrink-0"
class="text-red-400 hover:text-red-600 shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-1 rounded"
>
<AppIcon name="x" class="w-4 h-4" />
</button>
+1 -1
View File
@@ -2,7 +2,7 @@
<div class="p-8 max-w-4xl mx-auto">
<BreadcrumbBar :segments="[{ label: 'Shared with me' }]" :show-root="false" class="mb-4" />
<h2 class="text-2xl font-bold text-gray-900 mb-1">Shared with me</h2>
<h2 class="text-2xl font-semibold text-gray-900 mb-1">Shared with me</h2>
<p class="text-gray-500 text-sm mb-6">Documents other users have shared with you.</p>
<div v-if="loading" class="text-sm text-gray-400">Loading</div>
+3 -3
View File
@@ -3,7 +3,7 @@
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<div>
<h2 class="text-2xl font-bold text-gray-900">
<h2 class="text-2xl font-semibold text-gray-900">
{{ activeTopic ? activeTopic : 'All Topics' }}
</h2>
<p class="text-gray-500 text-sm mt-0.5">
@@ -13,7 +13,7 @@
<button
v-if="activeTopic"
@click="$router.push('/topics')"
class="text-sm text-indigo-600 hover:underline"
class="text-sm text-indigo-600 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 rounded"
>
All Topics
</button>
@@ -31,7 +31,7 @@
v-for="topic in topicsStore.topics"
:key="topic.id"
:to="`/topics/${encodeURIComponent(topic.name)}`"
class="bg-white border border-gray-200 rounded-xl p-4 hover:border-indigo-300 hover:shadow-sm transition-all"
class="bg-white border border-gray-200 rounded-xl p-4 hover:border-indigo-300 hover:shadow-sm transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
>
<div class="flex items-center gap-2 mb-2">
<span class="w-3 h-3 rounded-full" :style="{ backgroundColor: topic.color }"></span>
@@ -0,0 +1,171 @@
/**
* VISUAL-01 / VISUAL-03: Typography normalization invariants
*
* Tests that page-level h2 headings use text-2xl font-semibold (not font-bold),
* and section-level h3 headings inside cards use text-sm or text-lg font-semibold (not text-xl).
*/
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', () => ({
getSharedWithMe: vi.fn().mockResolvedValue([]),
listFolders: vi.fn().mockResolvedValue({ items: [] }),
getDocument: vi.fn().mockResolvedValue(null),
getMyPreferences: vi.fn().mockResolvedValue({ pdf_open_mode: 'new_tab' }),
listTopics: vi.fn().mockResolvedValue({ topics: [] }),
getMyQuota: vi.fn().mockResolvedValue({ used_bytes: 0, limit_bytes: 104857600 }),
}))
function makeRouter(extraRoutes = []) {
return createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: { template: '<div/>' } },
{ path: '/topics', component: { template: '<div/>' } },
{ path: '/shared', component: { template: '<div/>' } },
{ path: '/settings', component: { template: '<div/>' } },
...extraRoutes,
],
})
}
describe('VISUAL-03: SharedView page title uses font-semibold (not font-bold)', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.resetModules()
})
it('h2 has text-2xl font-semibold and not font-bold', async () => {
vi.doMock('../../api/client.js', () => ({
getSharedWithMe: vi.fn().mockResolvedValue([]),
listFolders: vi.fn().mockResolvedValue({ items: [] }),
getDocument: vi.fn().mockResolvedValue(null),
getMyPreferences: vi.fn().mockResolvedValue({ pdf_open_mode: 'new_tab' }),
listTopics: vi.fn().mockResolvedValue({ topics: [] }),
getMyQuota: vi.fn().mockResolvedValue({ used_bytes: 0, limit_bytes: 104857600 }),
}))
setActivePinia(createPinia())
const router = makeRouter()
await router.push('/shared')
await router.isReady()
const SharedView = (await import('../SharedView.vue')).default
const wrapper = mount(SharedView, {
global: {
plugins: [router],
stubs: { BreadcrumbBar: true, EmptyState: true, AppIcon: true },
},
})
await flushPromises()
const h2 = wrapper.find('h2')
expect(h2.exists()).toBe(true)
const cls = h2.attributes('class') || ''
expect(cls).toContain('font-semibold')
expect(cls).not.toContain('font-bold')
expect(cls).toContain('text-2xl')
wrapper.unmount()
})
})
describe('VISUAL-03: TopicsView page title uses font-semibold (not font-bold)', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.resetModules()
})
it('h2 has text-2xl font-semibold and not font-bold', async () => {
vi.doMock('../../api/client.js', () => ({
getSharedWithMe: vi.fn().mockResolvedValue([]),
listFolders: vi.fn().mockResolvedValue({ items: [] }),
getDocument: vi.fn().mockResolvedValue(null),
getMyPreferences: vi.fn().mockResolvedValue({ pdf_open_mode: 'new_tab' }),
listTopics: vi.fn().mockResolvedValue({ topics: [] }),
getMyQuota: vi.fn().mockResolvedValue({ used_bytes: 0, limit_bytes: 104857600 }),
}))
setActivePinia(createPinia())
const router = makeRouter([
{ path: '/topics/:name?', component: { template: '<div/>' } },
])
await router.push('/topics')
await router.isReady()
const TopicsView = (await import('../TopicsView.vue')).default
const wrapper = mount(TopicsView, {
global: {
plugins: [router],
stubs: { TopicManager: true, DocumentCard: true, BreadcrumbBar: true },
},
})
await flushPromises()
const h2 = wrapper.find('h2')
expect(h2.exists()).toBe(true)
const cls = h2.attributes('class') || ''
expect(cls).toContain('font-semibold')
expect(cls).not.toContain('font-bold')
expect(cls).toContain('text-2xl')
wrapper.unmount()
})
})
describe('VISUAL-03: SettingsAccountTab section headings use text-sm font-semibold', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.resetModules()
})
it('h3 inside bg-white panel does not use text-xl or font-bold', async () => {
vi.doMock('../../api/client.js', () => ({
getSharedWithMe: vi.fn().mockResolvedValue([]),
listFolders: vi.fn().mockResolvedValue({ items: [] }),
getDocument: vi.fn().mockResolvedValue(null),
getMyPreferences: vi.fn().mockResolvedValue({ pdf_open_mode: 'new_tab' }),
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', totp_enabled: false, handle: 'test' }
const router = makeRouter()
await router.push('/settings')
await router.isReady()
const SettingsAccountTab = (await import('../../components/settings/SettingsAccountTab.vue')).default
const wrapper = mount(SettingsAccountTab, {
global: {
plugins: [router],
stubs: {
TotpEnrollment: true,
ConfirmBlock: true,
PasswordStrengthBar: true,
AppIcon: true,
AppSpinner: true,
},
},
})
await flushPromises()
const headings = wrapper.findAll('h3')
expect(headings.length).toBeGreaterThan(0)
for (const h of headings) {
const cls = h.attributes('class') || ''
expect(cls).not.toContain('text-xl')
expect(cls).not.toContain('font-bold')
expect(cls).toContain('font-semibold')
}
wrapper.unmount()
})
})
+5 -5
View File
@@ -28,7 +28,7 @@
:class="['transition-colors', prov.is_active ? 'border-l-4 border-l-green-500' : '']"
>
<button
class="w-full flex items-center justify-between px-6 py-3 text-left hover:bg-gray-50 transition-colors"
class="w-full flex items-center justify-between px-6 py-3 text-left hover:bg-gray-50 active:bg-gray-100 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-500"
@click="toggleProvider(prov.provider_id)"
>
<div class="flex items-center gap-3">
@@ -91,7 +91,7 @@
<button
@click="saveSystemProvider(prov.provider_id, { activate: true })"
:disabled="savingProvider === prov.provider_id || prov.is_active"
class="text-xs bg-green-600 text-white font-semibold px-3 py-1.5 rounded-lg hover:bg-green-700 disabled:opacity-50 transition-colors"
class="text-xs bg-green-600 text-white font-semibold px-3 py-1.5 rounded-lg hover:bg-green-700 active:bg-green-800 disabled:opacity-50 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-green-500 focus-visible:ring-offset-1"
>
Set Active
</button>
@@ -99,7 +99,7 @@
<button
@click="saveSystemProvider(prov.provider_id)"
:disabled="savingProvider === prov.provider_id"
class="text-xs bg-indigo-600 text-white font-semibold px-3 py-1.5 rounded-lg hover:bg-indigo-700 disabled:opacity-50 flex items-center gap-1 transition-colors"
class="text-xs bg-indigo-600 text-white font-semibold px-3 py-1.5 rounded-lg hover:bg-indigo-700 active:bg-indigo-800 disabled:opacity-50 flex items-center gap-1 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
>
<span v-if="savingProvider === prov.provider_id" class="animate-spin rounded-full border-2 border-current border-t-transparent w-3 h-3"></span>
{{ savingProvider === prov.provider_id ? 'Saving' : 'Save' }}
@@ -108,7 +108,7 @@
<button
@click="runTestConnection(prov.provider_id)"
:disabled="testingProvider === prov.provider_id || savingProvider === prov.provider_id"
class="text-xs border border-gray-300 text-gray-700 font-semibold px-3 py-1.5 rounded-lg hover:bg-gray-100 disabled:opacity-50 flex items-center gap-1.5 transition-colors"
class="text-xs border border-gray-300 text-gray-700 font-semibold px-3 py-1.5 rounded-lg hover:bg-gray-100 active:bg-gray-200 disabled:opacity-50 flex items-center gap-1.5 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
>
<span
v-if="testingProvider === prov.provider_id"
@@ -178,7 +178,7 @@
<button
@click="saveConfig(user.id)"
:disabled="savingId === user.id"
class="text-sm text-indigo-600 hover:text-indigo-700 font-semibold disabled:opacity-50 flex items-center gap-1"
class="text-sm text-indigo-600 hover:text-indigo-700 font-semibold disabled:opacity-50 flex items-center gap-1 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 rounded"
>
<span v-if="savingId === user.id" class="animate-spin rounded-full border-2 border-current border-t-transparent w-3 h-3"></span>
{{ savingId === user.id ? 'Saving' : 'Save' }}
+6 -6
View File
@@ -44,14 +44,14 @@
</div>
<button
@click="applyFilters"
class="bg-indigo-600 text-white text-sm px-4 py-2 rounded-lg hover:bg-indigo-700 transition-colors"
class="bg-indigo-600 text-white text-sm px-4 py-2 rounded-lg hover:bg-indigo-700 active:bg-indigo-800 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
>
Apply filters
</button>
<button
v-if="activeFilterCount > 0"
@click="clearFilters"
class="border border-gray-300 text-gray-500 text-sm px-4 py-2 rounded-lg hover:bg-gray-50 transition-colors"
class="border border-gray-300 text-gray-500 text-sm px-4 py-2 rounded-lg hover:bg-gray-50 active:bg-gray-100 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
>
Clear filters
</button>
@@ -59,7 +59,7 @@
<button
@click="exportCsv"
:disabled="exportingCsv"
class="border border-gray-300 text-gray-700 text-sm px-4 py-2 rounded-lg hover:bg-gray-50 transition-colors disabled:opacity-50"
class="border border-gray-300 text-gray-700 text-sm px-4 py-2 rounded-lg hover:bg-gray-50 active:bg-gray-100 transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
>
<span v-if="exportingCsv" class="flex items-center gap-1">
<span class="animate-spin rounded-full border-2 border-current border-t-transparent w-3 h-3"></span>
@@ -141,7 +141,7 @@
<button
@click="prevPage"
:disabled="page <= 1"
class="text-sm text-gray-600 hover:text-gray-900 disabled:opacity-40 disabled:cursor-not-allowed px-3 py-1.5 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
class="text-sm text-gray-600 hover:text-gray-900 disabled:opacity-40 disabled:cursor-not-allowed px-3 py-1.5 border border-gray-200 rounded-lg hover:bg-gray-50 active:bg-gray-100 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
>
Previous
</button>
@@ -149,7 +149,7 @@
<button
@click="nextPage"
:disabled="page * perPage >= total"
class="text-sm text-gray-600 hover:text-gray-900 disabled:opacity-40 disabled:cursor-not-allowed px-3 py-1.5 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
class="text-sm text-gray-600 hover:text-gray-900 disabled:opacity-40 disabled:cursor-not-allowed px-3 py-1.5 border border-gray-200 rounded-lg hover:bg-gray-50 active:bg-gray-100 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
>
Next
</button>
@@ -183,7 +183,7 @@
<button
@click="downloadDailyExport"
:disabled="!selectedExportDate || downloadingExport"
class="bg-indigo-600 hover:bg-indigo-700 text-white text-sm px-4 py-2 rounded-lg disabled:opacity-50 transition-colors flex items-center gap-1"
class="bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800 text-white text-sm px-4 py-2 rounded-lg disabled:opacity-50 transition-colors flex items-center gap-1 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
>
<span v-if="downloadingExport" class="animate-spin rounded-full border-2 border-current border-t-transparent w-3 h-3"></span>
Download
@@ -2,7 +2,7 @@
<div>
<BreadcrumbBar :segments="[]" :show-root="false" class="mb-4" />
<h2 class="text-xl font-semibold text-gray-900 mb-6">Overview</h2>
<h2 class="text-2xl font-semibold text-gray-900 mb-6">Overview</h2>
<div v-if="loading" class="text-gray-500">Loading overview</div>
+3 -3
View File
@@ -55,7 +55,7 @@
<button
@click="submitCreate"
:disabled="creating"
class="bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-semibold px-4 py-2 rounded-lg disabled:opacity-50 transition-colors"
class="bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800 text-white text-sm font-semibold px-4 py-2 rounded-lg disabled:opacity-50 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
>
<span v-if="creating" class="flex items-center gap-1.5">
<span class="animate-spin rounded-full border-2 border-current border-t-transparent w-4 h-4"></span>
@@ -65,7 +65,7 @@
</button>
<button
@click="cancelCreate"
class="text-sm px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
class="text-sm px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 active:bg-gray-100 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
>
Cancel
</button>
@@ -78,7 +78,7 @@
<button
v-if="!showCreateForm"
@click="openCreateForm"
class="bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-semibold px-4 py-2 rounded-lg transition-colors"
class="bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800 text-white text-sm font-semibold px-4 py-2 rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
>
Create user
</button>