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 <button
@click="signOut" @click="signOut"
aria-label="Sign out" 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" /> <AppIcon name="logout" class="w-4 h-4" />
</button> </button>
@@ -86,7 +86,7 @@ async function signOut() {
<style scoped> <style scoped>
.nav-link { .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 { .nav-link-active {
@apply bg-indigo-50 text-indigo-700; @apply bg-indigo-50 text-indigo-700;
@@ -1,7 +1,7 @@
<template> <template>
<div class="space-y-4"> <div class="space-y-4">
<div> <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"> <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. Store these codes somewhere safe. Each can only be used once if you lose access to your authenticator app.
</p> </p>
@@ -22,7 +22,7 @@
<button <button
type="button" type="button"
@click="copyAll" @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 --> <!-- 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"> <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" type="button"
@click="acknowledged && $emit('acknowledged')" @click="acknowledged && $emit('acknowledged')"
:disabled="!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' : ''" :class="!acknowledged ? 'opacity-50 cursor-not-allowed' : ''"
> >
Enable two-factor authentication 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"> <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 --> <!-- Header -->
<div class="flex items-center justify-between mb-5"> <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 }} {{ existing ? 'Edit' : 'Connect' }} {{ provider?.label }}
</h3> </h3>
<button <button
@click="close" @click="close"
aria-label="Close modal" 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" /> <AppIcon name="x" class="w-5 h-5" />
</button> </button>
@@ -62,7 +62,7 @@
ref="pickerTriggerEl" ref="pickerTriggerEl"
@click.stop="toggleFolderPicker" @click.stop="toggleFolderPicker"
aria-label="Move to folder" 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" /> <AppIcon name="folderMove" class="w-4 h-4" />
</button> </button>
@@ -71,7 +71,7 @@
<button <button
@click.stop="openShareModal" @click.stop="openShareModal"
aria-label="Share document" 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" /> <AppIcon name="share" class="w-4 h-4" />
</button> </button>
@@ -98,13 +98,13 @@
@click.stop @click.stop
> >
<button <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)" @click.stop="moveToFolder(null)"
>Root (no folder)</button> >Root (no folder)</button>
<button <button
v-for="folder in allFolders" v-for="folder in allFolders"
:key="folder.id" :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)" @click.stop="moveToFolder(folder.id)"
>{{ folder.name }}</button> >{{ folder.name }}</button>
<p v-if="!allFolders.length" class="px-3 py-2 text-xs text-gray-400">No folders yet</p> <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"> <div class="flex items-center gap-0.5">
<button <button
@click="foldersExpanded = !foldersExpanded" @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'" :title="foldersExpanded ? 'Collapse folders' : 'Expand folders'"
> >
<AppIcon <AppIcon
@@ -60,7 +60,7 @@
<div v-if="loadingRoots" class="pl-7 py-1 space-y-1"> <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 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="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>
</div> </div>
<EmptyState <EmptyState
@@ -83,7 +83,7 @@
<div class="flex items-center gap-0.5"> <div class="flex items-center gap-0.5">
<button <button
@click="cloudExpanded = !cloudExpanded" @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'" :title="cloudExpanded ? 'Collapse cloud storage' : 'Expand cloud storage'"
> >
<AppIcon <AppIcon
@@ -107,7 +107,7 @@
<div v-if="loadingCloudConnections" class="pl-7 py-1 space-y-1"> <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 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="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>
</div> </div>
<EmptyState <EmptyState
@@ -135,7 +135,7 @@
<div v-if="topicsStore.loading" class="px-3 py-1 space-y-1"> <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 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="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>
</div> </div>
<EmptyState <EmptyState
@@ -192,7 +192,7 @@
<button <button
@click="signOut" @click="signOut"
aria-label="Sign out" 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" /> <AppIcon name="logout" class="w-4 h-4" />
</button> </button>
@@ -257,7 +257,7 @@ async function signOut() {
<style scoped> <style scoped>
.nav-link { .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 { .nav-link-active {
@apply bg-indigo-50 text-indigo-700; @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 --> <!-- 1. Account information -->
<section class="bg-white border border-gray-200 rounded-xl p-6"> <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 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">Email:</span> {{ authStore.user?.email }}</div>
<div><span class="text-gray-500">Username:</span> @{{ authStore.user?.handle }}</div> <div><span class="text-gray-500">Username:</span> @{{ authStore.user?.handle }}</div>
@@ -23,7 +23,7 @@
<!-- 2. Two-factor authentication --> <!-- 2. Two-factor authentication -->
<section class="bg-white border border-gray-200 rounded-xl p-6"> <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 --> <!-- TOTP enabled: show status + disable option -->
<template v-if="authStore.user?.totp_enabled"> <template v-if="authStore.user?.totp_enabled">
@@ -39,7 +39,7 @@
<button <button
type="button" type="button"
@click="confirmDisable2fa = true" @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 Disable 2FA
</button> </button>
@@ -64,7 +64,7 @@
<!-- 3. Change password --> <!-- 3. Change password -->
<section class="bg-white border border-gray-200 rounded-xl p-6"> <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"> <form @submit.prevent="changePassword" class="space-y-4 max-w-sm">
<div> <div>
@@ -125,13 +125,13 @@
<!-- 4. Sessions sign out all devices --> <!-- 4. Sessions sign out all devices -->
<section class="bg-white border border-gray-200 rounded-xl p-6"> <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"> <template v-if="!confirmSignOutAll">
<button <button
type="button" type="button"
@click="confirmSignOutAll = true" @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 Sign out all devices
</button> </button>
@@ -1,6 +1,6 @@
<template> <template>
<section class="bg-white border border-gray-200 rounded-xl p-6"> <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"> <p class="text-sm text-gray-600">
AI provider and model are managed by your administrator. Contact your admin AI provider and model are managed by your administrator. Contact your admin
to request changes to which AI provider is used for your documents. to request changes to which AI provider is used for your documents.
@@ -1,7 +1,7 @@
<template> <template>
<div> <div>
<section class="bg-white border border-gray-200 rounded-xl p-6"> <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> <p class="text-sm text-gray-600 mb-5">Connect a cloud storage provider to use as a document destination.</p>
<!-- Loading state --> <!-- Loading state -->
@@ -52,7 +52,7 @@
<template v-if="!connectionFor(provider.key)"> <template v-if="!connectionFor(provider.key)">
<button <button
@click="handleConnect(provider)" @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 }} Connect {{ provider.label }}
</button> </button>
@@ -63,14 +63,14 @@
<button <button
v-if="!OAUTH_PROVIDERS.has(provider.key) && confirmRemoveId !== connectionFor(provider.key)?.id" v-if="!OAUTH_PROVIDERS.has(provider.key) && confirmRemoveId !== connectionFor(provider.key)?.id"
@click="handleEdit(provider)" @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 Edit
</button> </button>
<button <button
v-if="confirmRemoveId !== connectionFor(provider.key)?.id" v-if="confirmRemoveId !== connectionFor(provider.key)?.id"
@click="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 }} Remove {{ provider.label }}
</button> </button>
@@ -90,14 +90,14 @@
<template v-else-if="connectionFor(provider.key)?.status === 'REQUIRES_REAUTH'"> <template v-else-if="connectionFor(provider.key)?.status === 'REQUIRES_REAUTH'">
<button <button
@click="handleConnect(provider)" @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 }} Reconnect {{ provider.label }}
</button> </button>
<button <button
v-if="confirmRemoveId !== connectionFor(provider.key)?.id" v-if="confirmRemoveId !== connectionFor(provider.key)?.id"
@click="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 }} Remove {{ provider.label }}
</button> </button>
@@ -118,14 +118,14 @@
<button <button
v-if="!OAUTH_PROVIDERS.has(provider.key) && confirmRemoveId !== connectionFor(provider.key)?.id" v-if="!OAUTH_PROVIDERS.has(provider.key) && confirmRemoveId !== connectionFor(provider.key)?.id"
@click="handleEdit(provider)" @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 Edit
</button> </button>
<button <button
v-if="confirmRemoveId !== connectionFor(provider.key)?.id" v-if="confirmRemoveId !== connectionFor(provider.key)?.id"
@click="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 }} Remove {{ provider.label }}
</button> </button>
@@ -162,7 +162,7 @@
<button <button
v-if="!showDisconnectAll" v-if="!showDisconnectAll"
@click="showDisconnectAll = true" @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 Disconnect all cloud storage
</button> </button>
@@ -1,6 +1,6 @@
<template> <template>
<section class="bg-white border border-gray-200 rounded-xl p-6"> <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> <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"> <div class="space-y-3">
@@ -19,7 +19,7 @@
<button <button
v-if="mode === 'local'" v-if="mode === 'local'"
@click="$emit('new-folder')" @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" /> <AppIcon name="plus" class="w-4 h-4" />
New folder New folder
@@ -100,11 +100,11 @@
<div class="flex justify-end gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity" @click.stop> <div class="flex justify-end gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity" @click.stop>
<template v-if="mode === 'local'"> <template v-if="mode === 'local'">
<button @click.stop="startRename(folder)" title="Rename" <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" /> <AppIcon name="pencil" class="w-3.5 h-3.5" />
</button> </button>
<button @click.stop="$emit('folder-delete', folder)" title="Delete folder" <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" /> <AppIcon name="trash" class="w-3.5 h-3.5" />
</button> </button>
</template> </template>
@@ -147,20 +147,20 @@
<div class="flex justify-end gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity" @click.stop> <div class="flex justify-end gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity" @click.stop>
<template v-if="mode === 'local'"> <template v-if="mode === 'local'">
<button @click.stop="$emit('file-share', file)" title="Share" <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" /> <AppIcon name="share" class="w-3.5 h-3.5" />
</button> </button>
<div class="relative"> <div class="relative">
<button <button
@click.stop="openFolderPicker(file.id, $event)" @click.stop="openFolderPicker(file.id, $event)"
title="Move to folder" 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" /> <AppIcon name="folderMove" class="w-3.5 h-3.5" />
</button> </button>
</div> </div>
<button @click.stop="$emit('file-delete', file.id)" title="Delete" <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" /> <AppIcon name="trash" class="w-3.5 h-3.5" />
</button> </button>
</template> </template>
@@ -221,7 +221,7 @@
@click.stop @click.stop
> >
<button <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" @click.stop="$emit('file-move', { fileId: folderPickerFileId, folderId: null }); folderPickerFileId = null"
> >
Root (no folder) Root (no folder)
@@ -229,7 +229,7 @@
<button <button
v-for="f in rootFolders" v-for="f in rootFolders"
:key="f.id" :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" @click.stop="$emit('file-move', { fileId: folderPickerFileId, folderId: f.id }); folderPickerFileId = null"
>{{ f.name }}</button> >{{ f.name }}</button>
<p v-if="!rootFolders.length" class="px-3 py-2 text-xs text-gray-400">No folders yet</p> <p v-if="!rootFolders.length" class="px-3 py-2 text-xs text-gray-400">No folders yet</p>
@@ -17,7 +17,7 @@
/> />
<button <button
type="submit" 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" :disabled="saving"
> >
{{ saving ? 'Adding…' : 'Add' }} {{ saving ? 'Adding…' : 'Add' }}
@@ -50,8 +50,8 @@
placeholder="Description" 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" 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="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">Cancel</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>
<div v-else class="flex-1 min-w-0"> <div v-else class="flex-1 min-w-0">
@@ -63,8 +63,8 @@
</div> </div>
<div class="flex gap-2 shrink-0"> <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="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">Delete</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>
</div> </div>
+10 -10
View File
@@ -1,7 +1,7 @@
<template> <template>
<div class="p-8 max-w-4xl mx-auto"> <div class="p-8 max-w-4xl mx-auto">
<!-- Back --> <!-- 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 Back
</button> </button>
@@ -12,7 +12,7 @@
<!-- Header --> <!-- Header -->
<div class="flex items-start justify-between gap-4 mb-6"> <div class="flex items-start justify-between gap-4 mb-6">
<div> <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"> <p class="text-sm text-gray-400 mt-1">
Uploaded {{ formatDate(doc.created_at) }} · {{ formatSize(doc.size_bytes) }} · {{ doc.mime_type }} Uploaded {{ formatDate(doc.created_at) }} · {{ formatSize(doc.size_bytes) }} · {{ doc.mime_type }}
</p> </p>
@@ -22,13 +22,13 @@
<button <button
v-if="isPdf" v-if="isPdf"
@click="openPdf" @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' }} {{ pdfOpenMode === 'in_app' ? 'Preview' : 'Open' }}
</button> </button>
<button <button
@click="confirmDelete" @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> >Delete</button>
</div> </div>
</div> </div>
@@ -36,19 +36,19 @@
<!-- Topics --> <!-- Topics -->
<div class="bg-white border border-gray-200 rounded-xl p-5 mb-5"> <div class="bg-white border border-gray-200 rounded-xl p-5 mb-5">
<div class="flex items-center justify-between mb-3"> <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"> <div class="flex gap-2">
<button <button
@click="reclassify" @click="reclassify"
:disabled="classifying" :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' }} {{ classifying ? 'Classifying…' : 'Re-classify' }}
</button> </button>
<button <button
@click="suggestTopics" @click="suggestTopics"
:disabled="suggesting" :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' }} {{ suggesting ? 'Suggesting…' : 'Suggest Topics' }}
</button> </button>
@@ -97,7 +97,7 @@
<!-- Extracted text --> <!-- Extracted text -->
<div class="bg-white border border-gray-200 rounded-xl p-5"> <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> <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> </div>
</template> </template>
@@ -132,11 +132,11 @@
<div class="flex gap-2 justify-end"> <div class="flex gap-2 justify-end">
<button <button
@click="cancelCloudDeleteWarning" @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> >Cancel</button>
<button <button
@click="confirmRemoveOnly" @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> >Remove from app</button>
</div> </div>
</div> </div>
+3 -3
View File
@@ -18,7 +18,7 @@
<button <button
@click="oauthSuccessProvider = null" @click="oauthSuccessProvider = null"
aria-label="Dismiss notification" 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" /> <AppIcon name="x" class="w-4 h-4" />
</button> </button>
@@ -30,7 +30,7 @@
v-for="tab in tabs" v-for="tab in tabs"
:key="tab.id" :key="tab.id"
@click="activeTab = 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 :class="activeTab === tab.id
? 'text-indigo-600 border-indigo-600' ? 'text-indigo-600 border-indigo-600'
: 'text-gray-500 hover:text-gray-700 border-transparent'" : 'text-gray-500 hover:text-gray-700 border-transparent'"
@@ -64,7 +64,7 @@
<button <button
@click="oauthError = null" @click="oauthError = null"
aria-label="Dismiss error" 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" /> <AppIcon name="x" class="w-4 h-4" />
</button> </button>
+1 -1
View File
@@ -2,7 +2,7 @@
<div class="p-8 max-w-4xl mx-auto"> <div class="p-8 max-w-4xl mx-auto">
<BreadcrumbBar :segments="[{ label: 'Shared with me' }]" :show-root="false" class="mb-4" /> <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> <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> <div v-if="loading" class="text-sm text-gray-400">Loading</div>
+3 -3
View File
@@ -3,7 +3,7 @@
<!-- Header --> <!-- Header -->
<div class="flex items-center justify-between mb-6"> <div class="flex items-center justify-between mb-6">
<div> <div>
<h2 class="text-2xl font-bold text-gray-900"> <h2 class="text-2xl font-semibold text-gray-900">
{{ activeTopic ? activeTopic : 'All Topics' }} {{ activeTopic ? activeTopic : 'All Topics' }}
</h2> </h2>
<p class="text-gray-500 text-sm mt-0.5"> <p class="text-gray-500 text-sm mt-0.5">
@@ -13,7 +13,7 @@
<button <button
v-if="activeTopic" v-if="activeTopic"
@click="$router.push('/topics')" @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 All Topics
</button> </button>
@@ -31,7 +31,7 @@
v-for="topic in topicsStore.topics" v-for="topic in topicsStore.topics"
:key="topic.id" :key="topic.id"
:to="`/topics/${encodeURIComponent(topic.name)}`" :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"> <div class="flex items-center gap-2 mb-2">
<span class="w-3 h-3 rounded-full" :style="{ backgroundColor: topic.color }"></span> <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' : '']" :class="['transition-colors', prov.is_active ? 'border-l-4 border-l-green-500' : '']"
> >
<button <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)" @click="toggleProvider(prov.provider_id)"
> >
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
@@ -91,7 +91,7 @@
<button <button
@click="saveSystemProvider(prov.provider_id, { activate: true })" @click="saveSystemProvider(prov.provider_id, { activate: true })"
:disabled="savingProvider === prov.provider_id || prov.is_active" :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 Set Active
</button> </button>
@@ -99,7 +99,7 @@
<button <button
@click="saveSystemProvider(prov.provider_id)" @click="saveSystemProvider(prov.provider_id)"
:disabled="savingProvider === 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> <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' }} {{ savingProvider === prov.provider_id ? 'Saving' : 'Save' }}
@@ -108,7 +108,7 @@
<button <button
@click="runTestConnection(prov.provider_id)" @click="runTestConnection(prov.provider_id)"
:disabled="testingProvider === prov.provider_id || savingProvider === 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 <span
v-if="testingProvider === prov.provider_id" v-if="testingProvider === prov.provider_id"
@@ -178,7 +178,7 @@
<button <button
@click="saveConfig(user.id)" @click="saveConfig(user.id)"
:disabled="savingId === 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> <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' }} {{ savingId === user.id ? 'Saving' : 'Save' }}
+6 -6
View File
@@ -44,14 +44,14 @@
</div> </div>
<button <button
@click="applyFilters" @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 Apply filters
</button> </button>
<button <button
v-if="activeFilterCount > 0" v-if="activeFilterCount > 0"
@click="clearFilters" @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 Clear filters
</button> </button>
@@ -59,7 +59,7 @@
<button <button
@click="exportCsv" @click="exportCsv"
:disabled="exportingCsv" :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 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> <span class="animate-spin rounded-full border-2 border-current border-t-transparent w-3 h-3"></span>
@@ -141,7 +141,7 @@
<button <button
@click="prevPage" @click="prevPage"
:disabled="page <= 1" :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 Previous
</button> </button>
@@ -149,7 +149,7 @@
<button <button
@click="nextPage" @click="nextPage"
:disabled="page * perPage >= total" :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 Next
</button> </button>
@@ -183,7 +183,7 @@
<button <button
@click="downloadDailyExport" @click="downloadDailyExport"
:disabled="!selectedExportDate || downloadingExport" :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> <span v-if="downloadingExport" class="animate-spin rounded-full border-2 border-current border-t-transparent w-3 h-3"></span>
Download Download
@@ -2,7 +2,7 @@
<div> <div>
<BreadcrumbBar :segments="[]" :show-root="false" class="mb-4" /> <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> <div v-if="loading" class="text-gray-500">Loading overview</div>
+3 -3
View File
@@ -55,7 +55,7 @@
<button <button
@click="submitCreate" @click="submitCreate"
:disabled="creating" :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 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> <span class="animate-spin rounded-full border-2 border-current border-t-transparent w-4 h-4"></span>
@@ -65,7 +65,7 @@
</button> </button>
<button <button
@click="cancelCreate" @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 Cancel
</button> </button>
@@ -78,7 +78,7 @@
<button <button
v-if="!showCreateForm" v-if="!showCreateForm"
@click="openCreateForm" @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 Create user
</button> </button>