diff --git a/.planning/research/ARCHITECTURE.md b/.planning/research/ARCHITECTURE.md
index f5f44e0..b2b7a05 100644
--- a/.planning/research/ARCHITECTURE.md
+++ b/.planning/research/ARCHITECTURE.md
@@ -1,508 +1,91 @@
-# Architecture Patterns
+# v0.3 Research: Architecture
-**Domain:** v0.2 UI Overhaul and Optimization — integration analysis for existing DocuVault codebase
-**Researched:** 2026-06-07
+**Milestone:** v0.3 Reimagining Cloud Storage integration
+**Date:** 2026-06-17
----
+## Target Model
-## How the Layout System Currently Works
+Cloud files become virtual documents: provider-owned bytes remain in the connected storage, while DocuVault stores metadata, extracted text, topics, embeddings/search data, status, and a bounded cache of temporary bytes.
-App.vue is the layout switch. It checks `route.meta.layout` and renders one of two branches:
+## Proposed Components
-```
-route.meta.layout === 'auth' → (renders inside a centered card)
-(anything else) → AppSidebar +
-```
+### Provider Capability Layer
-There are currently only two layouts:
-- **AuthLayout.vue** — centered card, no sidebar, used for login/register/password-reset
-- **Implicit main layout** — App.vue's `v-else` branch: `` + scrollable main
+Add a cloud file operation interface separate from the current object-storage `StorageBackend` contract:
-The admin panel (`/admin`) currently falls into the `v-else` branch. AdminView.vue renders inside the main user sidebar, inheriting AppSidebar's folder tree, quota bar, and user nav — none of which are relevant to an admin.
+- `list_folder`
+- `get_metadata`
+- `download_bytes` / streaming equivalent
+- `upload_file`
+- `rename_item`
+- `move_item`
+- `delete_item`
+- `create_folder`
+- `get_change_cursor` / `list_changes` where available
+- `capabilities()` returning action support and limitations
----
+This avoids forcing local/MinIO object semantics onto provider file-manager behavior.
-## Integration Point 1: FastAPI Admin Router Decomposition
+### Cloud Item Index
-### Current state
+Add durable tables for cloud-visible items and analysis/index state:
-`backend/api/admin.py` is a single file with `router = APIRouter(prefix="/api/admin")`. It is registered in `main.py` as:
+- `cloud_items`: user, connection, provider, provider item id/path, parent id/path, name, kind, MIME/content type, size, etag/version, mtime, path snapshot, last_seen_at, deleted_at.
+- `cloud_item_analysis`: item, status, extracted_text, topic links, semantic index status, last_analyzed_etag, error, job id, timestamps.
+- Optional `cloud_file_cache`: item, etag/version, byte location, size, last_accessed_at, expires_at, pin reason, eviction state.
-```python
-from api.admin import router as admin_router
-app.include_router(admin_router)
-```
+Keep `documents` for DocuVault-owned local/imported files unless a later design intentionally merges local and cloud into one polymorphic table.
-The file mixes four concern areas:
-- User management (CRUD on users, status, password reset)
-- Quota management (get/patch quota per user)
-- AI provider configuration (system-level: get, put, test-connection, get-models)
-- Audit log was already extracted to `api/audit.py` in v0.1
+### API Shape
-### Target structure
+Split `backend/api/cloud.py` when implementation starts:
-```
-backend/api/admin/
- __init__.py
- users.py — /api/admin/users endpoints
- quotas.py — /api/admin/users/{id}/quota endpoints
- ai.py — /api/admin/ai-config endpoints
-```
+- `api/cloud/connections.py`
+- `api/cloud/browse.py`
+- `api/cloud/actions.py`
+- `api/cloud/analysis.py`
+- `api/cloud/search.py` or reuse a shared search API
+- shared schemas in `api/schemas.py` or `api/cloud/schemas.py`
-### How to register without changing URL prefix
+Every endpoint must assert `connection.user_id == current_user.id` and `cloud_item.user_id == current_user.id`.
-FastAPI supports two-level include: a parent router with the shared prefix, sub-routers without prefix. This is the correct pattern:
+### Frontend Shape
-```python
-# backend/api/admin/__init__.py
-from fastapi import APIRouter
-from .users import router as users_router
-from .quotas import router as quotas_router
-from .ai import router as ai_router
+`FileManagerView` and `CloudFolderView` remain thin data providers. `StorageBrowser.vue` becomes capability-aware:
-router = APIRouter(prefix="/api/admin", tags=["admin"])
-router.include_router(users_router)
-router.include_router(quotas_router)
-router.include_router(ai_router)
-```
+- accepts `capabilities` prop
+- shows action buttons based on capabilities
+- emits generic events for open/upload/rename/move/delete/analyze
+- displays status badges for cached/indexed/analyzing/stale/error
-Each sub-router declares its routes WITHOUT a prefix (no `prefix=` arg):
+Cloud-specific stores own provider state and API calls; `StorageBrowser` stays store-agnostic.
-```python
-# backend/api/admin/users.py
-router = APIRouter()
+### Byte Cache and Offload
-@router.get("/users") # resolves to /api/admin/users
-@router.post("/users") # resolves to /api/admin/users
-@router.patch("/users/{id}/status")
-...
-```
+Cache flow:
-`main.py` changes from:
+1. User opens/previews/analyzes cloud item.
+2. Backend verifies ownership and provider metadata.
+3. Backend downloads bytes into a bounded cache or streams through memory if small.
+4. Extraction/preview reads from cache.
+5. Cache entry records item id, etag/version, byte size, last access, expiry.
+6. Eviction removes bytes once unpinned and stale or over budget.
-```python
-from api.admin import router as admin_router
-app.include_router(admin_router)
-```
+The cache is not the source of truth. Provider metadata and extracted/indexed data are.
-to the identical line — because `api/admin/__init__.py` exports `router` with the same name. **No URL changes. No consumer changes.**
+### Analysis Flow
-### What moves where
+1. User selects file/folder/provider and clicks Analyze.
+2. Backend resolves selection into cloud items, stores/updates metadata rows, and enqueues Celery jobs.
+3. Jobs hydrate bytes as needed, extract text, classify topics, optionally create embeddings, and persist analysis state.
+4. Search reads only persisted text/vector/topic metadata; it should not download cloud bytes during query.
+5. Opening a result hydrates bytes on demand if preview/download needs them.
-| Endpoints | Target file | Pydantic models that move with it |
-|---|---|---|
-| GET/POST /users, PATCH /users/{id}/status, POST /users/{id}/password-reset, DELETE /users/{id} | `admin/users.py` | `UserCreate`, `UserStatusUpdate`, `UserDeleteConfirm`, `_user_to_dict` |
-| GET/PATCH /users/{id}/quota, PATCH /users/{id}/ai-config | `admin/quotas.py` | `QuotaUpdate`, `UserAiConfigUpdate` |
-| GET/PUT /ai-config, POST /ai-config/test-connection, GET /ai-config/models | `admin/ai.py` | `SystemAiConfigUpdate`, `TestConnectionRequest`, `_ai_config_to_dict` |
+## Build Order
-`POST /topics` (create system topic) stays in `admin/users.py` — it is short and has no unique models. Alternatively it can go in its own `admin/topics.py`; a decision either way is fine.
-
-### Shared helpers
-
-`_DEFAULT_QUOTA_BYTES`, `CloudConnectionOut`, and any imports shared across sub-routers can live in `backend/api/admin/shared.py` and be imported by each sub-router. Do not duplicate them.
-
-### Files changed
-
-| File | Change type |
-|---|---|
-| `backend/api/admin.py` | DELETED — replaced by package |
-| `backend/api/admin/__init__.py` | CREATED — aggregating router |
-| `backend/api/admin/users.py` | CREATED |
-| `backend/api/admin/quotas.py` | CREATED |
-| `backend/api/admin/ai.py` | CREATED |
-| `backend/api/admin/shared.py` | CREATED (optional, holds shared constants/helpers) |
-| `backend/main.py` | NO CHANGE — import path `api.admin.router` remains valid |
-
----
-
-## Integration Point 2: Vue Admin Layout
-
-### Current layout switch
-
-`App.vue`:
-
-```html
-
-
-```
-
-### How to add AdminLayout without breaking anything
-
-Extend the `v-if` chain to a three-way switch using `route.meta.layout`:
-
-```html
-
-
-
-```
-
-`AdminLayout.vue` renders its own sidebar and a ` `. It is structurally identical in shape to the main layout but with admin-specific nav items.
-
-### Router changes
-
-Current single admin route:
-
-```js
-{ path: '/admin', component: () => import('../views/AdminView.vue'), meta: { requiresAdmin: true } }
-```
-
-Replace with a route subtree:
-
-```js
-{
- path: '/admin',
- component: AdminLayout,
- meta: { requiresAdmin: true, layout: 'admin' },
- children: [
- { path: '', redirect: '/admin/users' },
- { path: 'users', component: () => import('../views/admin/AdminUsersView.vue') },
- { path: 'quotas', component: () => import('../views/admin/AdminQuotasView.vue') },
- { path: 'ai', component: () => import('../views/admin/AdminAiView.vue') },
- { path: 'audit', component: () => import('../views/admin/AdminAuditView.vue') },
- ],
-}
-```
-
-### Meta inheritance caveat
-
-Vue Router 4 does NOT automatically merge `meta` from parent to children — `route.meta` on a child route only contains what is declared on that child. `App.vue` currently reads `route.meta.layout` directly. For the child routes to inherit `layout: 'admin'`, the check in App.vue must walk `route.matched`:
-
-```js
-// App.vue — layout resolution
-const layout = computed(() => route.matched.find(r => r.meta.layout)?.meta.layout)
-```
-
-Likewise, the navigation guard in `router/index.js` must change from `to.meta.requiresAdmin` to `to.matched.some(r => r.meta.requiresAdmin)`. The current guard already handles `.meta.public` the same way — so this is a one-line change pattern that's already established in the router.
-
-### AdminLayout.vue structure
-
-```
-AdminLayout.vue
- └── AdminSidebar.vue (nav links: Users, Quotas, AI Config, Audit Log)
- └──
-```
-
-`AdminSidebar.vue` is NOT `AppSidebar.vue`. Do not reuse or extend `AppSidebar`. The admin sidebar is a new, independent component with admin-specific nav.
-
-### Views created
-
-Each current tab component becomes a thin view wrapper (following the View -> Smart -> Presentational hierarchy):
-
-| New file | Wraps existing component |
-|---|---|
-| `views/admin/AdminUsersView.vue` | `AdminUsersTab.vue` (kept unchanged as smart component) |
-| `views/admin/AdminQuotasView.vue` | `AdminQuotasTab.vue` |
-| `views/admin/AdminAiView.vue` | `AdminAiConfigTab.vue` |
-| `views/admin/AdminAuditView.vue` | `AuditLogTab.vue` |
-
-The existing tab components (`AdminUsersTab.vue`, `AdminQuotasTab.vue`, etc.) are NOT renamed or restructured in this step. They become the smart components rendered inside the new thin view wrappers.
-
-### Files changed
-
-| File | Change type |
-|---|---|
-| `frontend/src/App.vue` | MODIFIED — add `v-else-if` for admin layout; meta layout resolution uses `route.matched` |
-| `frontend/src/router/index.js` | MODIFIED — /admin replaced with children subtree; guard uses `to.matched` |
-| `frontend/src/layouts/AdminLayout.vue` | CREATED |
-| `frontend/src/components/layout/AdminSidebar.vue` | CREATED |
-| `frontend/src/views/AdminView.vue` | DELETED — replaced by subtree |
-| `frontend/src/views/admin/AdminUsersView.vue` | CREATED (thin wrapper) |
-| `frontend/src/views/admin/AdminQuotasView.vue` | CREATED (thin wrapper) |
-| `frontend/src/views/admin/AdminAiView.vue` | CREATED (thin wrapper) |
-| `frontend/src/views/admin/AdminAuditView.vue` | CREATED (thin wrapper) |
-| `frontend/src/components/admin/AdminUsersTab.vue` | KEPT (smart component, unchanged) |
-| `frontend/src/components/admin/AdminQuotasTab.vue` | KEPT |
-| `frontend/src/components/admin/AdminAiConfigTab.vue` | KEPT |
-| `frontend/src/components/admin/AuditLogTab.vue` | KEPT |
-
----
-
-## Integration Point 3: client.js Decomposition
-
-### Current import pattern
-
-Every consumer does one of:
-
-```js
-import * as api from '../api/client.js' // namespace import — stores, views, most components
-import { specificFn } from '../api/client.js' // named import — a few components
-```
-
-35+ files import from `client.js`. A decomposition that changes the import path in all 35 files is a large, purely mechanical refactor with meaningful test/review cost for zero user-visible benefit.
-
-### Minimal, non-breaking approach: barrel re-export
-
-Create domain modules alongside `client.js`, then have `client.js` re-export everything so no consumer changes:
-
-```
-frontend/src/api/
- client.js — request() transport function + re-exports from domain files
- documents.js — document API calls (import request from './client.js')
- topics.js
- auth.js
- admin.js
- folders.js
- shares.js
- cloud.js
-```
-
-`client.js` becomes the HTTP transport layer and a re-export barrel:
-
-```js
-// client.js (after decomposition) — transport only + re-exports
-async function request(path, options = {}) { ... }
-export async function fetchDocumentContent(docId, options = {}) { ... }
-
-export * from './documents.js'
-export * from './topics.js'
-export * from './auth.js'
-export * from './admin.js'
-export * from './folders.js'
-export * from './shares.js'
-export * from './cloud.js'
-```
-
-Each domain file imports `request` from `client.js`:
-
-```js
-// documents.js
-import { request } from './client.js'
-export function listDocuments(...) { return request('/api/documents?...') }
-```
-
-This means `import * as api from '../api/client.js'` continues to work for all 35 existing consumers. The decomposition is fully internal.
-
-### Test mock compatibility
-
-Existing Vitest tests mock `api/client.js` at the module level (`vi.mock('../../api/client.js')`). Because the re-exports are in `client.js`, mocking `client.js` still intercepts all domain functions. No test changes needed.
-
-### Files changed
-
-| File | Change type |
-|---|---|
-| `frontend/src/api/client.js` | MODIFIED — split out domain functions, keep re-exports |
-| `frontend/src/api/documents.js` | CREATED |
-| `frontend/src/api/topics.js` | CREATED |
-| `frontend/src/api/auth.js` | CREATED (name does not conflict with `stores/auth.js` — different path) |
-| `frontend/src/api/admin.js` | CREATED |
-| `frontend/src/api/folders.js` | CREATED |
-| `frontend/src/api/shares.js` | CREATED |
-| `frontend/src/api/cloud.js` | CREATED |
-| All 35 consumer files | NO CHANGE |
-
----
-
-## Integration Point 4: Icon Strategy
-
-### Current state
-
-There are 58 inline `` blocks in `frontend/src/components/` and 8 more in `frontend/src/views/`. Each is a 3-6 line block with `stroke`, `viewBox`, `class`, and a ``. The same path data appears multiple times for the same icon (e.g., the document icon in the sidebar, document card, and document view are separate copies of the same path string).
-
-### Options assessed
-
-**Option A: SVG sprite sheet**
-A ``-based sprite in `index.html` with ` ` in components. Minimal bundle impact, no component overhead. Drawback: requires a build step or manual maintenance of the sprite file; poor DX for adding new icons during the visual redesign.
-
-**Option B: `AppIcon.vue` component with a `name` prop**
-A Vue component that maps `name` to an inline `` block. All SVG paths live in one file. Zero external dependencies. Easy to add icons.
-
-**Option C: lucide-vue-next**
-Purpose-built icon library for Tailwind projects. Tree-shakeable individual icon components: ` `. Maintained, typed, comprehensive. Avoids maintaining path data entirely.
-
-**Recommendation: Option B (`AppIcon.vue`) unless a dependency is explicitly approved.**
-
-The project already has 66 SVG paths hand-chosen to match the visual style. Switching to lucide means auditing that every lucide icon matches the expected visual. During a visual redesign milestone that may be desirable, but it is a separate decision. The minimal refactor that eliminates duplication without changing visual output is Option B.
-
-**Named slots are wrong for this problem.** Named slots solve "inject different markup into different zones of a layout". This problem is "render the correct path data for a given icon name". A `name` prop is the correct API.
-
-**Option B implementation:**
-
-```
-frontend/src/components/ui/AppIcon.vue
-```
-
-```html
-
-
-
-
-
-
-```
-
-Usage: ` `
-
-### Files changed (Option B)
-
-| File | Change type |
-|---|---|
-| `frontend/src/components/ui/AppIcon.vue` | CREATED |
-| ~35 components/views with inline SVGs | MODIFIED — replace `... ` with ` ` |
-
----
-
-## Suggested Build Order
-
-Dependencies between the four changes determine order. A change that other changes depend on must land first.
-
-```
-Step 1 — Backend admin router decomposition
-Step 2 — API client decomposition (client.js -> domain files)
-Step 3 — Admin layout + route subtree (frontend)
-Step 4 — Icon refactor
-```
-
-### Rationale
-
-**Step 1 (backend) first:**
-- No frontend dependencies. All existing tests pass with zero behavior change (same URLs, same auth, same import path in `main.py`).
-- Risk: lowest. Pure internal restructuring.
-
-**Step 2 (client.js) second:**
-- No dependency on the admin layout.
-- New admin view files created in Step 3 can import from `api/admin.js` specifically rather than the monolith.
-- Internal-only change. All 35 consumer files unchanged. All existing mock-based tests unchanged.
-- Risk: low.
-
-**Step 3 (admin layout + route subtree) third:**
-- Highest risk: modifies `App.vue` (central layout switch), `router/index.js` (all navigation), creates new layout files.
-- The existing smart components (`AdminUsersTab.vue` etc.) are not touched — they are simply wrapped by new thin view files.
-- Navigation guard logic for `requiresAdmin` and `layout` meta must be re-tested after this step.
-- Risk: medium.
-
-**Step 4 (icon refactor) last:**
-- Pure cosmetic. Zero behavior change.
-- Can be done incrementally (file by file) without blocking anything.
-- Risk: lowest.
-
-### Parallelism
-
-Steps 1 and 2 are independent and can be executed simultaneously — they touch no shared files.
-
-Steps 3 and 4 overlap on the admin component files (icons are replaced in Step 4, structure is set in Step 3). Complete Step 3 first to avoid merge conflicts.
-
----
-
-## Component Architecture: Existing vs. Target
-
-### Current (wrong)
-
-```
-App.vue
- └── AppSidebar (always present)
- └── AdminView.vue (tabs rendered in user sidebar context)
- └── AdminUsersTab.vue
- └── AdminQuotasTab.vue
- └── AdminAiConfigTab.vue
- └── AuditLogTab.vue
-```
-
-### Target (correct)
-
-```
-App.vue (layout switch)
- └── AuthLayout.vue (for meta.layout === 'auth')
- └── AdminLayout.vue (for meta.layout === 'admin')
- └── AdminSidebar.vue
- └──
- → AdminUsersView.vue (thin — passes props to AdminUsersTab)
- → AdminQuotasView.vue
- → AdminAiView.vue
- → AdminAuditView.vue
- └── Implicit main layout (v-else)
- └── AppSidebar.vue
- └──
- → FileManagerView, DocumentView, TopicsView, ...
-```
-
-The smart components (`AdminUsersTab.vue` etc.) are unchanged. The view layer above them is what changes.
-
----
-
-## What Is New vs. Modified: Complete Inventory
-
-### New files
-
-```
-backend/api/admin/__init__.py
-backend/api/admin/users.py
-backend/api/admin/quotas.py
-backend/api/admin/ai.py
-backend/api/admin/shared.py (optional — shared constants/helpers)
-
-frontend/src/layouts/AdminLayout.vue
-frontend/src/components/layout/AdminSidebar.vue
-frontend/src/views/admin/AdminUsersView.vue
-frontend/src/views/admin/AdminQuotasView.vue
-frontend/src/views/admin/AdminAiView.vue
-frontend/src/views/admin/AdminAuditView.vue
-frontend/src/api/documents.js
-frontend/src/api/topics.js
-frontend/src/api/auth.js
-frontend/src/api/admin.js
-frontend/src/api/folders.js
-frontend/src/api/shares.js
-frontend/src/api/cloud.js
-frontend/src/components/ui/AppIcon.vue (Option B only)
-```
-
-### Modified files
-
-```
-backend/main.py (no functional change — import stays identical)
-
-frontend/src/App.vue (add v-else-if; use route.matched for layout)
-frontend/src/router/index.js (admin route becomes children subtree; guard uses to.matched)
-frontend/src/api/client.js (gutted to transport + re-exports)
-All ~35 components with inline SVGs (icons replaced with AppIcon or lucide)
-```
-
-### Deleted files
-
-```
-backend/api/admin.py (replaced by package)
-frontend/src/views/AdminView.vue (replaced by route subtree)
-```
-
----
-
-## Minimal vs. Proper Assessment
-
-| Change | Is the minimal solution also the proper one? |
-|---|---|
-| Backend decomposition | Yes. Sub-router via `__init__.py` aggregator is both minimal and the standard FastAPI pattern. |
-| client.js decomposition | Yes. Re-export barrel means zero consumer churn while achieving internal separation. |
-| Admin layout | Yes. Three-way `v-if` in App.vue + new layout file is 20 lines of change in existing files. |
-| Icon refactor | The minimal solution (AppIcon.vue) is simpler than lucide but adds maintenance burden for the path map. lucide is more proper but adds a dependency. Call must be made by project owner. |
-
----
-
-## Sources
-
-- Inspected source: `backend/main.py`, `backend/api/admin.py`, `frontend/src/App.vue`, `frontend/src/router/index.js`, `frontend/src/layouts/AuthLayout.vue`, `frontend/src/api/client.js`, `frontend/src/views/AdminView.vue`, `frontend/src/components/layout/AppSidebar.vue`
-- FastAPI sub-router `include_router` nesting: standard FastAPI router composition (HIGH confidence)
-- Vue Router 4 `route.matched` for meta inheritance: Vue Router 4 documented behavior (HIGH confidence)
-- lucide-vue-next: tree-shakeable Vue 3 icon library (MEDIUM confidence — verified as active, Tailwind-compatible)
+1. Capability model + provider action interface + tests.
+2. Cloud item metadata/index schema + browse persistence.
+3. Unified browser actions for cloud files/folders.
+4. Cloud analysis jobs and cache/offload service.
+5. Unified smart search over local and analyzed cloud documents.
+6. Provider change detection, stale markers, and polish.
diff --git a/.planning/research/FEATURES.md b/.planning/research/FEATURES.md
index ca3fd57..41187d7 100644
--- a/.planning/research/FEATURES.md
+++ b/.planning/research/FEATURES.md
@@ -1,257 +1,65 @@
-# Feature Landscape — v0.2 UI Overhaul
+# v0.3 Research: Features
-**Domain:** Self-hosted SaaS document management (DocuVault)
-**Researched:** 2026-06-07
-**Scope:** UX patterns and UI feature decisions for v0.2. Existing functional features (upload, extraction, sharing, cloud backends, auth) are not re-researched — only their UX surface.
-
----
+**Milestone:** v0.3 Reimagining Cloud Storage integration
+**Date:** 2026-06-17
## Table Stakes
-Features users expect. Missing = product feels incomplete or broken.
+### Cloud-as-Local File Management
-| Feature | Why Expected | Complexity | Depends On |
-|---------|--------------|------------|------------|
-| Standalone admin layout (`/admin/*` routes with own sidebar) | AdminView.vue is currently a tab sheet bolted to the user layout. Admin and user contexts share no chrome. Nextcloud, Mayan EDMS, and every self-hosted SaaS treat admin as a distinct area. | Medium | Router nested routes, new `AdminLayout.vue` |
-| Admin sidebar with discrete nav items (Users, Quotas, AI Config, Audit Log) | Each admin tab is already a full standalone component. Route-based nav replaces in-view tabs — enables deep linking, browser back button, and future per-section permissions. | Low | Vue Router `/admin/users`, `/admin/quotas`, `/admin/ai`, `/admin/audit` child routes |
-| Empty states for every zero-content context | File list empty, folder empty, search no-results, shared-with-me empty, topics empty — each needs a distinct, actionable state. Current implementation is plain gray text. Zero-content states are the first thing new users see. | Medium | `StorageBrowser.vue`, `SharedView.vue`, `TopicsView.vue` |
-| Loading skeletons for document list and sidebar tree | The document list (`StorageBrowser`) and sidebar folder tree currently render a plain "Loading…" text string. Skeletons reduce perceived load time by 30–50% on equivalent wait times per usability studies. The `QuotaBar` already uses `animate-pulse` — extend that pattern. | Medium | `StorageBrowser.vue`, `AppSidebar.vue`, new `SkeletonRow.vue` |
-| Global search focus shortcut (`/` key) | Table stakes for document management tools. Paperless-ngx, Notion, GitHub, Linear all use `/` to focus search. Users who type `/` and nothing happens feel the app is unpolished. | Low | `SearchBar.vue`, `FileManagerView.vue` global `keydown` listener |
-| Escape key closes modals and clears search | Universal browser convention. Escape should: close `ShareModal`, close `FolderDeleteModal`, close `DocumentPreviewModal`, clear the inline folder rename input, and clear search field. Several of these already handle `keydown.escape` in inputs but not at the modal overlay level. | Low | `ShareModal.vue`, `FolderDeleteModal.vue`, `DocumentPreviewModal.vue` |
-| Sidebar collapses on mobile (hamburger toggle, overlay drawer) | On viewports below 1024px the fixed 256px sidebar eats 25–40% of the viewport. The current layout has no mobile handling — sidebar overlaps content on small screens. Minimum viable mobile: hamburger button + slide-in overlay drawer. | Medium | `AppSidebar.vue`, `App.vue`, new mobile toggle state |
-| Responsive document list column hiding | `StorageBrowser` grid already uses `hidden md:block` for Size and `hidden sm:block` for Modified. This is correct — verify it works and document the pattern as intentional. Ensure the icon + name + actions columns always render. | Low | `StorageBrowser.vue` (verify existing classes) |
-| Upload trigger keyboard shortcut (`U` key when no input is focused) | Secondary table stakes. Windows Explorer, Nextcloud, and Dropbox support pressing a key to trigger upload when focus is on the file list. Makes keyboard-first workflows viable. | Low | `DropZone.vue` or `FileManagerView.vue` global `keydown` listener |
-| Visible drag-and-drop affordance on the drop zone at all times | `DropZone.vue` already exists and handles dragover/drop. The gap: the drop zone is a full-width dashed box inside the content area, which is correct, but there is no whole-page drag overlay when a user drags a file from the OS file manager. Users miss the target and the drop is ignored. | Medium | `FileManagerView.vue` document-level dragenter/dragleave listener |
-| Contextual breadcrumb with admin section label | Admin subroutes need breadcrumbs: `Admin > Users`, `Admin > Audit Log`. Without them, admin pages feel unmoored. The user-facing `FolderBreadcrumb` is already built — admin can use the same component with static segments. | Low | `FolderBreadcrumb.vue`, `AdminLayout.vue` |
+- Browse connected cloud folders with local-like row actions.
+- Open/preview files through DocuVault authorization.
+- Upload files into the visible cloud folder.
+- Rename files and folders where provider supports it.
+- Move files/folders within the same provider connection.
+- Delete files/folders with explicit confirmation and audit logging.
+- Create folders where provider supports it.
+- Show unsupported actions disabled with a provider-aware reason.
----
+### Provider Health and Maintenance
+
+- Show connection health per provider.
+- Detect expired/revoked credentials and guide reconnect.
+- Invalidate folder/index caches after reconnect, delete, move, rename, or upload.
+- Preserve credentials encryption and never expose secrets.
+- Surface provider errors as actionable user-facing messages.
+
+### Analyze Cloud Documents
+
+- Analyze a selected file, selected folder, or whole provider connection.
+- Track analysis progress: queued, downloading/caching, extracting, classifying, indexed, failed.
+- Avoid duplicate analysis when provider item version/etag has not changed.
+- Allow retry of failed items.
+- Keep analysis non-destructive: analyzing a cloud file must not move or rewrite it.
+
+### Smart Search Across Local and Cloud
+
+- Search should cover local documents plus analyzed cloud documents.
+- Keyword/sentence search uses extracted text.
+- Idea/semantic search should be supported through embeddings if v0.3 includes semantic indexing.
+- Search results identify source: local, OneDrive, Google Drive, Nextcloud, WebDAV.
+- Opening a cloud result should hydrate bytes on demand if not cached.
+
+### Storage Minimization
+
+- Store provider metadata, extracted text, topics, and embeddings in DocuVault.
+- Cache full file bytes only while needed for preview/extraction/open.
+- Evict cached bytes by quota/TTL/LRU and never count transient cache bytes as user-owned imported storage.
+- Keep an explicit user action for permanent import/copy to local DocuVault storage if implemented later.
## Differentiators
-Features that add genuine value beyond the baseline. Not expected by most users, but valued when present.
+- Virtual-local status indicators: online-only, cached, analyzing, indexed, stale, error.
+- Folder-level analyze action with recursive selection preview.
+- Change detection via provider deltas where available, falling back to scheduled metadata refresh for WebDAV.
+- Unified local/cloud search with filters for source, provider, folder, topics, status, and analyzed/not analyzed.
+- Conflict-aware operations when provider etag/version changes between list and mutate.
-| Feature | Value Proposition | Complexity | Depends On |
-|---------|-------------------|------------|------------|
-| Whole-page drop zone (drag files anywhere on the screen to upload) | Removes the requirement to aim at the DropZone rectangle. Paperless-ngx supports this. Implementation: listen for `dragenter` at the document level, show a full-screen overlay with the drop target, then pipe the `drop` event into the existing upload emit chain. | Medium | `FileManagerView.vue`, new `PageDropOverlay.vue`, existing `DropZone` emit chain |
-| Skeleton placeholders shaped like the real content (5-column grid rows) | The QuotaBar has a simple pulse bar. The document list skeleton should be 5-column grid rows matching the actual layout — icon placeholder, name placeholder, size placeholder, date placeholder. This makes the transition from loading to content feel instantaneous rather than a layout jump. | Medium | New `SkeletonRow.vue`, `StorageBrowser.vue` |
-| Admin dashboard overview page (`/admin` root — stats at a glance) | User count, storage total across all users, recent audit log entries. Gives admins a reason to visit the admin panel rather than jumping straight to sub-sections. Self-hosted tools consistently provide this at the admin root. | Medium | New `AdminOverviewView.vue`, read-only aggregate API endpoints (may require backend work) |
-| Inline toast/notification system for upload completion and errors | Currently errors appear in the UploadProgress inline list and are never dismissed. A toast system enables transient success/error messages for any action (rename, delete, share) — not just upload. Constraint: keep it simple, not a full notification center. | Medium | New `ToastManager.vue` in `App.vue`, composable `useToast()` |
-| Document count badge polish on topic sidebar links | Already has `{{ topic.doc_count }}` in the sidebar. What's missing: show 0 counts as dimmed rather than hidden (users want to know the topic exists even if empty), and truncate to "9+" at double digits. | Low | `AppSidebar.vue` (minor change) |
-| Upload from keyboard shortcut `U` with accessible file picker trigger | The keyboard shortcut fires `triggerInput()` on DropZone. Accessibility requirement: this is equivalent to clicking the hidden file input — screen readers announce the file dialog opening. Already partially supported by `DropZone.vue`'s `triggerInput()` — wire up the global shortcut. | Low | `DropZone.vue`, `FileManagerView.vue` |
-| Tablet landscape layout: sidebar + content, no collapse needed | At 1024px (lg breakpoint), the sidebar is fully visible alongside content. This is the natural Tailwind `lg:flex` pattern. Verify it and make the breakpoint logic explicit in the layout wrapper. | Low | `App.vue` layout wrapper |
+## Anti-Features for v0.3
----
-
-## Anti-Features
-
-Features to explicitly NOT build in v0.2.
-
-| Anti-Feature | Why Avoid | What to Do Instead |
-|--------------|-----------|-------------------|
-| Folder reorder by drag-and-drop (drag folder A above folder B to change sidebar order) | Requires a persistent `position` column in the DB, a backend migration, and drag state tracking across the sidebar tree. The sidebar is already collapsible. Payoff is marginal. | Keep alphabetical or creation-time ordering. Users can rename folders to impose their own order (e.g., "1. Contracts"). |
-| Reorderable sidebar sections (drag "Folders" above "Cloud Storage") | Same DB persistence problem. Section order is fixed and meaningful — user folders first, then external cloud. No self-hosted document manager reorders sections. | Fixed section order in `AppSidebar.vue`. |
-| Virtual scrolling for the document list | DocuVault is quota-capped at 100 MB per user. At typical document sizes (10–500 KB each), a user has at most a few hundred documents before hitting quota. Virtual scrolling adds significant complexity (dynamic row height, scroll restoration) for an edge case that the quota cap prevents. | Standard `v-for`. Add server-side pagination in a later milestone if needed. |
-| Rich text document annotations or in-app editing | Already scoped out in `PROJECT.md`. Mentioned here because UX redesigns often attract "what if we added..." scope creep. The preview modal shows PDF; that is the limit. | Stay read-only. Document content is immutable after upload. |
-| Dark mode toggle | Requires a systematic color token system across all components plus full testing of the inverted palette. The current light-mode palette is inconsistent — fix that first. Tailwind makes dark mode mechanically possible but the v0.2 payoff is low relative to scope. | Design tokens and CSS variables in v0.2 that would support dark mode in a future milestone. |
-| Drag-and-drop to reorder documents within a folder | Requires a persistent sort order column per user per folder. Documents are naturally sorted by creation date or name — both useful orderings. Custom ordering has high complexity and low frequency of use in a document vault. | Keep the existing sort controls (created_at, name, size). Server-side sort is already implemented. |
-| Full-screen modal for file upload | The DropZone is embedded in the file browser content area, which is the correct placement. A modal upload workflow adds navigation overhead with no benefit for a simple file picker. | The whole-page drag overlay (differentiator above) is the right scope. |
-| Notification center / inbox for async events | Building a polling-based notification center (classification done, quota warning) is a full feature, not UX polish. Celery classification runs in the background and its result is visible when the user next opens the document. | Toast on upload completion. Badge count on the document list is sufficient for v0.2. |
-| Multi-select with batch operations (select 10 files, delete all) | Requires a selection state layer across the file list, a selection-aware toolbar, and confirmed batch-delete UX. Medium-to-high complexity for a feature that is rarely needed in a personal document vault. | Address in a future milestone if user feedback identifies it as a gap. |
-| Drag documents from file list to sidebar folder entries | Cross-component drag state requires either a Pinia drag store or provide/inject. The sidebar `FolderTreeItem` and `StorageBrowser` have no shared parent that can coordinate state without significant refactoring. The "Move to folder" picker button already covers this use case. | The existing folder picker button in the file row actions menu. |
-
----
-
-## Admin Panel: Specific Pattern Decisions
-
-### Route structure
-
-```
-/admin → AdminLayout.vue wrapper (persistent sidebar + router-view)
- /admin/ → AdminOverviewView.vue (stats at a glance)
- /admin/users → AdminUsersView.vue (wraps AdminUsersTab.vue)
- /admin/quotas → AdminQuotasView.vue (wraps AdminQuotasTab.vue)
- /admin/ai → AdminAiConfigView.vue (wraps AdminAiConfigTab.vue)
- /admin/audit → AdminAuditView.vue (wraps AuditLogTab.vue)
-```
-
-The existing tab components (`AdminUsersTab.vue`, `AdminQuotasTab.vue`, `AdminAiConfigTab.vue`, `AuditLogTab.vue`) are promoted to route-level views. The `AdminLayout.vue` replaces `AdminView.vue`. The tab strip becomes a sidebar with router-link active styling. No logic changes inside the tab components — only the container changes. `AdminView.vue` is deleted after the migration.
-
-Vue Router 4 nested routes pattern (HIGH confidence — verified against official docs):
-
-```js
-{
- path: '/admin',
- component: AdminLayout, // contains
- meta: { requiresAdmin: true },
- children: [
- { path: '', component: AdminOverviewView },
- { path: 'users', component: AdminUsersView },
- { path: 'quotas', component: AdminQuotasView },
- { path: 'ai', component: AdminAiConfigView },
- { path: 'audit', component: AdminAuditView },
- ],
-}
-```
-
-The `requiresAdmin` guard moves from the child route to the parent. All children inherit it automatically.
-
-### Admin sidebar nav items (in order)
-1. Overview
-2. Users
-3. Quotas
-4. AI Config
-5. Audit Log
-
-A "Back to app" link at the bottom of the admin sidebar returns to `/` — admin should not feel like a dead end.
-
-### Admin sidebar does NOT share code with `AppSidebar.vue`
-
-`AdminSidebar.vue` is a separate component. It has no folder tree, no cloud tree, no topics list, no quota bar. Those belong to the user interface. Sharing the sidebar component would create coupling between two logically separate contexts and conflict with the "thin view + smart component" architecture described in CLAUDE.md.
-
-### Breadcrumb
-
-Use the existing `FolderBreadcrumb.vue` or a simpler static variant: `Admin > [Section Name]`. No dynamic nesting required. The admin sidebar's active link already signals location; the breadcrumb adds skimmability for users who navigate via URL.
-
----
-
-## Empty State Patterns: Specific Decisions
-
-Each zero-content context gets a distinct implementation. Generic "No items" text is the anti-pattern.
-
-| Context | Headline | Subtext | CTA |
-|---------|----------|---------|-----|
-| Root folder (no folders yet) | "Start organizing your documents" | "Create a folder to group related files together." | "New folder" button |
-| Folder is empty (exists but has no contents) | "This folder is empty" | "Upload files here or move documents into this folder." | "Upload files" link (triggers DropZone click) |
-| Search returns no results | "No matches for '{query}'" | "Try a different search term or clear your search." | "Clear search" button |
-| Shared with me (no shares yet) | "Nothing shared with you yet" | "When someone shares a document with your handle, it appears here." | None (passive state) |
-| Topics list empty | "No topics yet" | "Topics are created automatically when you upload and classify a document." | None |
-| Audit log (admin, no entries yet) | "No audit events recorded" | "Events are recorded when users log in, upload documents, or make changes." | None |
-| Cloud storage — no connections | "No cloud storage connected" | "Connect OneDrive, Google Drive, Nextcloud, or WebDAV in Settings." | "Open Settings" link |
-
-Rule: every empty state that the user can resolve with an action gets exactly one CTA. Empty states that are passive (shared-with-me before anyone shares, topics before first upload, audit before any events) get explanatory subtext only — no CTA that leads nowhere useful.
-
-Implementation: extract a shared `EmptyState.vue` presentational component that accepts `headline`, `subtext`, and an optional `action` slot. Use it in all seven contexts above instead of duplicating the layout.
-
----
-
-## Loading State Patterns: Specific Decisions
-
-| Component | Current | Target | Rationale |
-|-----------|---------|--------|-----------|
-| Document list (`StorageBrowser`) | `Loading…
` text | 6–8 skeleton rows (5-col grid, `animate-pulse`) | List loads on every folder navigation. Skeletons match the grid layout exactly, preventing layout jump. |
-| Sidebar folder tree | `Loading…
` text | 3 skeleton nav items (indent + placeholder bar) | Sidebar loads once on mount. Short skeleton is sufficient. |
-| Sidebar topics list | `Loading…
` text | 4 skeleton topic pills (color dot + bar) | Same rationale as folder tree. |
-| Admin user table | No loading state | Skeleton table rows (3–5 rows, matching column count) | Admin tables are data-heavy; skeleton preserves table chrome during load. |
-| Audit log table | No loading state | Skeleton rows matching timestamp/user/action columns | Same. |
-| Upload progress item | Spinner + progress bar (correct) | Keep as-is | Upload is a blocking action, not a content load. Spinners are correct for actions. |
-| QuotaBar | `animate-pulse` bar (correct) | No change | Already implemented correctly. Document as the canonical skeleton pattern. |
-| Document preview modal | None | Spinner centered in modal body | PDF proxy fetch is a single-request action. Spinner is correct. |
-
-Rule: if the loading UI represents content the user will read (list, table, tree), use a skeleton. If the loading UI represents a system action (save, upload, submit, process), use a spinner. Never show a loading state that will be visible for less than 300ms — add a minimum display delay.
-
----
-
-## Drag-and-Drop: Scope Boundaries
-
-### Build in v0.2
-
-1. **Whole-page drop overlay for file upload** — listen for `dragenter` at the document level, show a full-screen overlay, delegate the `drop` event to the existing DropZone emit chain. The drop must only activate when files are being dragged (check `e.dataTransfer.types.includes('Files')`).
-
-2. **Move a document to a folder by dragging the file row onto a folder row** — already implemented in `StorageBrowser.vue`. Verify it functions correctly and polish the drop highlight: `ring-2 ring-inset ring-amber-300` on the folder row is correct. Ensure the file row shows reduced opacity during drag (`opacity-50` already applied).
-
-### Do Not Build in v0.2
-
-- Dragging files from the file list onto sidebar folder entries (requires cross-component drag state via Pinia or provide/inject — the "Move to folder" picker already covers this)
-- Touch drag-and-drop on mobile (HTML5 drag events do not fire on iOS Safari without a polyfill library; defer to the move picker button)
-- Drag folders to reorder them
-
-### Required for every drag interaction
-
-- Visual affordance on dragover (already done on DropZone; add to whole-page overlay)
-- Non-drag alternative for every drag action: the "Move to folder" picker button covers the drag-to-move case
-- Drop must be clearly confirmed: file row disappears from source location and appears in target on success
-
----
-
-## Keyboard Shortcuts: Final List
-
-| Shortcut | Action | Condition |
-|----------|--------|-----------|
-| `/` | Focus search bar | Not in an input, textarea, or contenteditable |
-| `Escape` | Close modal or clear active inline input | Modal open, or search focused with content |
-| `U` | Trigger file picker (upload) | Not in any input, on a folder/file manager view |
-| `N` | Start new folder inline input | Not in any input, on a folder/file manager view |
-
-### Do Not Build
-
-- `J`/`K` for row-by-row navigation — requires focus management, ARIA selected state, and keyboard event bubbling control. Over-engineered for a vault where opening a document is secondary to browsing and uploading.
-- `Ctrl+A` / `Cmd+A` for select all — requires multi-select, which is an anti-feature.
-- Arrow key tree navigation in the sidebar — screen readers handle this natively; custom arrow-key navigation would conflict with browser defaults.
-- `?` shortcut for a shortcut reference modal — too few shortcuts to warrant a help overlay.
-
----
-
-## Responsive Layout: Breakpoint Decisions
-
-| Viewport | Tailwind breakpoint | Sidebar | Document list | Admin |
-|----------|---------------------|---------|--------------|-------|
-| Mobile portrait | `< 640px` (below `sm`) | Hidden; hamburger button + overlay drawer | Icon + name only; Size (`hidden md:block`) and Modified (`hidden sm:block`) hidden | Same drawer |
-| Mobile landscape / small tablet | `640–767px` (`sm`) | Hidden; hamburger still active | Icon + name + Modified visible; Size hidden | Same |
-| Tablet portrait | `768–1023px` (`md`) | Hidden; hamburger still active | Full 5-col grid | Full columns |
-| Tablet landscape / small desktop | `1024px+` (`lg`) | Always visible (256px fixed) | Full 5-col grid | Full admin sidebar + content |
-| Desktop | `1280px+` (`xl`) | Always visible | Full grid + comfortable padding | Full layout |
-
-Key decision: sidebar collapses at the `lg` breakpoint (1024px), using `hidden lg:flex` on the sidebar and `lg:hidden` on the hamburger button. At 1024px the sidebar (256px) leaves 768px for content — sufficient for the 5-column grid with room for padding.
-
-Touch targets: all inline action icon buttons are currently `p-1.5` (approximately 30px). Increase to `p-2` on `sm:` and below using responsive padding classes. 44px is the WCAG target — not achievable for inline buttons without breaking the grid layout, but 36px is a reasonable minimum.
-
----
-
-## Feature Dependencies (summary)
-
-```
-Admin route subtree
- → AdminLayout.vue (new) contains AdminSidebar.vue (new) +
- → Child views wrap existing AdminUsersTab, AdminQuotasTab, AdminAiConfigTab, AuditLogTab
- → AdminView.vue deleted after migration
-
-Empty states
- → New EmptyState.vue (presentational, accepts headline/subtext/action slot)
- → Used in StorageBrowser.vue, SharedView.vue, TopicsView.vue, CloudStorageView.vue, AuditLogTab.vue
-
-Skeleton rows
- → New SkeletonRow.vue (5-col grid, animate-pulse)
- → Used in StorageBrowser.vue (document list), AppSidebar.vue (folder tree, topics list)
-
-Keyboard shortcuts
- → Global keydown listener in FileManagerView.vue
- → SearchBar.vue exposes focus() method
- → DropZone.vue exposes triggerInput() (already exists as triggerInput())
- → StorageBrowser.vue exposes startNewFolder() (already defineExposed)
-
-Mobile sidebar
- → App.vue holds sidebarOpen boolean state
- → AppSidebar.vue accepts isOpen prop + emits close
- → Hamburger button in App.vue header (mobile only, hidden lg:hidden)
-
-Whole-page drop overlay
- → New PageDropOverlay.vue (full-screen overlay, accepts active prop)
- → FileManagerView.vue listens for document dragenter/dragleave
- → On drop, delegates to existing upload flow via onFilesSelected
-```
-
----
-
-## Sources
-
-- Vue Router 4 nested routes: https://router.vuejs.org/guide/essentials/nested-routes
-- Skeleton screens vs. spinners UX: https://www.onething.design/post/skeleton-screens-vs-loading-spinners
-- Skeleton loading screen design: https://blog.logrocket.com/ux-design/skeleton-loading-screen-design/
-- SaaS empty state patterns: https://pixxen.com/saas-empty-state-design/
-- IBM Carbon Design System empty states: https://carbondesignsystem.com/patterns/empty-states-pattern/
-- Drag-and-drop SaaS UX: https://www.eleken.co/blog-posts/drag-and-drop-ui
-- Drag-and-drop NN/g guidelines: https://www.nngroup.com/articles/drag-drop/
-- Responsive SaaS breakpoints: https://tryhoverify.com/blog/responsive-design-breakpoints-2024-guide/
-- Keyboard shortcut design for web apps: https://sashika.medium.com/j-k-or-how-to-choose-keyboard-shortcuts-for-web-applications-a7c3b7b408ee
-- Nextcloud layout patterns: https://docs.nextcloud.com/server/latest/developer_manual/design/layout.html
-- Paperless-ngx frontend architecture: https://deepwiki.com/paperless-ngx/paperless-ngx/2-frontend-architecture
+- Native desktop sync client.
+- Offline-first web app for entire cloud drives.
+- Cross-provider move/copy as a seamless operation.
+- Real-time collaborative editing.
+- Public link sharing.
+- Automatic whole-cloud analysis without explicit user opt-in and cost warning.
diff --git a/.planning/research/PITFALLS.md b/.planning/research/PITFALLS.md
index 85f10e7..17f07c6 100644
--- a/.planning/research/PITFALLS.md
+++ b/.planning/research/PITFALLS.md
@@ -1,290 +1,66 @@
-# Domain Pitfalls — v0.2 UI Overhaul and Optimization
+# v0.3 Research: Pitfalls
-**Project:** DocuVault
-**Milestone:** v0.2 — UI Overhaul and Optimization on existing FastAPI + Vue 3 app
-**Researched:** 2026-06-07
-**Confidence:** HIGH (all pitfalls grounded in actual source files: router/index.js, App.vue, main.py, client.js, StorageBrowser.vue, DocumentCard.vue, DropZone.vue, AppSidebar.vue, and all api/*.py router files)
+**Milestone:** v0.3 Reimagining Cloud Storage integration
+**Date:** 2026-06-17
----
+## Major Risks
-## Critical Pitfalls
+### Full Mirror by Accident
-Mistakes that cause regressions, security gaps, or rewrites.
+If analysis downloads every cloud file into MinIO, user quota and infrastructure cost explode. Treat bytes as temporary cache unless the user explicitly imports a file.
----
+Prevention: separate cloud item metadata/index records from local `documents`; add cache limits and eviction tests early.
-### Pitfall 1: FastAPI Router Splitting — Prefix Already Embedded in APIRouter, Not in include_router
+### Provider Capability Mismatch
-**Phase:** Backend refactoring (split large router files)
+OneDrive, Google Drive, Nextcloud, and generic WebDAV do not expose identical semantics. Google Workspace files, WebDAV path IDs, OneDrive item IDs, etag behavior, move/rename semantics, and preview support differ.
-**What goes wrong:** Every existing router in this codebase declares its prefix on the `APIRouter` instance itself — `router = APIRouter(prefix="/api/documents", tags=["documents"])` — NOT on the `include_router(router, prefix=...)` call in `main.py`. When splitting a large file (e.g. `documents.py` at 852 lines, `admin.py` at 934 lines) into sub-modules, the new sub-routers pick up their endpoints via an `include_router` call inside the original module, or they are wired directly in `main.py`. Either way, if you add a prefix on `include_router` in addition to the prefix on the inner `APIRouter`, all URLs double-prefix to `/api/documents/api/documents/...`. Likewise, if you strip the prefix from the inner router and forget to add it to `include_router`, all URLs return 404.
+Prevention: model provider capabilities explicitly and disable unsupported actions with a clear reason.
-**Why it happens:** The original pattern (`router = APIRouter(prefix=...)` in the module + `app.include_router(router)` without a prefix in `main.py`) is unusual but consistent in this project. Developers splitting a file expect the standard pattern where prefixes go on `include_router`, not on the router definition.
+### Search That Downloads on Query
-**Consequences:** 404 on all endpoints in the split module; existing tests and the frontend `client.js` (which hard-codes `/api/documents`, `/api/admin`, etc.) silently break.
+Smart search must query persisted extracted text/embeddings. Downloading cloud bytes during search would be slow, expensive, and fragile.
-**Prevention:** When splitting, create sub-routers with NO prefix: `sub = APIRouter(tags=["documents-upload"])`. Register them inside the parent module with `router.include_router(sub)` so the parent's prefix propagates. The parent module keeps its existing prefix and its existing `app.include_router(parent)` call in `main.py` is unchanged.
+Prevention: search only analyzed/indexed cloud item records; show unanalyzed items as out of search scope until analyzed.
-**Detection warning signs:** Any test that calls `/api/documents/*` returns 404 immediately after the split; Swagger UI shows doubled path segments.
+### Stale or Wrong Results
----
+Cloud files can change outside DocuVault. Search results become misleading if etags/versions are ignored.
-### Pitfall 2: FastAPI Router Splitting — Circular Import via Shared Dependencies
+Prevention: store last analyzed etag/version, mark stale when provider metadata changes, and re-analyze changed items.
-**Phase:** Backend refactoring
+### Destructive Action Surprise
-**What goes wrong:** `folders.py` already exports a `document_move_router = APIRouter(prefix="/api/documents")` — a cross-cutting concern documented in the file (line 439-442). When splitting `documents.py`, if the new sub-module imports anything from `api/folders.py` (e.g. a shared validator or Pydantic schema), Python raises `ImportError: cannot import name X` at startup because both modules are in partial initialization state.
+Delete/move/rename in cloud mode changes the user's real provider storage, not a DocuVault copy.
-**Why it happens:** FastAPI modules that share models and validators tend to grow cross-references. The `document_move_router` in `folders.py` under the `/api/documents` prefix is the existing example — any new import in the opposite direction closes the import cycle.
+Prevention: clear confirmations for destructive actions, audit logs, ownership checks, and provider-specific error handling.
-**Consequences:** Application fails to start. Docker container exits immediately.
+### Credential and Token Drift
-**Prevention:** Shared Pydantic models and validators must live in `db/models.py` or a dedicated `schemas/` module — not in router files. Before splitting, grep for any `from api.X import` inside the router files being split and move the shared symbols to a neutral module first.
+OAuth tokens expire or are revoked. WebDAV passwords/app tokens change. Background jobs may fail long after the user connected the provider.
-**Detection warning signs:** `ImportError` or `cannot import name` in the uvicorn startup log.
+Prevention: connection health state, reconnect flows, token refresh persistence, and failure states that stop retry storms.
----
+### Blocking Event Loop or Worker Exhaustion
-### Pitfall 3: AdminLayout Vue Router Integration — beforeEach Guard Misses /admin/* Children
+Provider SDKs may be synchronous and cloud downloads can be large.
-**Phase:** Admin panel rearchitecture
+Prevention: keep sync SDK calls in thread wrappers, stream/chunk large files, use Celery queues/rate limits, and retry external API failures with backoff.
-**What goes wrong:** The current guard in `router/index.js` (lines 81-93) checks `to.meta.requiresAdmin` on the matched route object. The current `/admin` route is a flat route with that meta set directly. When `/admin` becomes a parent route with nested children (`/admin/users`, `/admin/audit`, etc.), the children do NOT automatically inherit `meta: { requiresAdmin: true }` from the parent in Vue Router 4 unless the guard iterates `to.matched`. If the children are added without repeating the meta, the guard's `to.meta.requiresAdmin` check evaluates to `undefined` (falsy) for every child route — non-admin users can navigate to `/admin/users` directly.
+### SSRF and URL Handling Regression
-**Why it happens:** Vue Router 4 does NOT merge parent `meta` into child `meta` by default. `to.meta` reflects only the deepest matched route's own meta, not the parent's.
+WebDAV/Nextcloud URLs are user-supplied. v0.1 already added SSRF guards; new action endpoints must preserve them.
-**Consequences:** Security regression — the admin guard that currently works correctly stops protecting all admin child routes. Non-admin users can access `/admin/users`, `/admin/audit`, etc.
+Prevention: all WebDAV outbound paths go through existing validation utilities; never join raw user path strings into URLs without provider helper methods.
-**Prevention:** Update the guard to check `to.matched.some(r => r.meta.requiresAdmin)` instead of `to.meta.requiresAdmin`. This is the correct Vue Router 4 idiom for inherited meta and requires one change to the guard. Apply the same fix to the `!to.meta.public` check if any auth routes gain children.
+### Cache Privacy Leak
-```javascript
-// router/index.js — change:
-if (to.meta.requiresAdmin && authStore.user?.role !== 'admin') {
-// to:
-if (to.matched.some(r => r.meta.requiresAdmin) && authStore.user?.role !== 'admin') {
-```
+Cached bytes are sensitive document content. A shared cache path without user/item scoping could leak across users.
----
+Prevention: cache keys include user id, connection id, item id, and etag; cache content is never served without current-user ownership checks.
-### Pitfall 4: AdminLayout Integration — App.vue Layout Switch Breaks for /admin/* Routes
+### Runaway AI Cost
-**Phase:** Admin panel rearchitecture
+"Analyze all cloud documents" could enqueue thousands of files.
-**What goes wrong:** `App.vue` currently switches between `AuthLayout` (when `route.meta.layout === 'auth'`) and the main app shell (sidebar + ``) for everything else. The admin panel needs a third layout (`AdminLayout`) with its own nav. The naive approach — `v-else-if="route.meta.layout === 'admin'"` in `App.vue` rendering `AdminLayout` which then renders ` ` — creates double rendering: `App.vue`'s `` already resolved the full route. The inner `` inside `AdminLayout` gets nothing because the route is already consumed.
-
-**Consequences:** Admin layout renders with an empty content area, or the admin page renders without the admin chrome.
-
-**Prevention:** The correct structure for a standalone admin layout in Vue Router 4:
-
-```javascript
-// router/index.js
-{
- path: '/admin',
- component: AdminLayout, // resolves to the layout shell, which contains
- meta: { requiresAdmin: true },
- children: [
- { path: '', redirect: '/admin/users' },
- { path: 'users', component: AdminUsersView },
- { path: 'quotas', component: AdminQuotasView },
- { path: 'ai', component: AdminAiView },
- { path: 'audit', component: AdminAuditView },
- ]
-}
-```
-
-`AdminLayout.vue` contains ` ` where page content renders. `App.vue` remains unchanged — it renders the component that the route resolves to, which is now `AdminLayout`. No third branch in `App.vue` is needed.
-
----
-
-### Pitfall 5: client.js Splitting — noRefreshPaths Breaks When Paths Move to Sub-modules
-
-**Phase:** Frontend refactoring (client.js split)
-
-**What goes wrong:** `client.js` has a hardcoded `noRefreshPaths` list inside `request()` (line 26) that guards against infinite refresh loops. If `request()` is moved to a shared `api/core.js` module and the path list stays there, it works. But the three blob-download functions (`adminExportAuditLogCsv`, `adminDownloadDailyExport`, `fetchDocumentContent`) each contain a duplicated copy of the 401-refresh-retry logic. When splitting `client.js` into domain files, those duplicates will be copied into new sub-files. This is the correct moment to eliminate them — but if missed, the bug surface multiplies.
-
-**Additionally:** If components are changed to import from sub-files directly (e.g. `import { listDocuments } from '../api/documents.js'`) instead of the barrel, every other component that imports from `client.js` still works — but a future refactor that renames a function in the sub-file will miss the barrel re-export and break components that still use the barrel. Both import paths exist simultaneously and diverge.
-
-**Prevention:** Split in this order:
-1. Extract `request()` + `noRefreshPaths` into `api/core.js`. Export `request` from there.
-2. Extract the 401-retry pattern from the three blob-download functions into a single `fetchWithRetry(url, headers)` helper also in `api/core.js`.
-3. Create domain files (`api/documents.js`, `api/admin.js`, `api/auth.js`, etc.) that import from `api/core.js`.
-4. Keep `api/client.js` as a re-export barrel: `export * from './documents.js'; export * from './admin.js'` etc. This preserves all existing `import { listDocuments } from '../api/client.js'` calls without touching any component. Never change component imports to point at sub-files directly.
-
----
-
-### Pitfall 6: Drag-and-Drop on DocumentCard — dragstart vs @click Conflict
-
-**Phase:** UX improvements (drag-and-drop)
-
-**What goes wrong:** `DocumentCard.vue` navigates on `@click="$router.push('/document/' + doc.id)"` (line 4). Browsers fire `click` after `dragend` when the drag distance is below the browser's drag-initiation threshold — typically ~4px. A user who clicks and releases without moving far enough triggers both `dragstart` and then `click`: the card navigates to the document even though the user intended to start a drag.
-
-**Also:** The action buttons inside `DocumentCard` use `@click.stop`. If a drag handle overlaps an action button area, `@click.stop` prevents the click from propagating but does not prevent `dragstart` from bubbling — behavior becomes inconsistent depending on which pixel the user starts from.
-
-**Note:** `StorageBrowser.vue` already implements drag-and-drop on list rows correctly (lines 141-145: `draggable`, `@dragstart`, `@dragend`) and does not have this problem because the row's `@click` emits `file-open` rather than navigating imperatively. The risk is specific to adding drag to `DocumentCard.vue` (card grid used in `TopicsView`).
-
-**Prevention:**
-1. Track drag state with a component-level `dragging` ref. Set `dragging = true` in `@dragstart`, reset to `false` in `@dragend`. Guard `@click`: `if (this.dragging) return`.
-2. Preferred: use a dedicated drag handle element (grip icon, `cursor-grab`) and set `draggable="true"` only on that element. Add `@click.stop` on the handle. The rest of the card retains click-to-navigate.
-
----
-
-### Pitfall 7: Virtual Scrolling — StorageBrowser DOM Assumptions Break
-
-**Phase:** Frontend performance (virtual scrolling)
-
-**What goes wrong:**
-
-`StorageBrowser.vue` renders its item list with `divide-y divide-gray-100` (line 76). The Tailwind `divide-y` utility inserts borders using adjacent-sibling CSS (`& > * + * { border-top-width: 1px }`). This only works when all children are present in the DOM simultaneously. Virtual scroll libraries keep only visible rows in the DOM — `divide-y` produces borders only between the visible subset, leaving gaps at virtual boundaries.
-
-The folder picker dropdown inside file rows (lines 185-213) is `absolute right-0 top-full` — positioned relative to its parent row. Virtual scroll repositions rows via `transform: translateY()`. Absolutely positioned children escape their transformed parent and appear at wrong viewport positions.
-
-CSS transitions on rows (`transition-all`, `transition-colors`) work with `v-for` because Vue animates DOM insertion/removal. Virtual scroll does not insert/remove — it repositions. Vue's `` cannot wrap a virtually scrolled list and will throw warnings.
-
-**Prevention:**
-1. Replace `divide-y` with `border-b border-gray-100` on each individual row element. Rows become self-contained and do not depend on DOM adjacency.
-2. The folder picker dropdown must use `` with `position: fixed` coordinates computed from `getBoundingClientRect()`. This is a prerequisite for virtual scroll adoption, not optional.
-3. Remove any `` wrapping a virtually scrolled list. Use per-row fade-in via a CSS class added on first mount instead.
-
----
-
-## Moderate Pitfalls
-
----
-
-### Pitfall 8: Responsive Layout — w-64 Sidebar vs Flex-1 Content on Mobile
-
-**Phase:** Responsive / mobile layout
-
-**What goes wrong:** `App.vue` uses `flex h-screen overflow-hidden` with `AppSidebar` fixed at `w-64 shrink-0`. On screens narrower than ~320px the content area collapses below usable width. `StorageBrowser`'s 5-column grid `grid-cols-[2rem_1fr_6rem_8rem_6rem]` has `hidden sm:block` and `hidden md:block` to hide size and date columns — but the fixed `2rem` icon and `6rem` action columns still consume 128px on either side of the filename, leaving minimal space on a 375px screen.
-
-**Prevention:**
-1. On mobile (`< lg`), hide the sidebar. Use a hamburger button in a mobile-only header bar. Toggle with `translate-x-0 / -translate-x-full` transition on the sidebar element.
-2. The sidebar open/closed state needs to be in a Pinia store ref (or provided from `App.vue`) so the hamburger button in the header, the sidebar overlay, and the main content area all react to it. Do not put it in a component's `data()`.
-3. On mobile, simplify the grid to `grid-cols-[2rem_1fr_4rem]` — filename and a single action column. Test at 375px explicitly.
-
----
-
-### Pitfall 9: Admin Panel + Responsive — Double Padding After Extracting Tab Content
-
-**Phase:** Admin panel rearchitecture + responsive layout (interaction pitfall)
-
-**What goes wrong:** `AdminView.vue` wraps all content in `p-8 max-w-5xl mx-auto`. Each tab component (`AdminUsersTab.vue`, `AdminQuotasTab.vue`, etc.) has its own internal padding. When tab components are extracted into standalone page views (`AdminUsersView.vue`, etc.), the `p-8 max-w-5xl mx-auto` wrapper moves to `AdminLayout.vue`. If the extracted page views retain their own inner padding, content is double-padded. If `AdminLayout` does not include the constraint, the new admin pages render full-bleed against the viewport.
-
-**Prevention:** When extracting tab components into page views, strip all top-level padding and `max-w` from the extracted component — those belong to `AdminLayout`'s content area. The page components should fill 100% of the space provided by the layout slot.
-
----
-
-### Pitfall 10: Options API Refactoring — Prop Mutation via v-model
-
-**Phase:** Frontend refactoring (Vue component decomposition)
-
-**What goes wrong:** If a component receives a prop and binds it directly to ` `, Vue emits a prop mutation warning in development. In production (no warnings), the mutation silently fails to propagate: the component's displayed value updates (local vdom), but the parent never receives the change because no `update:propName` event was emitted.
-
-**Specific risk in this codebase:** `AdminUsersTab`, `AdminQuotasTab`, and `AuditLogTab` currently own their filter/search state internally. When extracted into standalone views, these may receive initial state from route query params and accidentally bind those props directly to inputs.
-
-**Prevention:** Any input the component controls must bind to a local `data()` copy initialized from the prop: `data() { return { localFilter: this.initialFilter } }`. Emit `update:initialFilter` on change. Never write `v-model="$props.X"`.
-
----
-
-### Pitfall 11: Options API Refactoring — data() vs computed() in Decomposed Components
-
-**Phase:** Frontend refactoring
-
-**What goes wrong:** When extracting a large component, logic that was a `computed()` in the parent often gets copy-pasted into a child's `data()`. A `data()` value initializes once at mount — it does not react to prop changes. If the parent later passes a different prop value (e.g. the document list changes after a search), the child's stale `data()` copy never updates.
-
-**Prevention:** For every value in a newly created component, ask: "If the parent passes a different value for this prop, should the child update?" Yes → `computed()`. No → `data()`. Initialized from prop but locally modified afterward → `data()` with a `watch` on the prop to reset when it changes externally.
-
----
-
-### Pitfall 12: Keyboard Shortcuts — Conflict with Text Inputs and Browser Defaults
-
-**Phase:** UX improvements (keyboard shortcuts)
-
-**What goes wrong:** A global `keydown` listener on `window` fires regardless of focused element. Shortcuts like `/` (focus search), `n` (new folder), `Delete` (delete selected) are also typed characters. They must not fire when any ` `, `