docs(05): add UAT, UI-SPEC, deferred items, debug notes; refine plans 09-11

Plan refinements: Vitest tests added to 09/10 must-haves, explicit
mock_flow two-tuple pattern in 10, test_admin_api.py fixture usage in 11.
New artifacts: UAT checklist, UI-SPEC, deferred-items, debug investigation
for cloud-doc-operations-fail.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
curo1305
2026-05-30 11:57:54 +02:00
parent 34f012b4e8
commit 67edc19a36
7 changed files with 1115 additions and 23 deletions
@@ -0,0 +1,760 @@
---
phase: 5
slug: cloud-storage-backends
status: approved
shadcn_initialized: false
preset: none
created: 2026-05-28
---
# Phase 5 — UI Design Contract
> Visual and interaction contract for Phase 5: Cloud Storage Backends.
> Generated by gsd-ui-researcher, verified by gsd-ui-checker.
> Design system is vanilla Tailwind CSS (no shadcn) — matches Phases 14.
---
## Design System
| Property | Value |
|----------|-------|
| Tool | none |
| Preset | not applicable |
| Component library | none |
| Icon library | inline SVG, stroke-based, w-4 h-4 for nav, w-5 h-5 for status icons |
| Font | system-ui (Tailwind default) |
No shadcn gate applies — this is a Vue 3 / Tailwind project without shadcn.
---
## Spacing Scale
All values match the existing 8-point scale already established in Phases 14:
| Token | Value | Tailwind | Usage |
|-------|-------|----------|-------|
| xs | 4px | `gap-1`, `p-1` | Icon gaps, tight inline pairs |
| sm | 8px | `gap-2`, `p-2` | Badge padding, button icon gap |
| md | 16px | `gap-4`, `p-4` | Default element spacing |
| lg | 24px | `p-6` | Section card inner padding |
| xl | 32px | `p-8` | Page padding (SettingsView wrapper) |
| 2xl | 48px | `mb-12` | Major section breaks (not used in this phase) |
| 3xl | 64px | — | Page-level spacing (not used in this phase) |
Exceptions:
- Provider card row inner padding: `px-4 py-3` (12px top/bottom, 16px sides) — matches existing admin table row density
- Modal inner padding: `p-6` (lg)
- Touch target minimum: `min-h-[44px]` on all primary action buttons in modal (existing ConfirmBlock contract)
- Badge/pill label padding: `px-2 py-0.5` (status badges) and `px-1.5 py-0.5` ("Recommended" tag) — optical micro-sizing for pill text, carry-forward from Phases 14 badge pattern
- Sidebar section header icon gap: `gap-0.5` (2px) between chevron and nav link — optical icon alignment, matches existing Folders section pattern
---
## Typography
All roles match the locked type scale from Phases 14. Exactly 4 distinct font size tokens are in use.
| Role | Tailwind Classes | Usage in Phase 5 |
|------|-----------------|------------------|
| Page heading | `text-2xl font-semibold text-gray-900` | "Settings" page title (unchanged) |
| Section heading | `text-xl font-semibold text-gray-800` | Tab content section headers (e.g. "Connected providers"); also: modal header `<h3>` in CloudCredentialModal (same size role) |
| Subsection heading | `text-sm font-semibold text-gray-900` | Provider name inside card, modal section labels |
| Body | `text-sm text-gray-700 leading-relaxed` | Description text, modal helper text, provider display names |
| Secondary body | `text-sm text-gray-600` | Status description, connected-at date |
| Meta / label | `text-xs text-gray-500` | "Connected on …" date stamps, badge text on status pills |
Font size token summary (4 tokens, no others permitted):
- `text-2xl` — Page heading
- `text-xl` — Section heading (also: modal header)
- `text-sm` — Body / Subsection heading
- `text-xs` — Meta / Label
Weights in use: 400 (regular — body, secondary, meta) and 600 (semibold — headings, button labels, subsection labels). No additional weights introduced.
Body line height: `leading-relaxed` (1.625) — applied to the `text-sm text-gray-700` role. Heading line height: `leading-tight` (1.25) — applied to `text-2xl` and `text-xl` heading roles.
---
## Color
Matches 60/30/10 contract locked in Phases 14:
| Role | Tailwind Value | Usage |
|------|---------------|-------|
| Dominant (60%) | `bg-gray-50` | Page background |
| Secondary (30%) | `bg-white` + `border-gray-200` | Section cards, modal background, provider rows |
| Accent (10%) | `indigo-600` / `indigo-700` | Primary buttons (Connect {provider}, Save, Reconnect {provider}), active tab underline, focus rings |
| Destructive | `red-600` / `red-700` | Remove/Disconnect button, disconnect-all action, error banner background tint |
Accent (`indigo-600`) is reserved for:
- "Connect {provider}" button on unconnected providers
- "Reconnect {provider}" button on REQUIRES_REAUTH providers
- "Save" button in WebDAV/Nextcloud credential modal
- Active tab indicator underline in SettingsView tab strip
- Focus rings (`focus:ring-indigo-500`) on all inputs in the credential modal
Additional semantic colors introduced by Phase 5:
| Role | Tailwind Classes | Usage |
|------|-----------------|-------|
| Success (ACTIVE status) | `bg-green-100 text-green-700` | ACTIVE badge |
| Warning (REQUIRES_REAUTH) | `bg-yellow-100 text-yellow-800` | REQUIRES_REAUTH badge, warning banner background |
| Error (ERROR status) | `bg-red-100 text-red-700` | ERROR badge |
| Neutral (not connected) | `bg-gray-100 text-gray-600` | "Not connected" badge |
| Warning banner | `bg-yellow-50 border border-yellow-200 text-yellow-800` | REQUIRES_REAUTH inline banner within provider row |
| Error banner | `bg-red-50 border border-red-200 text-red-700` | OAuth error banner (persistent, query param triggered) |
---
## Focal Point
**Primary focal anchor for the Cloud Storage tab view:** The provider list rows — specifically the status badges and action buttons on the right side of each row. This is where the user's eye is directed: badges communicate current state at a glance, action buttons drive the next step.
Visual hierarchy to support this: provider name (`font-semibold`) draws attention first, status badge follows as inline confirmation, action button column (`shrink-0`, right-aligned) provides a predictable vertical target for scanning. The "Disconnect all cloud storage" link at the bottom is intentionally low-contrast (plain `text-red-600` text link) to keep it out of the primary scan path.
---
## New UI Surfaces
### Surface 1: SettingsView Tab Conversion
**Change:** Convert SettingsView from flat stacked sections to a 3-tab layout matching AdminView's pattern exactly.
**Tab structure:**
```
tabs = [
{ id: 'preferences', label: 'Preferences' },
{ id: 'ai', label: 'AI Configuration' },
{ id: 'cloud', label: 'Cloud Storage' },
]
```
**Tab strip (copy AdminView verbatim):**
```
<div class="flex border-b border-gray-200 mb-6">
<button
v-for="tab in tabs"
:key="tab.id"
@click="activeTab = tab.id"
class="px-4 py-2 text-sm font-semibold border-b-2 transition-colors"
:class="activeTab === tab.id
? 'text-indigo-600 border-indigo-600'
: 'text-gray-500 hover:text-gray-700 border-transparent'"
>
{{ tab.label }}
</button>
</div>
```
**Tab content mapping:**
- `preferences` tab: existing "Document Preferences" section (pdf_open_mode radios) — extracted into `SettingsPreferencesTab.vue`
- `ai` tab: existing "AI configuration" section (admin-managed notice) — extracted into `SettingsAiTab.vue`
- `cloud` tab: new cloud storage management — `SettingsCloudTab.vue`
**Active tab on mount:** `preferences` (first tab, user's existing default context)
**Active tab override on OAuth redirect:** If `?cloud_connected=` or `?cloud_error=` query param is present in `onMounted`, set `activeTab = 'cloud'` before clearing the query params.
**SettingsView wrapper:** Retain `p-8 max-w-3xl mx-auto`. The heading ("Settings") and description stay above the tab strip.
---
### Surface 2: SettingsCloudTab — Provider Cards
**Component:** `frontend/src/components/settings/SettingsCloudTab.vue`
**Layout:** A single section card (`bg-white border border-gray-200 rounded-xl p-6`) containing:
1. Section heading: `<h3 class="text-xl font-semibold text-gray-800 mb-1">Cloud Storage</h3>`
2. Description: `<p class="text-sm text-gray-600 mb-5">Connect a cloud storage provider to use as a document destination.</p>`
3. Provider list: one row per provider, stacked with `divide-y divide-gray-100`
4. "Disconnect all" action (shown only when at least one provider is ACTIVE or ERROR)
**Provider row structure** (one row per provider, always shown — all 4 providers always visible):
```
<div class="flex items-center justify-between py-3 gap-4">
<!-- Left: icon + name + status badge -->
<div class="flex items-center gap-3 min-w-0">
<!-- Provider icon: w-8 h-8 rounded-lg bg-gray-50 border border-gray-200 flex items-center justify-center -->
<span class="text-sm font-semibold text-gray-900">{{ provider.label }}</span>
<StatusBadge :status="connection?.status ?? 'not_connected'" />
</div>
<!-- Right: action button(s) -->
<div class="flex items-center gap-2 shrink-0">
<!-- See button specs per status below -->
</div>
</div>
```
**Provider labels and icons:**
| Provider key | Display label | Icon description |
|-------------|--------------|-----------------|
| `google_drive` | Google Drive | Cloud icon with "G" text or inline SVG cloud, `text-blue-500` |
| `onedrive` | OneDrive | Cloud icon, `text-sky-500` |
| `nextcloud` | Nextcloud | Cloud icon, `text-orange-500` |
| `webdav` | WebDAV server | Server/database icon, `text-gray-500` |
Provider icons are inline SVG, `w-5 h-5`, stroke-based consistent with the project's existing icon vocabulary. Use the standard cloud path from the project's SVG set. Color is applied via `class` on the SVG element.
**Status badges** (pill component, `StatusBadge.vue` or inline):
| Status | Classes | Label |
|--------|---------|-------|
| `ACTIVE` | `bg-green-100 text-green-700 text-xs font-semibold px-2 py-0.5 rounded-full` | Active |
| `REQUIRES_REAUTH` | `bg-yellow-100 text-yellow-800 text-xs font-semibold px-2 py-0.5 rounded-full` | Reconnect needed |
| `ERROR` | `bg-red-100 text-red-700 text-xs font-semibold px-2 py-0.5 rounded-full` | Error |
| `not_connected` | `bg-gray-100 text-gray-600 text-xs font-semibold px-2 py-0.5 rounded-full` | Not connected |
**Action button label pattern:** All action buttons include the provider's display label as the noun object. The `{provider}` placeholder resolves to the display label from the provider labels table above (e.g., "Google Drive", "OneDrive", "Nextcloud", "WebDAV server").
**Action buttons per status:**
| Status | Button label | Classes |
|--------|--------------|---------|
| `not_connected` | "Connect {provider}" | `bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-semibold px-4 py-2 rounded-lg transition-colors` |
| `ACTIVE` | "Remove {provider}" | `text-sm px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 text-gray-700 transition-colors` |
| `REQUIRES_REAUTH` | "Reconnect {provider}" (primary) + "Remove {provider}" (secondary) | Reconnect: `bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-semibold px-4 py-2 rounded-lg transition-colors`; Remove: `text-sm px-3 py-2 text-gray-500 hover:text-gray-700 transition-colors` |
| `ERROR` | "Remove {provider}" | `text-sm px-4 py-2 border border-red-300 rounded-lg hover:bg-red-50 text-red-600 transition-colors` |
Example rendered labels: "Connect Google Drive", "Reconnect OneDrive", "Remove Nextcloud", "Remove WebDAV server".
**Loading state:** When a Connect or Reconnect button is clicked and the API call is in-flight, the button shows a spinner icon (`animate-spin`) replacing the label, and is `disabled opacity-50 cursor-not-allowed`. Width is held fixed to prevent layout shift (use `min-w-[160px]` on the button — wide enough for the longest provider name combination).
**Connected-at date:** For ACTIVE and ERROR connections, show below the badge: `<span class="text-xs text-gray-500">Connected {date}</span>`. Use locale date format: `new Date(connection.connected_at).toLocaleDateString()`. Not shown for `not_connected` or `REQUIRES_REAUTH`.
**REQUIRES_REAUTH inline banner:** Below the row (not inside it), conditionally rendered per provider:
```
<div
v-if="connection?.status === 'REQUIRES_REAUTH'"
class="mx-0 mb-2 p-3 rounded-lg bg-yellow-50 border border-yellow-200 flex items-start gap-2"
>
<svg class="w-4 h-4 text-yellow-600 shrink-0 mt-0.5" ...warning-triangle-icon... />
<p class="text-sm text-yellow-800">
Your {{ provider.label }} connection needs to be re-authorized.
Click <strong>Reconnect {{ provider.label }}</strong> to restore access.
</p>
</div>
```
**"Disconnect all" action:** Rendered at the bottom of the section card, below the provider list. Only visible when at least one connection exists with status `ACTIVE` or `ERROR`.
```
<div class="pt-4 border-t border-gray-100 flex justify-end">
<button
@click="showDisconnectAll = true"
class="text-sm text-red-600 hover:text-red-700 hover:underline font-medium transition-colors"
>
Disconnect all cloud storage
</button>
</div>
```
Clicking opens a confirmation block using the existing `ConfirmBlock` component pattern:
```
Message: "This will permanently delete all cloud storage credentials. Your documents will remain in DocuVault, but cloud documents may become inaccessible."
Confirm label: "Disconnect all"
Cancel label: "Keep all connected"
confirmClass: "bg-red-600 hover:bg-red-700 text-white"
```
**Empty state:** No explicit empty state needed — the tab always shows all 4 providers (3 OAuth + 1 WebDAV), each with "Not connected" status. The section heading description serves as the orientation copy.
---
### Surface 3: OAuth Success / Error Toast
**Trigger:** `onMounted` in SettingsView reads `window.location.search` for `?cloud_connected={provider}` or `?cloud_error={message}`. After reading, replace the URL using `router.replace({ path: '/settings' })` to clean the query params.
**Success toast:**
```
<div
v-if="oauthSuccessProvider"
class="fixed top-4 right-4 z-50 flex items-center gap-3 bg-white border border-green-200 rounded-xl shadow-lg px-5 py-4 max-w-sm"
>
<svg class="w-5 h-5 text-green-500 shrink-0" ...checkmark-circle-icon... />
<div class="flex-1 min-w-0">
<p class="text-sm font-semibold text-gray-900">{{ providerLabel }} connected</p>
<p class="text-xs text-gray-500 mt-0.5">Your files are now available in the sidebar.</p>
</div>
<button
@click="oauthSuccessProvider = null"
aria-label="Dismiss notification"
class="text-gray-400 hover:text-gray-600 shrink-0"
>
<svg class="w-4 h-4" ...x-icon... />
</button>
</div>
```
- Auto-dismiss after 5000 ms (`setTimeout(() => oauthSuccessProvider = null, 5000)`)
- Position: `fixed top-4 right-4 z-50` — top-right corner, above all content
- Shadow: `shadow-lg`
**Error banner** (persistent — requires manual dismissal):
```
<div
v-if="oauthError"
class="mb-6 flex items-start gap-3 bg-red-50 border border-red-200 rounded-xl px-5 py-4"
>
<svg class="w-5 h-5 text-red-500 shrink-0 mt-0.5" ...exclamation-circle-icon... />
<div class="flex-1 min-w-0">
<p class="text-sm font-semibold text-red-700">Connection failed</p>
<p class="text-sm text-red-600 mt-0.5">{{ oauthError }}</p>
<p class="text-xs text-red-500 mt-1">Try connecting again. If the problem persists, check that the app has the correct permissions in your provider's account settings.</p>
</div>
<button
@click="oauthError = null"
aria-label="Dismiss error"
class="text-red-400 hover:text-red-600 shrink-0"
>
<svg class="w-4 h-4" ...x-icon... />
</button>
</div>
```
- Position: inline, rendered above the section card inside the "Cloud Storage" tab content
- Persistent until manually dismissed or page navigation
- The `?cloud_error=` value is URL-decoded and displayed as the error message body
**Provider label mapping** (for toast message):
| Query param value | Display label |
|------------------|--------------|
| `google_drive` | Google Drive |
| `onedrive` | OneDrive |
| `nextcloud` | Nextcloud |
| `webdav` | WebDAV server |
---
### Surface 4: WebDAV / Nextcloud Credential Modal
**Trigger:** Clicking "Connect {provider}" on the Nextcloud or WebDAV provider row.
**Component:** `CloudCredentialModal.vue` — a centered modal overlay.
**Overlay:** `fixed inset-0 bg-gray-900 bg-opacity-40 z-40 flex items-center justify-center p-4`
**Modal panel:** `bg-white rounded-xl shadow-xl w-full max-w-md p-6`
**Header:**
```
<div class="flex items-center justify-between mb-5">
<h3 class="text-xl font-semibold text-gray-900">Connect {{ providerLabel }}</h3>
<button
@click="close"
aria-label="Close modal"
class="text-gray-400 hover:text-gray-600 transition-colors"
>
<svg class="w-5 h-5" ...x-icon... />
</button>
</div>
```
Note: The modal `<h3>` uses `text-xl font-semibold` — the Section heading role from the typography scale.
**Form fields:**
1. **Server URL** (Nextcloud and WebDAV only — not shown for OAuth providers):
```
<label class="block text-sm font-semibold text-gray-900 mb-1">Server URL</label>
<input
type="url"
v-model="serverUrl"
placeholder="https://nextcloud.example.com/remote.php/dav/files/username/"
class="block w-full rounded-lg px-3 py-2 text-sm border border-gray-300 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
/>
<p class="text-xs text-gray-500 mt-1">Full WebDAV endpoint URL including username path segment.</p>
```
2. **Username:**
```
<label class="block text-sm font-semibold text-gray-900 mb-1 mt-4">Username</label>
<input
type="text"
v-model="username"
autocomplete="username"
class="block w-full rounded-lg px-3 py-2 text-sm border border-gray-300 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
/>
```
3. **Auth method toggle** (radio group, displayed between Username and Password fields):
```
<div class="mt-4 mb-2">
<p class="text-sm font-semibold text-gray-900 mb-2">Authentication method</p>
<div class="space-y-2">
<label class="flex items-start gap-3 cursor-pointer">
<input type="radio" value="app_password" v-model="authMethod"
class="mt-0.5 text-indigo-600 focus:ring-indigo-500" />
<div>
<span class="text-sm font-semibold text-gray-900">App password</span>
<span class="ml-2 bg-green-100 text-green-700 text-xs font-semibold px-1.5 py-0.5 rounded">Recommended</span>
<p class="text-xs text-gray-500 mt-0.5">
Can be revoked individually without changing your main account password.
</p>
</div>
</label>
<label class="flex items-start gap-3 cursor-pointer">
<input type="radio" value="account_password" v-model="authMethod"
class="mt-0.5 text-indigo-600 focus:ring-indigo-500" />
<div>
<span class="text-sm font-semibold text-gray-900">Account password</span>
<p class="text-xs text-gray-500 mt-0.5">
Simpler to set up, but revoking access requires changing your entire account password.
</p>
</div>
</label>
</div>
</div>
```
Default selected: `app_password`.
4. **Password / App password field:**
```
<label class="block text-sm font-semibold text-gray-900 mb-1 mt-4">
{{ authMethod === 'app_password' ? 'App password' : 'Password' }}
</label>
<input
type="password"
v-model="password"
autocomplete="current-password"
class="block w-full rounded-lg px-3 py-2 text-sm border border-gray-300 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
/>
```
**Validation error display** (inline, shown below offending field):
```
<p class="text-xs text-red-600 mt-1">{{ fieldError }}</p>
```
**Connection test error** (shown above buttons after failed test):
```
<div
v-if="connectError"
class="mt-4 p-3 rounded-lg bg-red-50 border border-red-200"
>
<p class="text-sm font-semibold text-red-700">Connection failed</p>
<p class="text-sm text-red-600 mt-0.5">{{ connectError }}</p>
<p class="text-xs text-red-500 mt-1">Check that the server URL is correct, the credentials are valid, and the server allows WebDAV access from external clients.</p>
</div>
```
**Footer buttons:**
```
<div class="flex justify-end gap-3 mt-6">
<button
type="button"
@click="close"
class="text-sm px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
>
Keep current settings
</button>
<button
type="button"
@click="submit"
:disabled="saving"
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 min-h-[44px] min-w-[80px]"
>
<svg v-if="saving" class="w-4 h-4 animate-spin mx-auto" ...spinner... />
<span v-else>Connect {{ providerLabel }}</span>
</button>
</div>
```
**Dismiss behavior:** Clicking the X button, "Keep current settings", or pressing Escape closes the modal. Clicking the overlay background also closes. When `saving` is true, all close actions are disabled (prevent accidental dismissal during the API call).
---
### Surface 5: Cloud Provider Nodes in Sidebar
**Placement:** A new "Cloud Storage" section added to `AppSidebar.vue`, positioned immediately after the Folders section (after the `</div>` closing the Folders collapsible block), before the Topics section.
**Section header and collapsible pattern** (mirrors Folders section exactly):
```html
<div class="mt-3">
<div class="flex items-center gap-0.5">
<!-- Expand/collapse chevron -->
<button
@click="cloudExpanded = !cloudExpanded"
class="p-1 rounded hover:bg-gray-100 text-gray-400 hover:text-gray-600 transition-colors shrink-0"
:title="cloudExpanded ? 'Collapse cloud storage' : 'Expand cloud storage'"
>
<svg
class="w-3 h-3 transition-transform duration-150"
:class="cloudExpanded ? 'rotate-90' : ''"
fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</button>
<!-- "Cloud Storage" label — navigates to /settings?tab=cloud -->
<a
href="/settings"
class="nav-link flex-1 min-w-0"
>
<svg class="w-4 h-4 mr-2 shrink-0 text-sky-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z" />
</svg>
Cloud Storage
</a>
</div>
<!-- Collapsible content: one node per active connection -->
<template v-if="cloudExpanded">
<div v-if="loadingCloudConnections" class="pl-7 py-1 text-xs text-gray-400">Loading…</div>
<div v-else-if="activeCloudConnections.length === 0" class="pl-7 py-1 text-xs text-gray-400">
No cloud storage connected
</div>
<CloudProviderTreeItem
v-for="connection in activeCloudConnections"
:key="connection.id"
:connection="connection"
:depth="1"
/>
</template>
</div>
```
**`activeCloudConnections`:** Only connections with status `ACTIVE` are shown as tree nodes. `REQUIRES_REAUTH` and `ERROR` connections are not shown in the sidebar (user must go to Settings to resolve them).
**`CloudProviderTreeItem.vue`** — new component, mirrors `FolderTreeItem.vue` structure:
```html
<template>
<div>
<!-- Row -->
<div
class="flex items-center group"
:style="{ paddingLeft: `${depth * 12}px` }"
>
<!-- Expand/collapse arrow -->
<button
@click.prevent.stop="toggleExpand"
class="w-5 h-5 flex items-center justify-center text-gray-400 hover:text-gray-600 shrink-0 transition-colors"
:aria-label="expanded ? 'Collapse ' + connection.display_name : 'Expand ' + connection.display_name"
>
<svg
class="w-3 h-3 transition-transform duration-150"
:class="{ 'rotate-90': expanded }"
fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M9 5l7 7-7 7" />
</svg>
</button>
<!-- Provider name (click navigates to cloud folder root) -->
<button
@click="navigateToRoot"
class="flex items-center gap-2 flex-1 min-w-0 px-2 py-1.5 rounded-lg text-sm font-medium transition-colors"
:class="isActive
? 'bg-indigo-50 text-indigo-700'
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'"
>
<!-- Provider cloud icon (w-4 h-4, provider color) -->
<svg class="w-4 h-4 shrink-0" :class="providerIconColor" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z" />
</svg>
<span class="truncate">{{ connection.display_name }}</span>
</button>
</div>
<!-- Children: first-level cloud folders (lazy loaded) -->
<template v-if="expanded">
<div v-if="loading" class="pl-12 py-1 text-xs text-gray-400">Loading…</div>
<div v-else-if="children.length === 0" class="pl-12 py-1 text-xs text-gray-400">Empty</div>
<CloudFolderTreeItem
v-for="folder in children"
:key="folder.id"
:folder="folder"
:provider="connection.provider"
:depth="depth + 1"
/>
</template>
</div>
</template>
```
**Provider icon colors:**
| Provider | `providerIconColor` class |
|----------|--------------------------|
| `google_drive` | `text-blue-500` |
| `onedrive` | `text-sky-500` |
| `nextcloud` | `text-orange-500` |
| `webdav` | `text-gray-500` |
**Loading behavior for cloud folder expansion:**
- On first expand: show `text-xs text-gray-400` "Loading…" at `pl-12` (depth 1 * 12 = 12px left + icon space)
- On success: render `CloudFolderTreeItem` nodes
- On error: show `text-xs text-red-500` "Failed to load — tap to retry" at `pl-12`, clicking retries the fetch
- 60-second TTL cache handled server-side; frontend always calls the API on expand if `childrenLoaded === false`
**`CloudFolderTreeItem.vue`:** A simplified version of `FolderTreeItem.vue` for cloud folder nodes. Uses a plain folder icon (`text-gray-400`). Navigates to `/cloud/{provider}/{folder_id}` on click. Indentation via `depth * 12` matching the existing pattern. Lazy-loads nested children via the same expand/toggle mechanism.
**Depth / indentation:** Cloud tree nodes use the same `depth * 12` px left-padding formula as `FolderTreeItem`. Provider root is at depth 1 (same level as local root folders). Cloud sub-folders start at depth 2.
---
## Copywriting Contract
### Primary CTA labels
All action buttons that target a specific provider use the `{verb} {provider}` pattern. The `{provider}` token resolves to the display label from the provider labels table.
| Action | Button label pattern | Example |
|--------|---------------------|---------|
| Connect OAuth provider | "Connect {provider}" | "Connect Google Drive" |
| Connect WebDAV / Nextcloud (modal submit) | "Connect {provider}" | "Connect Nextcloud" |
| Reconnect (REQUIRES_REAUTH) | "Reconnect {provider}" | "Reconnect OneDrive" |
| Remove single active/error provider | "Remove {provider}" | "Remove Google Drive" |
| Remove single from REQUIRES_REAUTH row (secondary) | "Remove {provider}" | "Remove Nextcloud" |
| Disconnect all providers (trigger link) | "Disconnect all cloud storage" | (no provider token — applies to all) |
| Disconnect all (ConfirmBlock confirm label) | "Disconnect all" | |
| Cancel WebDAV/Nextcloud modal | "Keep current settings" | |
| Dismiss disconnect-single ConfirmBlock | "Keep connected" | |
| Dismiss disconnect-all ConfirmBlock | "Keep all connected" | |
### Status badge labels
| Status | Badge label |
|--------|------------|
| `ACTIVE` | Active |
| `REQUIRES_REAUTH` | Reconnect needed |
| `ERROR` | Error |
| `not_connected` | Not connected |
### Empty states
| Location | Copy |
|----------|------|
| Cloud tab, no active connections | (no explicit empty state — all 4 providers always shown with "Not connected" badge) |
| Sidebar cloud section, no active connections | "No cloud storage connected" |
| Cloud folder tree node, empty provider root | "Empty" |
| Cloud folder tree node, load error | "Failed to load — tap to retry" |
### Error messages
| Error scenario | Copy |
|---------------|------|
| WebDAV connection test failed (modal inline) | "Connection failed" (heading) + server error message (body) + "Check that the server URL is correct, the credentials are valid, and the server allows WebDAV access from external clients." |
| OAuth callback error (`?cloud_error=`) | "Connection failed" (heading) + decoded error value (body) + "Try connecting again. If the problem persists, check that the app has the correct permissions in your provider's account settings." |
| Disconnect failed (API error) | Toast or inline: "Failed to disconnect. Please try again." |
| Cloud folder load failed (sidebar tree) | "Failed to load — tap to retry" |
### REQUIRES_REAUTH inline banner
"Your {provider label} connection needs to be re-authorized. Click **Reconnect {provider label}** to restore access."
### Disconnect single confirmation
Uses existing `ConfirmBlock` component:
- Message: "This will permanently remove your {provider label} credentials from DocuVault. Your cloud documents will remain in your {provider label} account."
- Confirm label: "Remove {provider label}" (e.g. "Remove Google Drive")
- Cancel label: "Keep connected"
- `confirmClass`: `"bg-red-600 hover:bg-red-700 text-white"`
### Disconnect-all confirmation
Uses existing `ConfirmBlock` component:
- Message: "This will permanently delete all cloud storage credentials. Your documents will remain in DocuVault, but cloud documents may become inaccessible."
- Confirm label: "Disconnect all"
- Cancel label: "Keep all connected"
- `confirmClass`: `"bg-red-600 hover:bg-red-700 text-white"`
### OAuth success toast
"{provider label} connected" (heading) + "Your files are now available in the sidebar." (body)
---
## Component Inventory
New components introduced in Phase 5:
| Component path | Purpose | Extends |
|---------------|---------|---------|
| `frontend/src/components/settings/SettingsCloudTab.vue` | Cloud Storage tab content | New |
| `frontend/src/components/settings/SettingsPreferencesTab.vue` | Extracted Preferences tab (pdf_open_mode) | Extracted from SettingsView |
| `frontend/src/components/settings/SettingsAiTab.vue` | Extracted AI config tab | Extracted from SettingsView |
| `frontend/src/components/cloud/CloudCredentialModal.vue` | WebDAV / Nextcloud credential input modal | New |
| `frontend/src/components/cloud/CloudProviderTreeItem.vue` | Provider root node in sidebar tree | Mirrors FolderTreeItem |
| `frontend/src/components/cloud/CloudFolderTreeItem.vue` | Cloud sub-folder node in sidebar tree | Mirrors FolderTreeItem |
Modified components:
| Component path | Change |
|---------------|--------|
| `frontend/src/views/SettingsView.vue` | Convert to 3-tab layout; add OAuth param handling in `onMounted`; add success toast + error banner state |
| `frontend/src/components/layout/AppSidebar.vue` | Add "Cloud Storage" collapsible section below Folders |
New Pinia store:
| Store | State |
|-------|-------|
| `frontend/src/stores/cloudConnections.js` | `connections: []`, `loading: bool`, `error: string\|null`. Actions: `fetchConnections()`, `disconnect(id)`, `disconnectAll()` |
---
## Registry Safety
No shadcn registry. No third-party component registries. All UI is custom Tailwind + inline SVG matching the existing project pattern.
| Registry | Blocks Used | Safety Gate |
|----------|-------------|-------------|
| shadcn official | none | not applicable |
| third-party | none | not applicable |
---
## Interaction States Summary
| Component | States |
|-----------|--------|
| Connect {provider} button | default, hover, loading (spinner + disabled), disabled (already connected) |
| Remove {provider} button | default, hover, loading (spinner + disabled) |
| Reconnect {provider} button | default, hover, loading (spinner + disabled) |
| WebDAV modal "Connect {provider}" button | default, hover, loading (spinner + disabled) |
| WebDAV modal inputs | default, focus (indigo ring), error (red border + error text below) |
| Status badge | static — no hover state |
| Success toast | visible (auto-dismiss 5s), dismiss on "Dismiss notification" X button |
| Error banner | visible (persistent), dismiss on "Dismiss error" X button |
| REQUIRES_REAUTH banner | visible when status === REQUIRES_REAUTH, disappears after reconnect |
| Cloud tree provider node | default, hover (bg-gray-100), active/selected (bg-indigo-50 text-indigo-700) |
| Cloud tree expand arrow | default (text-gray-400), hover (text-gray-600), expanded (rotate-90) |
| Cloud folder tree loading | "Loading…" text (text-xs text-gray-400) |
| Cloud folder tree error | "Failed to load — tap to retry" (text-xs text-red-500, cursor-pointer) |
---
## Checker Sign-Off
- [x] Dimension 1 Copywriting: PASS
- [x] Dimension 2 Visuals: PASS
- [x] Dimension 3 Color: PASS
- [x] Dimension 4 Typography: PASS
- [x] Dimension 5 Spacing: PASS
- [x] Dimension 6 Registry Safety: PASS
**Approval:** approved 2026-05-28