---
phase: 6.2
slug: close-v1-sharing-cloud-delete-csv-export-gaps
status: draft
shadcn_initialized: false
preset: none
created: 2026-05-31
---

# Phase 6.2 — UI Design Contract

> Visual and interaction contract for Phase 6.2: Close v1 sharing + cloud-delete + CSV export gaps.
> Generated by gsd-ui-researcher. Verified by gsd-ui-checker.

---

## Design System

| Property | Value |
|----------|-------|
| Tool | none — pure Tailwind CSS v3.4 |
| Preset | not applicable |
| Component library | none (Heroicons inline SVG only) |
| Icon library | Heroicons stroke, w-4 / w-5 sizes (inline SVG, no package) |
| Font | system-ui (browser default stack — no custom font loaded) |

**Source:** codebase scan — no `components.json`, no component registry, confirmed via directory listing.

---

## Spacing Scale

Declared values (all multiples of 4, mapped to Tailwind utilities):

| Token | Value | Tailwind Class | Usage |
|-------|-------|----------------|-------|
| xs | 4px | `gap-1`, `p-1` | Icon gaps, inline badge padding |
| sm | 8px | `gap-2`, `p-2` | Compact element spacing, button icon padding |
| md | 16px | `p-4`, `gap-4` | Default element spacing, card body padding |
| lg | 24px | `p-6`, `gap-6` | Modal body padding, section padding |
| xl | 32px | `p-8` | Empty state vertical padding |
| 2xl | 48px | `py-12` | Page-level empty state vertical rhythm |
| 3xl | 64px | n/a | Not used in this phase |

**Exceptions:**

- Icon-only action buttons: `min-h-[44px] min-w-[44px]` touch target — established in DocumentCard and maintained for all new icon buttons. Source: `DocumentCard.vue` line 43.
- Share row inline items: `py-2` (8px vertical) per row — matches existing `ShareModal.vue` recipient list rhythm.
- Permission dropdown in share creation row: no extra spacing; sits inline within the existing `flex gap-2` row.

---

## Typography

| Role | Size | Weight | Line Height | Tailwind Classes |
|------|------|--------|-------------|------------------|
| Body | 14px | 400 | 1.5 | `text-sm` |
| Label / caption | 12px | 600 | 1.4 | `text-xs font-semibold` |
| Heading (modal title) | 18px | 600 | 1.2 | `text-lg font-semibold` |
| Mono (timestamps, IDs) | 12px | 400 | 1.4 | `text-xs font-mono` |

**Source:** codebase scan — `ShareModal.vue` uses `text-lg font-semibold` for modal title, `text-sm` for body text, `text-xs` for labels and badges. `AuditLogTab.vue` uses `font-mono text-xs` for timestamps and IP addresses. All four roles are already present in the components being modified.

---

## Color

| Role | Value | Tailwind Class | Usage |
|------|-------|----------------|-------|
| Dominant (60%) | #F9FAFB | `bg-gray-50` | Page background, table header row |
| Secondary (30%) | #FFFFFF | `bg-white` | Cards, modals, table body, dropdown panels |
| Accent (10%) | #4F46E5 | `bg-indigo-600` / `text-indigo-600` | Primary action buttons, focus rings, topic pills, shared badge |
| Destructive | #EF4444 | `text-red-500` / `text-red-600` / `bg-red-50` | Remove access button, cloud delete failure modal warning text, error inline text |

**Accent reserved for (explicit list):**

1. Primary CTA buttons: "Share document", "Apply filters", "Export CSV", "Download" — `bg-indigo-600 hover:bg-indigo-700 text-white`
2. Focus rings on all text inputs and selects: `focus:ring-2 focus:ring-indigo-500`
3. "Shared" indicator pill on DocumentCard: `bg-indigo-50 text-indigo-600`
4. Permission badge when set to "edit" (distinguished from "view" gray): `bg-indigo-50 text-indigo-600` — matches topic pill pattern
5. View/Edit toggle active state: `bg-indigo-50 text-indigo-600`

**Destructive reserved for:**

1. "Remove access" inline link in ShareModal recipient row — `text-red-500 hover:text-red-700`
2. Cloud delete failure modal — warning message text `text-red-700`, border accent on the modal (see Component Contracts below)
3. Inline error text under inputs — `text-red-600 text-xs`

**Source:** codebase scan — colors extracted from `ShareModal.vue`, `DocumentCard.vue`, `AuditLogTab.vue`, `AdminUsersTab.vue`.

---

## Copywriting Contract

### Permission Dropdown (ShareModal — share creation row)

| Element | Copy |
|---------|------|
| Dropdown label (aria-label) | "Permission level" |
| Option: view | "Can view" |
| Option: edit | "Can edit" |
| Default selected | "Can view" |

### View/Edit Toggle (ShareModal — per share row)

| Element | Copy |
|---------|------|
| Toggle label: view active | "View" |
| Toggle label: edit active | "Edit" |
| Aria-label pattern | "Change permission for {handle}" |
| Optimistic error (toggle fails) | "Failed to update permission." |

### Cloud Delete Failure Modal

