Files
curo1305 67edc19a36 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>
2026-05-30 11:57:54 +02:00

32 KiB
Raw Permalink Blame History

phase, slug, status, shadcn_initialized, preset, created
phase slug status shadcn_initialized preset created
5 cloud-storage-backends approved false none 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>
  1. 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"
/>
  1. 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.

  1. 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):

<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:

<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

  • Dimension 1 Copywriting: PASS
  • Dimension 2 Visuals: PASS
  • Dimension 3 Color: PASS
  • Dimension 4 Typography: PASS
  • Dimension 5 Spacing: PASS
  • Dimension 6 Registry Safety: PASS

Approval: approved 2026-05-28