---
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 1–4.

---

## 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 1–4:

| 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 1–4 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 1–4. 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 1–4:

| 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