| Element | Copy |
|---------|------|
| Modal heading | "Cloud delete failed" |
| Body text | "The file could not be deleted from {provider}. Remove it from DocuVault anyway? The file will remain on {provider}." |
| Primary CTA (remove from app) | "Remove from app" |
| Secondary action (cancel) | "Cancel" |
| Aria-label for modal | "Cloud delete warning" |

**Note:** `{provider}` is replaced at runtime with the cloud provider display name (e.g., "Google Drive", "OneDrive"). If the provider name is unavailable, fall back to "your cloud storage".

### Audit Log — Daily Exports Section

| Element | Copy |
|---------|------|
| Section label | "Daily exports" |
| Dropdown label | "Select date" |
| Dropdown placeholder | "Choose a date" |
| Download button | "Download" |
| Empty state (no exports in bucket) | "No daily exports available." |
| Loading state (fetching list) | "Loading exports…" |

### Audit Log — CSV Export Fix (behavior only, no copy change)

The "Export CSV" button label is unchanged. The behavior changes from `window.location.href` to `fetch()` + Blob URL. No new copy needed — the button already reads "Export CSV".

### Audit Log — User Filter Label

| Element | Copy |
|---------|------|
| Filter field label (was "User") | "User handle" |
| Input placeholder (was "All users") | "All users" |

### Shared Badge Fix (DocumentCard — no copy change)

The "Shared" pill copy is unchanged (`Shared`). Only the v-if condition changes from `doc.share_count > 0` to `doc.is_shared`. No copy update.

### Error States

| Scenario | Copy |
|----------|------|
| Share creation — user not found | "User not found. Check the handle and try again." (unchanged, already in ShareModal) |
| Share creation — already shared | "This document is already shared with that user." (unchanged) |
| Share creation — generic error | "Something went wrong. Please try again." (unchanged) |
| Permission update failed | "Failed to update permission." |
| Daily export download failed | "Download failed. Please try again." |
| CSV export request failed | "Export failed. Please try again." |
| Cloud delete failure | See modal copy above. |

### Destructive Actions

| Action | Confirmation Approach |
|--------|-----------------------|
| Remove access (revoke share) | Optimistic — immediate removal on click, restore on API failure. No confirmation dialog. Matches existing pattern in `ShareModal.vue:handleRevoke()`. |
| Delete cloud document (default) | Browser `window.confirm()` replaced by the cloud delete failure modal only when the API returns `{"cloud_delete_failed": true}`. Normal delete (MinIO or successful cloud delete) keeps the existing `window.confirm()` on `DocumentView.vue`. |
| "Remove from app" (cloud delete failure path) | Confirmed via the cloud delete failure modal primary CTA. Single click on "Remove from app" proceeds without a second confirmation. |

---

## Component Contracts

### C-1: Permission Dropdown in ShareModal (share creation row)

**Location:** `frontend/src/components/sharing/ShareModal.vue` — insert between the handle input and the "Share document" button within the `flex gap-2` row.

**Markup contract:**

```
<select
  v-model="permission"
  aria-label="Permission level"
  class="border border-gray-300 rounded-lg px-3 py-2 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-indigo-500 shrink-0"
>
  <option value="view">Can view</option>
  <option value="edit">Can edit</option>
</select>
```

- `permission` reactive ref defaults to `"view"`
- Passed as `permission: permission.value` in the `shareDocument()` call
- Width: `shrink-0` — does not expand; native select width for 2 options is sufficient (~90px)
- Sits between handle input (`flex-1`) and Share button (`shrink-0`)

### C-2: View/Edit Toggle in ShareModal (per share row)

**Location:** `frontend/src/components/sharing/ShareModal.vue` — replace the static `<span class="text-xs bg-gray-100 text-gray-600 px-2 py-1 rounded-full font-medium">view</span>` in each recipient row.

**Visual spec:**

- Two adjacent pill buttons: "View" and "Edit"
- Active state: `bg-indigo-50 text-indigo-600 font-medium`
- Inactive state: `bg-gray-100 text-gray-600`
- Both use: `text-xs px-2 py-1 rounded-full font-medium transition-colors`
- Wrapper: `flex rounded-full overflow-hidden border border-gray-200` (pill group container)
- Spacing: no gap between the two buttons (joined pills)

**Interaction:**

- Clicking the inactive state calls `PATCH /api/shares/{id}` with `{ permission: "view" | "edit" }`
- Optimistic update: toggle state immediately on click, revert on API error
- Loading state: button shows spinner inline while PATCH is in-flight; both toggle buttons `opacity-50 pointer-events-none` during in-flight state
- Error: show `error.value = "Failed to update permission."` below the row (same `text-xs text-red-600 mt-2` pattern)

### C-3: Cloud Delete Failure Modal

**Location:** New component `frontend/src/components/documents/CloudDeleteWarningModal.vue` OR inline conditional block in `DocumentView.vue` — inline in DocumentView is preferred (mirrors how the inline delete confirmation panel works in AdminUsersTab).

**Trigger:** `confirmDelete()` in `DocumentView.vue` receives `{ cloud_delete_failed: true }` from the delete API call. Instead of navigating away, set `showCloudDeleteWarning.value = true`.

