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:
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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()
|
||||
})
|
||||
})
|
||||
@@ -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' }}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user