**Visual spec:**

```
Fixed overlay: bg-black/40 flex items-center justify-center z-50
Panel: bg-white rounded-2xl shadow-xl p-6 max-w-sm w-full mx-4
```

- Heading: `text-lg font-semibold text-gray-900 mb-2` — "Cloud delete failed"
- Body: `text-sm text-gray-600 mb-6` — full warning sentence (see Copywriting Contract)
- Warning icon: Heroicons `ExclamationTriangleIcon` w-5 h-5 text-amber-500 inline before heading, or `text-red-600` — use amber-500 (warning, not error) to match the semantic distinction: this is a degraded-success, not a hard failure
- Button row: `flex gap-3 mt-4 justify-end`
  - Primary ("Remove from app"): `bg-red-600 hover:bg-red-700 text-white text-sm px-4 py-2 rounded-lg transition-colors`
  - Secondary ("Cancel"): `border border-gray-300 text-gray-700 text-sm px-4 py-2 rounded-lg hover:bg-gray-50 transition-colors`
- `role="dialog"` `aria-modal="true"` `aria-labelledby` on the panel
- Click-outside (`@click.self`) closes the modal (same as ShareModal)
- Pressing "Cancel" closes modal; document is NOT deleted. The pending delete is abandoned.
- Pressing "Remove from app" calls `DELETE /api/documents/{id}?remove_only=true`, then navigates to `/` on success.

**Warning icon Tailwind:** `text-amber-500` (Tailwind `amber-500` = `#F59E0B`) — signals a recoverable warning state distinct from the hard red error color.

### C-4: Daily Exports Section in AuditLogTab

**Location:** `frontend/src/components/admin/AuditLogTab.vue` — add as a new section below the existing pagination block.

**Visual spec:**

- Section separator: `<div class="border-t border-gray-100 mt-6 pt-6">`
- Section label: `<h3 class="text-sm font-semibold text-gray-700 mb-3">Daily exports</h3>`
- Controls row: `<div class="flex items-end gap-3">`
  - Date select: `<select class="text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500 bg-white">` populated from API response
  - Download button: `<button class="bg-indigo-600 hover:bg-indigo-700 text-white text-sm px-4 py-2 rounded-lg disabled:opacity-50 transition-colors">Download</button>`
  - Button disabled when no date is selected or download is in-flight
- Loading state (fetching list): `<p class="text-sm text-gray-400">Loading exports…</p>` replaces the select
- Empty state (bucket empty): `<p class="text-sm text-gray-400 italic">No daily exports available.</p>` replaces the select
- Download in-flight: spinner inline in Download button (same `animate-spin` pattern as ShareModal submit button)

**Date option format in select:** `YYYY-MM-DD` as displayed value (e.g., "2026-05-30"). Options sorted newest-first. `value` attribute is the date string (used to construct the API call).

### C-5: Audit Log User Filter Label Update

**Location:** `frontend/src/components/admin/AuditLogTab.vue` — filter bar.

- Change `<label>` text from "User" to "User handle"
- Change `v-model` binding from `filters.user_id` to `filters.user_handle` (or rename the reactive property)
- Placeholder stays "All users"
- No visual change otherwise; same `text-sm border border-gray-300 rounded-lg px-3 py-2` input styling

---

## State Inventory

Every interactive element in this phase must handle these states:

| Component | States Required |
|-----------|----------------|
| Permission dropdown (C-1) | default (view), changed (edit), disabled (during submit) |
| View/Edit toggle (C-2) | view-active, edit-active, loading (in-flight PATCH), error |
| Cloud delete warning modal (C-3) | hidden, visible, removing (in-flight remove_only call) |
| Daily exports select (C-4) | loading, empty, populated, selection-made |
| Daily exports download button (C-4) | default, disabled (no selection), loading (in-flight download), error |
| CSV export button | default, loading (in-flight fetch), error |
| Share creation button | default, disabled (empty handle), loading, error |

---

## Accessibility Contract

- All new interactive elements have an `aria-label` or are labelled by a visible `<label>` element
- Cloud delete failure modal: `role="dialog"` `aria-modal="true"` `aria-labelledby="cloud-delete-modal-title"` on the panel; focus trapped within modal while open
- View/Edit toggle buttons: each `<button>` has `aria-pressed` reflecting the current active state; wrapper has `role="group"` with `aria-label="Permission"`
- Permission dropdown: `aria-label="Permission level"` (no visible label needed — sits inline)
- All destructive buttons use `text-red-600` or `bg-red-600` and include descriptive accessible names
- Minimum touch target `min-h-[44px] min-w-[44px]` applied to all icon-only buttons; inline text buttons (e.g., "Remove access", "Cancel") do not require the 44px minimum

---

## Registry Safety

| Registry | Blocks Used | Safety Gate |
|----------|-------------|-------------|
| shadcn official | none | not applicable — shadcn not initialized |
| Third-party | none | not applicable |

No component library. No registry. All components hand-built with Tailwind CSS following established project patterns.

---

## 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:** pending
