docs(phase-10): research UX & interaction

This commit is contained in:
curo1305
2026-06-14 22:04:12 +02:00
parent 71118076a4
commit c84dcb77c1
@@ -0,0 +1,923 @@
# Phase 10: UX & Interaction — Research
**Researched:** 2026-06-14
**Domain:** Vue 3 Options API / Composition API hybrid — frontend UX patterns
**Confidence:** HIGH (all findings grounded in direct source reading of the actual component files)
---
<user_constraints>
## User Constraints (from CONTEXT.md)
### Locked Decisions
**Toast Notifications (UX-10)**
- D-01: Position: bottom-right, stacking upward from the corner.
- D-02: Visual differentiation: colored left-border accent + inline icon per type. Green/success, red/error, amber/warning, blue/info. Toast body background white/neutral.
- D-03: Injection: `ToastContainer.vue` uses `<Teleport to="body">`.
- D-04: The `show(message, type, duration)` signature is locked from Phase 8's stub. Phase 10 implements rendering without changing the signature or any Phase 8 call sites.
**Empty States (UX-01)**
- D-05: Each context gets its own icon from the existing Heroicons-style set (stroke-based 24×24 viewBox).
- D-06: `EmptyState.vue` takes explicit props: `icon` (SVG path string or AppIcon name), `headline` (string), `subtext` (string).
- D-07: CTA is an optional named `#cta` slot. EmptyState.vue renders nothing where slot is empty.
**AppIcon SVG Centralization (CODE-05)**
- D-08: AppIcon.vue uses a `name → SVG path string` map. No new dependency.
- D-09: Outline/stroke only — no `variant` prop, no solid variant.
- D-10: Prop interface: `<AppIcon name="folder" class="w-4 h-4 text-amber-500" />`. `class` forwarded to outer `<svg>`. Unknown names log a warning in dev mode and render nothing.
**BreadcrumbBar (UX-12)**
- D-11: Props-driven — every view computes its own `segments` array and passes it to `BreadcrumbBar.vue`.
- D-12: `BreadcrumbBar.vue` emits `@navigate(index)` — parent handles navigation. Last segment is non-clickable plain text.
- D-13: Admin/settings/topics views provide static `segments` computed property. File manager and cloud folder views use folder store `breadcrumb` array.
**Keyboard Shortcuts (UX-05 through UX-08)**
- D-14: Global `keydown` listener in `mounted()` / `beforeUnmount()`. Guard: `['INPUT','TEXTAREA','SELECT'].includes(document.activeElement?.tagName) || document.activeElement?.isContentEditable` → early return.
- D-15: `/` → focus search bar; `Escape` → close modals + clear search; `U` → trigger upload file picker; `N` → start new folder inline input.
**Drag-and-Drop (UX-09, UX-11)**
- D-16: OS drop overlay: `dataTransfer.types.includes('Files')` detection. Overlay attached at `window`/`document` level, not inside `StorageBrowser`.
- D-17: Drag-to-move drop highlight: `ring-2 ring-inset ring-amber-300` on valid folder targets.
- D-18: `DocumentCard.vue` dedicated drag handle element to prevent `dragend`-then-`click` navigation.
### Claude's Discretion
- Icon name assignments in AppIcon.vue's internal map (researcher resolves below)
- Exact icon choices for each EmptyState context (researcher resolves below)
- Keyboard shortcut global listener architectural home (researcher resolves below)
- Exact query/store plumbing for `U` shortcut to reach upload input ref (researcher resolves below)
- Dropdown/modal clipping audit (researcher resolves below)
### Deferred Ideas (OUT OF SCOPE)
None — discussion stayed within phase scope.
</user_constraints>
<phase_requirements>
## Phase Requirements
| ID | Description | Research Support |
|----|-------------|------------------|
| UX-01 | EmptyState.vue across 7+ zero-content contexts | Component Inventory §3 maps all contexts |
| UX-02 | StorageBrowser 5-col skeleton grid during loading | Architecture Patterns §Skeleton Pattern |
| UX-03 | Sidebar folder tree + topics skeleton during loading | Architecture Patterns §Skeleton Pattern |
| UX-04 | Admin user table + audit log table skeleton rows | Architecture Patterns §Skeleton Pattern |
| UX-05 | `/` focuses search bar (no input focused) | Keyboard Shortcuts section |
| UX-06 | `Escape` closes any open modal + clears search | Keyboard Shortcuts section |
| UX-07 | `U` triggers file upload picker | Keyboard Shortcuts section; Upload Input Ref Chain |
| UX-08 | `N` starts new folder inline input | Keyboard Shortcuts section; New-Folder Trigger Chain |
| UX-09 | OS drag-onto-window → full-screen overlay → upload | Drag-and-Drop section |
| UX-10 | Toast notification system — implement Phase 8 stub | Toast System section |
| UX-11 | Drag-to-move end-to-end wiring + ring highlight | Drag-to-Move Current State section |
| UX-12 | Shared BreadcrumbBar.vue across all views | BreadcrumbBar Extraction section |
| UX-13 | Dropdown viewport-edge clipping fixes | Component Inventory §2 |
| UX-14 | Remove sidebar "New" folder button | AppSidebar.vue analysis |
| CODE-05 | Centralize ~66 inline SVGs into AppIcon.vue | Component Inventory §1 (full SVG audit) |
</phase_requirements>
---
## Summary
Phase 10 is a pure frontend UX phase. The backend is untouched. Every deliverable is a Vue component, store, or directive change. The codebase has been directly audited for this research document — all findings are from reading source files, not from training-data assumptions.
The key findings: (1) `StorageBrowser.vue` already has ~80% of drag-to-move implemented — folder `@dragover` / `@drop` handlers exist, `draggingFile` ref is tracked, and `ring-2 ring-inset ring-amber-300` highlight is already wired; the missing piece is that `emit('file-move')` succeeds but the drop from OS files (UX-09) is entirely separate. (2) `FolderBreadcrumb.vue` already exists as a component — `BreadcrumbBar.vue` (UX-12) renames and universalizes it, adding static segments support for admin/settings views. (3) `AppIcon.vue` is new; the SVG audit found **66 `<path>` elements across 29 files**, with **32 distinct path strings** mapping to **21 named icons**. (4) The folder picker dropdown in both `StorageBrowser.vue` and `DocumentCard.vue` is the primary clipping risk; `SearchableModelSelect.vue` already uses `<Teleport to="body">` with `getBoundingClientRect()` and is the correct reference pattern. (5) App.vue uses `<script setup>` (Composition API) despite the project's Options API convention — the global keyboard listener belongs in App.vue directly, not a separate component.
**Primary recommendation:** Work in 4 waves: (W0) create the foundation components (AppIcon, EmptyState, BreadcrumbBar, ToastContainer/store) with no wiring; (W1) wire toast call sites + empty states + skeletons across the app; (W2) keyboard shortcuts + OS drag-drop overlay; (W3) drag-to-move end-to-end + clipping fixes + UX-14 sidebar removal.
---
## Architectural Responsibility Map
| Capability | Primary Tier | Secondary Tier | Rationale |
|------------|-------------|----------------|-----------|
| Toast display | Frontend (Client) | — | Visual only; store holds state, ToastContainer renders via Teleport |
| Toast state | Pinia store (client) | — | Already stubbed in `stores/toast.js`; Phase 10 fills in reactive `toasts` array |
| EmptyState rendering | Frontend component | — | Pure presentational; receives props from parent |
| Keyboard shortcuts | App.vue (Client) | FileManagerView.vue | App.vue registers listener; shortcuts that need refs (U, N) delegate down through browserRef |
| OS drag-drop overlay | App.vue (Client) | — | Must be at window level, not inside StorageBrowser; App.vue is the correct mount point |
| Drag-to-move | StorageBrowser.vue | FileManagerView.vue | Smart component owns drag state; view wires `@file-move` to store action |
| BreadcrumbBar | Shared component | Each view (data) | Views compute segments arrays; BreadcrumbBar is display-only |
| SVG icon paths | AppIcon.vue | — | Single source of truth for all path strings |
| Skeleton loading | Per-component | — | Each component (StorageBrowser, AppSidebar, AdminUsersView, AdminAuditView) renders its own skeleton |
---
## Standard Stack
### Core (all already installed)
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| Vue 3 | ^3.5.x | Component framework | Project standard |
| Pinia | 2.x | Store for toast state | Project standard; `useToastStore` already stubbed |
| Tailwind CSS | 3.x | Utility classes for skeleton `animate-pulse`, ring highlight | Project standard |
| `<Teleport>` | Vue 3 built-in | Mount ToastContainer + fixed dropdowns outside stacking context | Already used in `SearchableModelSelect.vue` |
### No New Dependencies
Phase 10 requires zero new npm packages. All patterns use:
- Native HTML5 drag-and-drop API (`dragstart`, `dragover`, `drop`, `dragleave`, `dataTransfer`)
- Native `document.addEventListener('keydown', ...)` (already used in `DocumentPreviewModal.vue`)
- Vue built-ins: `<Teleport>`, `ref()`, `computed()`, `onMounted()`, `onUnmounted()`
- Tailwind `animate-pulse` for skeletons (already in project)
**Installation:** No `npm install` step required for Phase 10.
---
## Package Legitimacy Audit
No new packages are being installed in Phase 10. This section is intentionally empty.
**Packages removed due to slopcheck [SLOP] verdict:** none
**Packages flagged as suspicious [SUS]:** none
---
## Architecture Patterns
### System Architecture Diagram
```
User action (keyboard / drag / button click)
App.vue global keydown listener ──────────────► FileManagerView.vue
│ (/, Escape, U, N) (browserRef.startNewFolder()
│ DropZone inputRef.click())
├── ToastContainer.vue ◄── useToastStore.show(msg, type, duration)
│ (Teleport to body) │
│ └── called from: FileManagerView, SettingsAccountTab,
│ TotpEnrollment (Phase 8 call sites)
├── OS drag overlay ◄── window dragenter/dragover/dragleave/drop
│ (Teleport to body) (dataTransfer.types.includes('Files'))
│ └──► FileManagerView @upload handler
StorageBrowser.vue
├── BreadcrumbBar.vue (replaces FolderBreadcrumb.vue)
│ ← segments prop from parent view
│ → @navigate emit to parent
├── Skeleton rows (v-if="loading")
│ 5-col grid, animate-pulse gray blocks
├── EmptyState.vue (replaces inline empty text)
│ ← icon, headline, subtext props
│ └── #cta slot
└── Folder rows with drag-to-move
@dragover.prevent → ring highlight (already wired)
@drop.prevent → emit('file-move') (already wired)
Missing: toast on success + ring-2 ring-inset ring-amber-300 class present but
only activated when draggingFile is non-null (already correct)
AppSidebar.vue
├── Folder tree skeleton (replaces "Loading…" text)
├── Topics skeleton (replaces "Loading…" text)
├── Cloud skeleton (replaces "Loading…" text)
├── EmptyState micro (replaces "No folders yet", "No topics yet", etc.)
└── REMOVE: inline "New" folder button (UX-14)
```
### Recommended Project Structure (new files only)
```
frontend/src/
├── components/
│ ├── ui/
│ │ ├── AppIcon.vue NEW — icon registry (CODE-05)
│ │ ├── EmptyState.vue NEW — shared empty state (UX-01)
│ │ ├── BreadcrumbBar.vue NEW — replaces FolderBreadcrumb.vue (UX-12)
│ │ └── ToastContainer.vue NEW — renders toast stack (UX-10)
│ └── layout/
│ └── OsDragOverlay.vue NEW — full-screen file drop overlay (UX-09)
└── stores/
└── toast.js MODIFIED — add reactive toasts array + auto-dismiss
```
`FolderBreadcrumb.vue` is deleted after `BreadcrumbBar.vue` replaces it (CLAUDE.md: no dead code).
---
## Claude's Discretion — Resolved Findings
### 1. Keyboard Shortcut Home: App.vue Directly
**Determination:** Register the global `keydown` listener directly in `App.vue`.
**Reasoning:**
- `App.vue` already uses `<script setup>` (Composition API) — `onMounted()` and `onUnmounted()` are available natively.
- `App.vue` already holds the `browserRef` ref indirectly via its `<router-view>` rendering `FileManagerView.vue`. The shortcut handler needs two refs that cross component boundaries:
- `U` → needs access to `FileManagerView.vue`'s upload input
- `N` → needs access to `StorageBrowser.vue`'s `startNewFolder()` (exposed via `defineExpose`)
- Adding a `AppKeyboardManager.vue` child would require passing those refs back up anyway, introducing unnecessary prop-drilling or a Pinia store for refs.
- `DocumentPreviewModal.vue` shows the exact same pattern inline in a single component (direct `document.addEventListener` in `onMounted`).
- **Conclusion:** Add `onMounted`/`onUnmounted` + `onKeydown` handler to App.vue's `<script setup>`.
**What refs are needed in App.vue:**
- `searchInputRef` — a `ref` for the search `<input>` in `SearchBar.vue`. Currently `SearchBar.vue` does not expose its input ref. It will need `defineExpose({ focus() { inputEl.value?.focus() } })` added, and `FileManagerView.vue` will pass a `ref="searchBarRef"` to `<StorageBrowser>` which in turn exposes a `focusSearch()` method.
- `fileManagerRef` — the route view in `App.vue` renders `FileManagerView.vue` via `<router-view>`. App.vue can get a ref with `<router-view ref="routeViewRef" />`. Then the `U` shortcut calls `routeViewRef.triggerUpload?.()` and `N` calls `routeViewRef.startNewFolder?.()`.
- **Simpler alternative:** Use a Pinia store (`useShortcutStore`) with action methods that any component can call. This avoids the ref-chain problem entirely. However, given the project's "minimal code" mandate, a simple ref chain is probably lighter.
### 2. Upload Input Ref Chain (for `U` shortcut)
**Current chain (traced from source):**
```
App.vue (<router-view ref="routeViewRef">)
└── FileManagerView.vue (ref="browserRef" on <StorageBrowser>)
└── StorageBrowser.vue
└── <DropZone @files-selected="$emit('upload', $event)" />
└── DropZone.vue
└── <input ref="inputRef" type="file" class="hidden" />
└── triggerInput() { inputRef.value?.click() }
```
**Analysis:**
- `DropZone.vue` has `triggerInput()` but does NOT expose it via `defineExpose`. It needs `defineExpose({ triggerInput })` added.
- `StorageBrowser.vue` currently exposes only `startNewFolder` via `defineExpose`. It needs to also expose `triggerUpload() { dropZoneRef.value?.triggerInput() }`, requiring a `ref="dropZoneRef"` on `<DropZone>`.
- `FileManagerView.vue` uses `ref="browserRef"` on `<StorageBrowser>`. It needs to expose `triggerUpload() { browserRef.value?.triggerUpload() }` via `defineExpose`.
- App.vue then calls `routeViewRef.value?.triggerUpload?.()` — guarded with `?.` so it silently does nothing outside FileManagerView.
**Simpler alternative:** Emit a custom DOM event (`window.dispatchEvent(new Event('docuvault:trigger-upload'))`) from App.vue's keydown handler; DropZone listens for it in `onMounted`. This avoids ref chains entirely and is idiomatic for cross-tree communication without Pinia.
**Recommendation:** Use the ref chain (3 levels deep) to stay consistent with how `startNewFolder` is already exposed. Total additional code: ~8 lines across 3 files.
### 3. New-Folder Input Trigger Chain (for `N` shortcut)
**Current state (from source):**
- `StorageBrowser.vue` has `startNewFolder()` already defined and **already exposed** via `defineExpose({ startNewFolder })` (line 327).
- `FileManagerView.vue` already calls `browserRef?.startNewFolder()` from its `@new-folder` handler (line 18).
- `FileManagerView.vue` already holds `ref="browserRef"` on `<StorageBrowser>` (line 17).
**What's needed for `N` shortcut:**
- `FileManagerView.vue` needs `defineExpose({ startNewFolder: () => browserRef.value?.startNewFolder() })`.
- App.vue calls `routeViewRef.value?.startNewFolder?.()`.
**Guard required:** `N` shortcut must only fire when the current route is `/` or `/folders/:id` (file manager views). App.vue can check `route.path.startsWith('/') && !route.path.startsWith('/admin') && !route.path.startsWith('/settings')` or check `routeViewRef.value?.startNewFolder != null`.
### 4. Dropdown Clipping Audit (see Component Inventory §2 below)
### 5. EmptyState Icon Choices (see Component Inventory §3 below)
---
## Component Inventory
### Section 1: SVG Audit — Complete Inline Path Inventory
**Total unique `<path>` elements found:** 34 distinct `d` attribute values across 29 files.
**Total `<svg>` blocks:** 66 [ASSUMED: exact count from `grep -c "<svg"` totals = 66 confirmed by file-by-file count]
Below is the complete deduplicated icon map for `AppIcon.vue`. Names are camelCase per D-08 convention.
| AppIcon Name | SVG `d` value | Files that use this icon |
|---|---|---|
| `plus` | `M12 4v16m8-8H4` | StorageBrowser.vue (new folder btn) |
| `folder` | `M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V7z` | StorageBrowser (×2), AppSidebar, CloudFolderTreeItem, FolderRow, FolderTreeItem, StorageBrowser new-folder row |
| `folderMove` | `M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2V7z` | StorageBrowser file-actions move btn, DocumentCard move btn |
| `pencil` | `M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z` | StorageBrowser folder rename btn |
| `trash` | `M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16` | StorageBrowser file/folder delete btns |
| `share` | `M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z` | StorageBrowser share btn |
| `document` | `M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z` | StorageBrowser file icon, DocumentCard, SharedView |
| `chevronRight` | `M9 5l7 7-7 7` | AppSidebar (×2 chevron toggles), FolderBreadcrumb, TreeItem |
| `tag` | `M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z` | AppSidebar "All Topics" |
| `inbox` | `M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4` | AppSidebar "Shared with me" |
| `cloud` | `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` | AppSidebar Cloud, CloudStorageView, CloudProviderTreeItem, SettingsCloudTab |
| `shield` | `M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z` | AppSidebar "Admin" |
| `cog` | `M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z` | AppSidebar "Settings" (gear body) |
| `cogDot` | `M15 12a3 3 0 11-6 0 3 3 0 016 0z` | AppSidebar "Settings" (gear center — SECOND path in same `<svg>`) |
| `logout` | `M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1` | AppSidebar sign-out, AdminSidebar sign-out |
| `home` | `M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6` | AdminSidebar "Overview" |
| `users` | `M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z` | AdminSidebar "Users" |
| `chartBar` | `M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z` | AdminSidebar "Quotas" |
| `lightBulb` | `M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z` | AdminSidebar "AI Config" |
| `clipboardList` | `M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01` | AdminSidebar "Audit Log" |
| `upload` | `M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12` | DropZone.vue |
| `x` | `M6 18L18 6M6 6l12 12` | DocumentPreviewModal close, ShareModal close, SettingsView dismiss (×2) |
| `checkCircle` | `M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z` | SettingsView oauth success toast |
| `exclamationCircle` | `M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z` | SettingsView oauth error banner |
| `warning` | `M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z` | FolderDeleteModal warning icon, SettingsCloudTab warning |
| `copy` | `M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z` | AdminUsersView copy-password btn |
| `check` | `M5 13l4 4L19 7` | AdminUsersView password-copied confirmation |
| `refresh` | `M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15` | AdminUsersView regenerate-password btn |
| `pencilEdit` | `M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z` | SearchableModelSelect "use this model" |
| `chevronDown` | `M19 9l-7 7-7-7` | SearchableModelSelect dropdown chevron |
| `dots` | `M10 6a2 2 0 110-4 2 2 0 010 4zm0 6a2 2 0 110-4 2 2 0 010 4zm0 6a2 2 0 110-4 2 2 0 010 4z` | FolderRow three-dot menu (fill-based — different SVG attrs) |
| `checkMark` | `M4.5 12.75l6 6 9-13.5` | AccountView TOTP enabled checkmark |
| `fileDoc` | `M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z` | CloudFolderTreeItem file icon |
**Special cases for AppIcon.vue implementation:**
1. **`cog` and `cogDot`** — Settings icon in AppSidebar uses TWO `<path>` elements in one `<svg>`. AppIcon.vue needs to handle this. Solution: store as array `paths: { cog: ['path1...', 'path2...'] }` and render with `v-for`. Or create a single combined `d` value. Simpler: accept that `cog` is the only dual-path icon and handle it with `Array.isArray(paths[name])` check.
2. **`dots`** — FolderRow three-dot menu uses `fill="currentColor"` (not stroke). AppIcon.vue is stroke-only per D-09. Options: (a) keep the inline SVG in FolderRow and skip replacing it; (b) add a `filled` prop to AppIcon violating D-09; (c) replace the three-dot icon with a stroke equivalent. **Recommendation:** replace with the Heroicons stroke `ellipsis-vertical` path: `M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z` and keep stroke conventions. [ASSUMED: this Heroicons path data — verify by checking heroicons.com before using]
3. **`UploadProgress.vue` icons** — Uses `fill="currentColor"` solid icons from Heroicons 20px set for error-circle and checkmark-circle. Same issue as dots. These are small (w-5 h-5) and purely functional. Recommendation: keep inline or swap for equivalent stroke-based icons.
4. **`DocumentPreviewModal.vue` spinner** — Uses a non-standard spinner SVG with a `<circle>` element, not a `<path>`. Excluded from AppIcon.vue (already exists as `AppSpinner.vue` which the plan can use instead).
**Summary:** AppIcon.vue will hold approximately 28-30 named icons, covering all stroke-based instances. 4-6 fill-based icons (dots, error-circle, check-circle, spinner) stay as inline SVG or use AppSpinner.vue.
---
### Section 2: Dropdown Clipping Audit
| Component | File | Current Approach | Clipping Risk | Recommended Fix |
|---|---|---|---|---|
| Folder picker (move-to-folder) | `StorageBrowser.vue` lines 197-212 | `absolute right-0 top-full mt-1` inside file row | **HIGH** — file row is inside a scrollable `overflow-y-auto` div; picker will clip at the container boundary | `<Teleport to="body">` + `getBoundingClientRect()` fixed positioning (mirror SearchableModelSelect.vue) |
| Folder picker (move-to-folder) | `DocumentCard.vue` lines 75-92 | `absolute right-0 top-full mt-1` inside card | **HIGH** — same overflow clipping issue | `<Teleport to="body">` + `getBoundingClientRect()` fixed positioning |
| FolderRow three-dot menu | `FolderRow.vue` lines 49-65 | `absolute right-0 top-full mt-1` | **MEDIUM** — used in `TopicsView` / folder grid; may clip at viewport right edge if card is near edge | `<Teleport to="body">` + fixed positioning |
| SearchableModelSelect dropdown | `SearchableModelSelect.vue` lines 37-91 | Already uses `<Teleport to="body">` + `getBoundingClientRect()` with flip-above logic | **NONE — already fixed** | Reference implementation for others |
| FolderDeleteModal | `FolderDeleteModal.vue` | `fixed inset-0` overlay + `max-w-md mx-4` panel | **NONE** — fixed overlay always within viewport | No change |
| ShareModal | `ShareModal.vue` | `fixed inset-0` overlay + `max-w-md mx-4` panel | **NONE** — fixed overlay always within viewport | No change |
| DocumentPreviewModal | `DocumentPreviewModal.vue` | `fixed inset-0` full-screen | **NONE** | No change |
**Clipping pattern reference (from `SearchableModelSelect.vue`):**
```javascript
function updatePosition() {
const rect = inputEl.value.getBoundingClientRect()
const spaceBelow = window.innerHeight - rect.bottom
const dropH = Math.min(240, estimated_height)
if (spaceBelow >= dropH || spaceBelow > 120) {
dropdownStyle.value = { top: `${rect.bottom + 4}px`, left: `${rect.left}px`, width: `${rect.width}px` }
} else {
dropdownStyle.value = { bottom: `${window.innerHeight - rect.top + 4}px`, left: `${rect.left}px`, width: `${rect.width}px` }
}
}
```
For the folder picker in StorageBrowser, the trigger is the move-button. Capture its `getBoundingClientRect()` in the `toggleFolderPicker` handler and store in a reactive `pickerStyle` object. Pass to the Teleported `<ul>` via `:style="pickerStyle"`.
---
### Section 3: EmptyState Contexts — Complete Inventory
| Context | View/Component | Current Text | Proposed `headline` | Proposed `subtext` | Icon Name | CTA Slot |
|---|---|---|---|---|---|---|
| Root file list (no folders, no files) | `StorageBrowser.vue` (emptyMessage prop) | `"No folders yet"` (via FileManagerView prop) | "Nothing here yet" | "Create a folder or upload your first file to get started." | `folder` | Upload button |
| Folder — empty folder | `StorageBrowser.vue` (emptyMessage prop) | `"This folder is empty"` (via FileManagerView prop) | "This folder is empty" | "Upload files above or create a sub-folder." | `document` | None |
| Search — no results | `StorageBrowser.vue` lines 236-241 | `No items match "{{ searchQuery }}".` | `No results for "{{ searchQuery }}"` | "Try a different search term or clear the filter." | `search` (magnifying glass — needs new icon) | Clear search button |
| Shared with me | `SharedView.vue` lines 9-12 | "No documents shared with you yet." | "Nothing shared with you yet" | "When someone shares a document with you, it will appear here." | `inbox` | None |
| Topics list | `AppSidebar.vue` line 164 | "No topics yet" | "No topics yet" | "Topics are created automatically when documents are classified." | `tag` | None |
| Cloud connections | `CloudStorageView.vue` lines 22-27 | "No cloud storage connected." + Settings link | "No cloud storage connected" | "Connect Google Drive, OneDrive, Nextcloud, or a WebDAV server in Settings." | `cloud` | Settings link |
| Admin audit log — no entries | `AdminAuditView.vue` line 87 | "No audit log entries match the selected filters." | "No entries found" | "Try adjusting your filters or date range." | `clipboardList` | Clear filters button |
| Sidebar cloud — no active connections | `AppSidebar.vue` line 148 | "No cloud storage connected" | micro EmptyState (icon + 1 line) | "Connect in Settings" | `cloud` | Settings link |
| Sidebar folders — no folders | `AppSidebar.vue` line 103 | "No folders yet" | micro EmptyState (icon + 1 line) | "Create a folder in the file manager" | `folder` | None |
**Note on search icon:** The current codebase does not have a magnifying glass / search icon. The required path is the standard Heroicons outline search: `M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0` — add to AppIcon.vue as `search`. [ASSUMED: Heroicons path — verify before use]
**Note on sidebar micro empty states:** `AppSidebar.vue` uses `px-3 py-1 text-xs text-gray-400` for its "Loading…" / "No X yet" placeholders. `EmptyState.vue` should support a `size="sm"` prop or the sidebar can use a simplified version (icon at w-4 h-4 + single line of text) rather than the full centered layout that StorageBrowser uses.
---
## Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Toast stack z-index management | Custom z-index cascading | `<Teleport to="body">` | Teleport renders outside all stacking contexts; no z-index arithmetic needed |
| Dropdown position calculation | Manual `top`/`left` arithmetic | `getBoundingClientRect()` + fixed positioning (SearchableModelSelect.vue pattern) | Already implemented correctly in the codebase; copy the pattern |
| Icon loading | `<img src="icon.svg">` or external icon font | AppIcon.vue name→path map | Zero network requests; current colors from `stroke="currentColor"`; trivially tree-shakeable |
| OS drag detection | File-type sniffing from content | `dataTransfer.types.includes('Files')` | The only reliable way to distinguish OS file drag from element drag before the drop event |
| Skeleton shimmer animation | Custom CSS keyframes | Tailwind `animate-pulse` | Already in project bundle; consistent visual language |
**Key insight:** The entire drag-to-move infrastructure (`draggingFile` ref, `dragOverFolderId`, `ring-2 ring-inset ring-amber-300` class binding, `@dragover.prevent`, `@drop.prevent`) is already implemented in `StorageBrowser.vue`. Phase 10 does NOT rebuild it — it debugs why the emit chain is incomplete and wires the toast on success.
---
## Drag-to-Move Current State (UX-11)
**What already works (from `StorageBrowser.vue` source):**
- `draggingFile = ref(null)` — tracks the file being dragged (line 341)
- `dragOverFolderId = ref(null)` — tracks which folder is the current drag target (line 342)
- `onFileDragStart(file, e)` — sets `draggingFile`, configures `dataTransfer.effectAllowed = 'move'` (lines 344-348)
- `@dragover.prevent` on folder rows calls `onFolderDragOver(folder.id, $event)` — sets `dragOverFolderId` (line 88)
- `@dragleave` on folder rows clears `dragOverFolderId` (line 89)
- `@drop.prevent` on folder rows calls `onDropDocOnFolder(folder.id)` which `emit('file-move', { fileId, folderId })` (lines 356-362)
- The CSS class `:class="{ 'bg-amber-50 ring-2 ring-inset ring-amber-300': dragOverFolderId === folder.id }"` is already on folder rows (lines 83-86)
**What is NOT working / missing:**
1. `onFolderDragOver` has a guard `if (!draggingFile.value) return` — this is correct and means the OS file drag (UX-09) will NOT accidentally trigger folder highlight. Good.
2. `FileManagerView.vue` handles `@file-move` with `doMove(fileId, folderId)` which calls `docsStore.moveToFolder(docId, folderId)` but does NOT fire a toast on success (line 145-147).
3. `@dragend` on file rows resets both `draggingFile` and `dragOverFolderId` (line 145) — correctly clears state after drag.
4. **The drag-to-move mechanism is functionally complete** for the in-browser drag case. The only missing wires are: (a) success toast in `FileManagerView.doMove()`; (b) testing that the ring highlight actually renders visually (may need a `mode === 'local'` guard check).
**What's needed to "wire end-to-end" per UX-11:**
- Add `useToastStore().show('Document moved', 'success')` to `FileManagerView.doMove()` on success.
- Add `useToastStore().show('Move failed', 'error')` on catch.
- Verify the `@dragover.prevent` guard (`mode === 'local'`) prevents cloud mode from activating (already: `mode === 'local' ? onFolderDragOver(...) : null`).
- Add a `dragging` data ref to `DocumentCard.vue` to prevent click-after-drag navigation (D-18): `dragging = false` in `data()`, set `true` in `@dragstart`, reset `false` in `@dragend`; guard `@click`: `if (this.dragging) return`. But `DocumentCard.vue` is used in `TopicsView` (card grid), not in `StorageBrowser`'s list view — `StorageBrowser` file rows handle dragging directly. `DocumentCard.vue` does NOT currently have `draggable="true"`. The D-18 fix applies if DocumentCard gains drag capability this phase; if it doesn't, skip.
---
## BreadcrumbBar Extraction (UX-12)
**Current state:**
- `FolderBreadcrumb.vue` exists at `frontend/src/components/folders/FolderBreadcrumb.vue`
- It already accepts a `:segments` prop (Array of `{ id, name }`)
- It already emits `@navigate(segmentId)` — parent handles routing
- It already renders: `Home` button → `` → clickable segments → last segment as plain text
- It already handles ellipsis for > 4 segments
**What `BreadcrumbBar.vue` needs to add over `FolderBreadcrumb.vue`:**
1. **Static segments support** — Admin views need segments like `[{ label: 'Admin' }, { label: 'Users' }]` where there is no `id` (no navigation). The last segment is always non-clickable. Intermediate segments without an `id` are non-clickable too (for admin breadcrumbs).
2. **Renamed interface** — current: `segments: [{ id, name }]` → proposed: `segments: [{ id?, label }]` — use `label` for display (rename `name``label` in the prop shape). The segment `id` remains optional; segments without `id` render as plain text.
3. **`Home` → dynamic root label** — FileManagerView uses "Home"; admin views don't want "Home" as the root. Add a `rootLabel` prop (default: `'Home'`) that replaces the hardcoded "Home" text.
**Prop interface for BreadcrumbBar.vue:**
```javascript
props: {
segments: { type: Array, default: () => [] }, // [{ id?, label }]
rootLabel: { type: String, default: 'Home' }, // shown as first clickable segment
showRoot: { type: Boolean, default: true }, // admin views set false (no Home)
}
emit: ['navigate'] // emits segment.id (or null for root)
```
**Where each view provides segments:**
| View | Segments computed property | rootLabel |
|---|---|---|
| `FileManagerView.vue` | `foldersStore.breadcrumb` mapped to `{ id, label: name }` | `'Home'` |
| `CloudFolderView.vue` | existing `breadcrumb` computed → `{ id, label: name }` | `'Cloud'` |
| `AdminUsersView.vue` | `[{ label: 'Users' }]` (static, no root needed) | — (showRoot: false) |
| `AdminAuditView.vue` | `[{ label: 'Audit Log' }]` | — (showRoot: false) |
| `SettingsView.vue` | `[{ label: 'Settings' }, { label: activeTab }]` | — (showRoot: false) |
**Delete:** `FolderBreadcrumb.vue` after `BreadcrumbBar.vue` is wired everywhere. StorageBrowser currently imports `FolderBreadcrumb` — update to import `BreadcrumbBar` and pass `showRoot: true` for local mode, `false` for cloud mode.
---
## Toast System Implementation (UX-10)
**Current stub state (`stores/toast.js`):**
```javascript
function show(message, type = 'success', duration = 4000) {
// No-op stub
}
```
**What Phase 10 implements:**
1. **Store** — replace the no-op with reactive state:
```javascript
const toasts = ref([]) // [{ id, message, type, duration }]
function show(message, type = 'success', duration = 4000) {
const id = Date.now() + Math.random()
toasts.value.push({ id, message, type, duration })
setTimeout(() => dismiss(id), duration)
}
function dismiss(id) {
toasts.value = toasts.value.filter(t => t.id !== id)
}
return { toasts, show, dismiss }
```
2. **ToastContainer.vue** — new component, mounted in `App.vue`:
```html
<Teleport to="body">
<div class="fixed bottom-4 right-4 flex flex-col-reverse gap-2 z-[9999] pointer-events-none">
<TransitionGroup name="toast">
<div v-for="toast in toastStore.toasts" :key="toast.id"
class="pointer-events-auto flex items-center gap-3 bg-white rounded-xl shadow-lg border border-gray-100 pl-0 pr-4 py-3 max-w-sm min-w-[280px]"
@click="toastStore.dismiss(toast.id)">
<!-- Left color bar -->
<div class="w-1 self-stretch rounded-l-xl" :class="accentClass(toast.type)"></div>
<!-- Icon -->
<AppIcon :name="iconForType(toast.type)" class="w-5 h-5 shrink-0" :class="iconColorClass(toast.type)" />
<!-- Message -->
<p class="text-sm text-gray-800 flex-1">{{ toast.message }}</p>
</div>
</TransitionGroup>
</div>
</Teleport>
```
3. **Where to mount ToastContainer:** In `App.vue` template, alongside `<router-view>`. Since `App.vue` uses `<script setup>`, import and add `<ToastContainer />` to the template.
4. **Phase 8 call sites that must remain unchanged:**
- `SettingsAccountTab.vue``useToastStore().show('Sessions revoked', 'success')`
- `TotpEnrollment.vue``useToastStore().show('TOTP enabled', 'success')`
5. **New call sites to add (FileManagerView.vue):**
- Upload complete: `show('${files.length} file(s) uploaded', 'success')`
- Upload error: `show('Upload failed: ' + item.error, 'error')`
- Document deleted: `show('Document deleted', 'success')`
- Move to folder success: `show('Document moved', 'success')`
- Share revoked: `show('Share revoked', 'success')` — ShareModal emits this
---
## Common Pitfalls
### Pitfall 1: App.vue is `<script setup>` but Project Convention is Options API
**What goes wrong:** CLAUDE.md says "Options API preserved in v0.2 refactor" but `App.vue` is already `<script setup>`. Assuming Options API lifecycle hooks will fail silently.
**Root cause:** App.vue was written in Composition API style when it was created (likely Phase 9). The Options API convention applies to new components, but App.vue is already Composition API.
**Prevention:** Use `onMounted`/`onUnmounted` from Vue 3 in App.vue additions. New components (EmptyState, BreadcrumbBar, ToastContainer, OsDragOverlay) should be Options API per convention.
### Pitfall 2: `dragend`-then-`click` on File Rows in StorageBrowser
**What goes wrong:** File rows in StorageBrowser use `@click="$emit('file-open', file)"`. After a drag that covers < 4px, the browser fires `dragend` then immediately fires `click`, causing the document to open after the user intended a drag.
**Root cause:** Pitfall 6 from PITFALLS.md — browser fires click after dragend below drag initiation threshold.
**Prevention:** Add `draggingFile` state guard to file row click: `:@click="draggingFile ? null : $emit('file-open', file)"`. Reset `draggingFile` to `null` in `@dragend` with a `nextTick` delay so the click guard has time to intercept: `@dragend="nextTick(() => { draggingFile = null })"`.
### Pitfall 3: OS Drag Overlay Bleeding into In-App Element Drags
**What goes wrong:** The OS drag overlay checks `dataTransfer.types.includes('Files')` — but this must only fire when the drag originates from OUTSIDE the browser. An in-app element drag (file row to folder row) will NOT have `'Files'` in types, so this should be safe. However, the `dragenter` event fires on EVERY element the cursor passes over. If the overlay is shown based on `dragenter` alone, it will flicker.
**Prevention:** Use a counter pattern: increment `dragDepth` on `dragenter`, decrement on `dragleave`. Show overlay when `dragDepth > 0 && isFilesDrag`. Reset to 0 on `drop`. This prevents premature hide when cursor moves between child elements.
### Pitfall 4: Keyboard `Escape` Double-Firing with DocumentPreviewModal
**What goes wrong:** `DocumentPreviewModal.vue` registers its OWN `document.addEventListener('keydown', handleKeydown)` that emits `close` on `Escape`. If App.vue ALSO registers a global handler that tries to close modals on `Escape`, both fire.
**Root cause:** DocumentPreviewModal manages its own close. The App.vue Escape handler must NOT attempt to close modals directly — it should only clear the search bar when no modal is open, or be a no-op when a modal is open.
**Prevention:** Track open modal state in a Pinia store (e.g., `useModalStore` with `openModal: ref(null)`). App.vue Escape handler checks: if `modalStore.openModal`, do nothing (let the modal's own listener handle it). Else: clear search query.
**Alternative:** Don't register Escape in App.vue at all — let each modal handle its own Escape, and only handle `/` and other non-conflicting shortcuts at the App.vue level. Escape for "clear search" can be handled inside `SearchBar.vue` directly.
### Pitfall 5: Folder Picker Teleport Requires Tracking Which Button Triggered It
**What goes wrong:** Teleporting the folder picker out of the file row means the picker's position must be calculated from the trigger button's `getBoundingClientRect()`. If multiple files have their picker open (impossible due to `folderPickerFileId` being a single ref), or if the scroll position changes after opening, the picker floats at the wrong position.
**Prevention:** Call `updatePickerPosition()` on `scroll` events (same pattern as SearchableModelSelect.vue which registers `window.addEventListener('scroll', onScroll, true)`). Store the computed position in a reactive `pickerStyle` ref.
### Pitfall 6: Toast `z-index` vs Drag Overlay
**What goes wrong:** ToastContainer uses `z-[9999]`. OsDragOverlay uses `fixed inset-0` — it must have a z-index that covers the entire page including dropdowns but does NOT cover the toast stack.
**Prevention:** `OsDragOverlay` uses `z-[9998]`. `ToastContainer` uses `z-[9999]`. `DocumentPreviewModal` uses `z-50`. The drop overlay renders ABOVE the main content but BELOW toasts so upload feedback is visible even during an active drop.
### Pitfall 7: Skeleton Row Height Must Match Real Row Height
**From PITFALLS.md §Pitfall 13:** Skeleton rows that are different heights from actual rows cause layout shift (CLS) when real content loads.
**StorageBrowser grid row height:** `px-4 py-2.5` with grid `grid-cols-[2rem_1fr_6rem_8rem_6rem]`. Skeleton rows must use identical classes with `animate-pulse` gray blocks:
```html
<div class="px-4 py-2.5 grid grid-cols-[2rem_1fr_6rem_8rem_6rem] gap-3 items-center">
<div class="w-7 h-7 bg-gray-100 rounded-lg animate-pulse"></div>
<div class="h-4 bg-gray-100 rounded animate-pulse w-2/3"></div>
<div class="h-3 bg-gray-100 rounded animate-pulse hidden md:block"></div>
<div class="h-3 bg-gray-100 rounded animate-pulse hidden sm:block"></div>
<div class="w-14 h-3 bg-gray-100 rounded animate-pulse"></div>
</div>
```
---
## Code Examples
### AppIcon.vue — Settings icon (dual-path case)
```html
<!-- Source: direct audit of AppSidebar.vue, verified from source -->
<template>
<svg :class="$attrs.class" fill="none" stroke="currentColor"
viewBox="0 0 24 24" aria-hidden="true">
<template v-if="Array.isArray(paths[name])">
<path v-for="(d, i) in paths[name]" :key="i"
stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="d" />
</template>
<path v-else
stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
:d="paths[name]" />
</svg>
</template>
<script>
export default {
name: 'AppIcon',
inheritAttrs: false,
props: { name: { type: String, required: true } },
computed: {
paths() { return this.$options._iconPaths }
},
_iconPaths: {
folder: 'M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v9...',
cog: [
'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0...',
'M15 12a3 3 0 11-6 0 3 3 0 016 0z'
],
// ...
}
}
</script>
```
### Global keydown handler in App.vue (Composition API)
```javascript
// Source: direct audit of App.vue (uses <script setup>) + DocumentPreviewModal.vue pattern
import { onMounted, onUnmounted, ref } from 'vue'
import { useRoute } from 'vue-router'
const routeViewRef = ref(null) // <router-view ref="routeViewRef" />
function onKeydown(e) {
const tag = document.activeElement?.tagName
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(tag) || document.activeElement?.isContentEditable) return
if (e.key === '/' && !e.ctrlKey && !e.metaKey) {
e.preventDefault()
routeViewRef.value?.focusSearch?.()
}
if (e.key === 'Escape') {
routeViewRef.value?.clearSearch?.()
}
if (e.key === 'u' || e.key === 'U') {
routeViewRef.value?.triggerUpload?.()
}
if (e.key === 'n' || e.key === 'N') {
routeViewRef.value?.startNewFolder?.()
}
}
onMounted(() => document.addEventListener('keydown', onKeydown))
onUnmounted(() => document.removeEventListener('keydown', onKeydown))
```
### OsDragOverlay — depth counter pattern
```javascript
// Source: derived from D-16 + Pitfall 3 analysis (ASSUMED pattern — standard solution)
let dragDepth = 0
function onDragEnter(e) {
if (!e.dataTransfer?.types.includes('Files')) return
dragDepth++
showOverlay.value = true
}
function onDragLeave() {
dragDepth = Math.max(0, dragDepth - 1)
if (dragDepth === 0) showOverlay.value = false
}
function onDrop(e) {
dragDepth = 0
showOverlay.value = false
const files = Array.from(e.dataTransfer.files)
if (files.length) emit('files-dropped', files)
}
onMounted(() => {
window.addEventListener('dragenter', onDragEnter)
window.addEventListener('dragleave', onDragLeave)
window.addEventListener('dragover', e => e.preventDefault())
window.addEventListener('drop', onDrop)
})
```
---
## State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|---|---|---|---|
| Spinner/text "Loading…" | Structured skeleton rows matching grid layout | Phase 10 | Eliminates layout shift; communicates content shape before data loads |
| Inline empty "No items" text | `EmptyState.vue` with icon + headline + subtext + CTA slot | Phase 10 | Consistent visual language; actionable empty states |
| Per-file SVG blocks | `AppIcon.vue` name→path registry | Phase 10 | Deduplication; single update point for icon changes |
| Per-view breadcrumb (FolderBreadcrumb) | Shared `BreadcrumbBar.vue` with static segments support | Phase 10 | Consistent breadcrumb across all contexts |
| Silent feedback on actions | Toast notifications via `useToastStore` | Phase 10 | Users know actions succeeded or failed |
**Deprecated/outdated after Phase 10:**
- `FolderBreadcrumb.vue`: deleted after BreadcrumbBar.vue is wired
- `"Loading…"` text in StorageBrowser, AppSidebar, AdminUsersView, AdminAuditView: replaced by skeletons
- `"No folders yet"` / `"No topics yet"` / `"No cloud storage connected"` plain text: replaced by EmptyState.vue
- Inline `<svg>` blocks across 29 files: replaced by `<AppIcon name="..." />`
- AppSidebar `startNewFolder()` / `cancelNewFolder()` / `submitNewFolder()` functions and their local state: these are the SIDEBAR folder creation functions being removed (UX-14). The file manager's own inline new-folder UI in StorageBrowser is KEPT.
---
## Wave / Dependency Plan
### Wave 0 — Foundation Components (all parallel, no inter-dependencies)
**Goal:** Create new shared components and update the toast store. No wiring to call sites yet.
| Req(s) | Task | Files Created/Modified |
|---|---|---|
| CODE-05 | Create `AppIcon.vue` with complete `name→path` map (all ~30 icons) | `components/ui/AppIcon.vue` NEW |
| UX-01 | Create `EmptyState.vue` with `icon`, `headline`, `subtext` props + `#cta` slot | `components/ui/EmptyState.vue` NEW |
| UX-12 | Create `BreadcrumbBar.vue` extending FolderBreadcrumb interface | `components/ui/BreadcrumbBar.vue` NEW |
| UX-10 | Implement `useToastStore` reactive state + `ToastContainer.vue` + mount in `App.vue` | `stores/toast.js` MODIFIED, `components/ui/ToastContainer.vue` NEW, `App.vue` MODIFIED |
**Wave 0 rationale:** All four tasks produce standalone components / updated store. None depend on each other and none modify existing call sites — so a failure in one task does not block others.
---
### Wave 1 — Wire Empty States, Skeletons, Toast Call Sites (parallel within wave, blocked on Wave 0)
**Goal:** Replace all "Loading…" / "No items" text across the app. Add toast calls to existing actions.
| Req(s) | Task | Files Modified |
|---|---|---|
| UX-02 | Add 5-col skeleton rows to `StorageBrowser.vue` (replace "Loading…" div) | `StorageBrowser.vue` |
| UX-03 | Add skeleton placeholders to `AppSidebar.vue` folder tree + topics + cloud sections | `AppSidebar.vue` |
| UX-04 | Add skeleton table rows to `AdminUsersView.vue` + `AdminAuditView.vue` | `AdminUsersView.vue`, `AdminAuditView.vue` |
| UX-01 | Wire `EmptyState.vue` into: StorageBrowser (root, folder, search), SharedView, CloudStorageView, AppSidebar, AdminAuditView | `StorageBrowser.vue`, `SharedView.vue`, `CloudStorageView.vue`, `AppSidebar.vue`, `AdminAuditView.vue` |
| UX-10 | Add toast calls to `FileManagerView.vue` (upload, delete, move, rename, share revoke) | `FileManagerView.vue` |
| UX-12 | Replace `FolderBreadcrumb` usages with `BreadcrumbBar`; add static segments to admin/settings views; delete `FolderBreadcrumb.vue` | `StorageBrowser.vue`, `FileManagerView.vue`, `CloudFolderView.vue`, and 5 admin/settings views |
| UX-14 | Remove "New" button from `AppSidebar.vue` sidebar + all associated `startNewFolder` sidebar state | `AppSidebar.vue` |
---
### Wave 2 — Keyboard Shortcuts + OS Drag Overlay (parallel, blocked on Wave 0)
**Goal:** Keyboard navigation and OS file drag upload.
| Req(s) | Task | Files Modified |
|---|---|---|
| UX-05, UX-06, UX-07, UX-08 | Add global keydown handler to `App.vue`; expose `focusSearch`, `triggerUpload`, `startNewFolder`, `clearSearch` on `FileManagerView.vue`; expose `triggerInput` on `DropZone.vue`; expose `triggerUpload` on `StorageBrowser.vue`; add `focusSearch` chain through `SearchBar.vue` | `App.vue`, `FileManagerView.vue`, `StorageBrowser.vue`, `DropZone.vue`, `SearchBar.vue` |
| UX-09 | Create `OsDragOverlay.vue` (depth counter, full-screen visual, Teleport to body); mount in `App.vue`; connect to `FileManagerView.vue` upload flow | `OsDragOverlay.vue` NEW, `App.vue` MODIFIED |
---
### Wave 3 — Drag-to-Move Completion + Dropdown Fixes + SVG Migration (parallel within wave, blocked on Waves 0-1)
**Goal:** Wire drag-to-move toast, fix dropdown clipping, replace all inline SVGs.
| Req(s) | Task | Files Modified |
|---|---|---|
| UX-11 | Add success/error toast to `FileManagerView.doMove()`; verify ring-2 highlight renders; add `DocumentCard.vue` drag guard (if DocumentCard gains draggable in this phase) | `FileManagerView.vue`, optionally `DocumentCard.vue` |
| UX-13 | Teleport folder pickers in `StorageBrowser.vue` and `DocumentCard.vue` with `getBoundingClientRect()` positioning; Teleport `FolderRow.vue` dropdown menu | `StorageBrowser.vue`, `DocumentCard.vue`, `FolderRow.vue` |
| CODE-05 | Replace all inline `<svg>` blocks across 29 files with `<AppIcon name="..." class="..." />` | All 29 files with inline SVGs |
**Wave 3 rationale:** SVG migration (CODE-05) requires AppIcon.vue from Wave 0 but can proceed independently of Waves 1-2 after Wave 0. Dropdown fixes and drag completion are similarly independent of each other.
---
## Validation Architecture
*(nyquist_validation: true in .planning/config.json)*
### Test Framework
| Property | Value |
|---|---|
| Framework | Vitest (already configured — PERF-01 installed `@vueuse/core` etc in Phase 8) |
| Config file | `frontend/vite.config.js` (contains `test:` block if Phase 8 configured it) |
| Quick run command | `cd frontend && npm run test -- --run` |
| Full suite command | `cd frontend && npm run test` |
### Phase Requirements → Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|---|---|---|---|---|
| UX-01 | EmptyState renders icon + headline + subtext; CTA slot renders when provided | unit | `npm run test -- --run EmptyState` | ❌ Wave 0 |
| UX-02 | StorageBrowser renders skeleton rows when `loading=true`; "Loading…" text absent | unit | `npm run test -- --run StorageBrowser` | ❌ Wave 0 |
| UX-05 | `/` key focuses search input (not when input focused) | unit (simulate keydown) | `npm run test -- --run keyboard` | ❌ Wave 2 |
| UX-06 | `Escape` clears search; fires only when no input is focused | unit | `npm run test -- --run keyboard` | ❌ Wave 2 |
| UX-07 | `U` key calls `triggerInput()` on DropZone (not when input focused) | unit | `npm run test -- --run keyboard` | ❌ Wave 2 |
| UX-08 | `N` key calls `startNewFolder()` (not when input focused) | unit | `npm run test -- --run keyboard` | ❌ Wave 2 |
| UX-10 | Toast appears within 200ms of `show()` call; auto-dismisses after duration; disappears on click | unit | `npm run test -- --run toast` | ❌ Wave 0 |
| UX-10 | Toast does not appear when `show()` is called with existing Phase 8 call-site signatures | unit | `npm run test -- --run toast` | ❌ Wave 0 |
| UX-11 | `file-move` emit received → toast fires; ring class applied during dragOver | unit | `npm run test -- --run StorageBrowser` | ❌ Wave 3 |
| UX-12 | BreadcrumbBar renders segments; last segment non-clickable; navigate emitted on segment click | unit | `npm run test -- --run BreadcrumbBar` | ❌ Wave 0 |
| UX-13 | Dropdown picker renders at correct viewport position (getBoundingClientRect mock) | unit | `npm run test -- --run dropdown` | ❌ Wave 3 |
| UX-14 | AppSidebar does not render "New" button in folder section | unit | `npm run test -- --run AppSidebar` | ❌ Wave 1 |
| CODE-05 | AppIcon renders correct SVG path for each named icon; warns in dev for unknown name | unit | `npm run test -- --run AppIcon` | ❌ Wave 0 |
| UX-09 | OS drag overlay appears on window dragenter with Files type; hidden on dragleave | unit | `npm run test -- --run OsDragOverlay` | ❌ Wave 2 |
**Manual-only tests (cannot be automated in unit tests):**
- UX-03: Sidebar skeleton visual appearance — verify skeletons match TreeItem indent levels visually
- UX-04: Admin table skeleton rows — verify column alignment matches real rows
- Toast stacking behavior with multiple simultaneous toasts
- OS drag-and-drop actual file upload end-to-end flow
- Keyboard shortcut: `N` in a cloud folder view (should be a no-op)
### Key Behavioral Contracts
| Contract | Value | Test Type |
|---|---|---|
| Toast auto-dismiss timing | 4000ms (locked by Phase 8 stub default) | unit (mock timers) |
| Toast manual dismiss | click anywhere on toast | unit |
| Keyboard guard: `/` inside focused input | does NOT redirect to search | unit |
| Keyboard guard: `N` inside focused input | does NOT trigger folder creation | unit |
| Breadcrumb last segment | non-clickable (no `@click` / `@navigate` on last segment) | unit |
| OS drag detection | `dataTransfer.types.includes('Files')` required | unit |
| Drag-to-move ring highlight | `ring-2 ring-inset ring-amber-300` class on hover target | unit |
| AppIcon unknown name | logs `console.warn` in dev; renders nothing | unit |
### Sampling Rate
- **Per task commit:** `cd frontend && npm run test -- --run [component-name]`
- **Per wave merge:** `cd frontend && npm run test -- --run`
- **Phase gate:** Full suite green before `/gsd:verify-work`
### Wave 0 Gaps
- [ ] `frontend/src/components/ui/AppIcon.test.js` — covers CODE-05 (name→path rendering, unknown name warn)
- [ ] `frontend/src/components/ui/EmptyState.test.js` — covers UX-01 (props, CTA slot)
- [ ] `frontend/src/components/ui/BreadcrumbBar.test.js` — covers UX-12 (last segment, navigate emit)
- [ ] `frontend/src/stores/toast.test.js` — covers UX-10 (show, auto-dismiss, dismiss on click)
- [ ] `frontend/src/components/ui/ToastContainer.test.js` — covers UX-10 visual rendering
---
## Security Domain
Phase 10 is purely frontend UX. No new API endpoints, no authentication changes, no sensitive data handling. Security checklist items:
| ASVS Category | Applies | Standard Control |
|---|---|---|
| V2 Authentication | no | — |
| V3 Session Management | no | — |
| V4 Access Control | no | — |
| V5 Input Validation | no | Toast messages are internal strings, not user input |
| V6 Cryptography | no | — |
**Note:** The OS drag overlay receives `dataTransfer.files` — these are files selected by the user from their own OS. The upload flow calls the same `docsStore.upload()` path already used by DropZone. No new attack surface. The existing quota enforcement, MIME type validation, and backend file handling are unchanged.
**Security gate items for Phase 10:**
- `npm audit --audit-level=high` — verify no high/critical CVEs introduced (no new packages, should be clean)
- `bandit -r backend/` — unchanged (backend untouched this phase)
- Verify no keyboard shortcut can trigger privileged actions (U, N only fire upload picker and folder input — no data deletion or admin actions)
---
## Open Questions
1. **UploadProgress.vue solid icons**
- What we know: `UploadProgress.vue` uses solid (fill-based) `<svg>` for error-circle and checkmark-circle, which differ from the stroke-only convention.
- What's unclear: Should CODE-05 replace these with stroke equivalents or keep them as inline SVG?
- Recommendation: Replace with stroke equivalents (`checkCircle` = `M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z` and `exclamationCircle`) already in the AppIcon map. The visual difference (solid vs stroke) is minor at w-5 h-5 and the consistency benefit outweighs it.
2. **`N` shortcut in cloud folder view**
- What we know: `N` should start new folder. But `CloudFolderView.vue` (cloud file manager) does not have folder creation — cloud folders are managed by the provider.
- What's unclear: Should `N` be a no-op in cloud context, or show a "not available in cloud" toast?
- Recommendation: No-op. The App.vue handler calls `routeViewRef.value?.startNewFolder?.()` with optional chaining — if CloudFolderView doesn't expose `startNewFolder`, nothing happens. No toast needed.
3. **BreadcrumbBar in SettingsView**
- What we know: SettingsView uses tabs, not routes. The "active tab" changes without route change.
- What's unclear: Should BreadcrumbBar update when the active settings tab changes? UX-12 says "breadcrumb updates on navigation" — tab changes are not route navigations.
- Recommendation: Static breadcrumb for settings showing `Settings Account` (or whichever tab). Update the segments computed when `activeTab` changes using a `computed()`. This is a pure UI update, not a route change.
---
## Assumptions Log
| # | Claim | Section | Risk if Wrong |
|---|---|---|---|
| A1 | Total SVG `<path>` count is 66 across 29 files | Component Inventory §1 | Low — count was derived from grep output; off-by-one would not affect plan |
| A2 | `FolderRow.vue` three-dot menu should use stroke equivalent of dots icon | Component Inventory §1 (Special Cases) | Low — keeping inline fill-based icon is valid fallback |
| A3 | Heroicons outline search path: `M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0` | Component Inventory §3 | Medium — verify at heroicons.com before committing to AppIcon.vue |
| A4 | `npm run test -- --run` is the correct Vitest command format for this project | Validation Architecture | Low — if wrong, use `npx vitest run` |
| A5 | `SearchBar.vue` does not currently expose its input via `defineExpose` | Keyboard Shortcut section | Low — check SearchBar.vue source before plan is written if not already read |
---
## Environment Availability
| Dependency | Required By | Available | Version | Fallback |
|---|---|---|---|---|
| Node.js + npm | Frontend build | ✓ | (project running) | — |
| Vue 3 | All components | ✓ | ^3.5.x (Phase 8) | — |
| Pinia | Toast store | ✓ | already installed | — |
| Tailwind CSS | Skeleton animate-pulse, ring utilities | ✓ | already installed | — |
| `<Teleport>` | ToastContainer, dropdown fixes | ✓ | Vue 3 built-in | — |
No missing dependencies. Phase 10 requires no new installations.
---
## Sources
### Primary (HIGH confidence — direct source inspection)
- `frontend/src/components/storage/StorageBrowser.vue` — drag state, breadcrumb props, empty state, loading, defineExpose
- `frontend/src/views/FileManagerView.vue` — browserRef pattern, upload handler, folder CRUD
- `frontend/src/components/layout/AppSidebar.vue` — "New" button (UX-14), loading text, empty text, sidebar sections
- `frontend/src/stores/toast.js` — Phase 8 stub, locked signature
- `frontend/src/App.vue` — layout structure, `<script setup>` usage
- `frontend/src/components/folders/FolderBreadcrumb.vue` — current breadcrumb interface (segments, navigate emit)
- `frontend/src/components/ui/SearchableModelSelect.vue` — Teleport + getBoundingClientRect dropdown pattern (reference implementation)
- `frontend/src/components/documents/DocumentPreviewModal.vue` — keydown listener pattern reference
- All 29 Vue files with inline SVGs — path `d` attribute values extracted directly
### Secondary (MEDIUM confidence)
- `.planning/research/PITFALLS.md` — Pitfalls 6, 7, 12, 13 (documented from prior source audit in v0.2 research phase)
- `.planning/research/ARCHITECTURE.md` — component responsibility map and integration points
### Tertiary (LOW confidence / ASSUMED)
- Heroicons search path value (A3) — training knowledge, not verified against heroicons.com this session
- Exact Vitest command format (A4) — training knowledge; verify against `package.json` scripts
---
## Metadata
**Confidence breakdown:**
- Component inventory (SVG audit, dropdown audit, empty state contexts): HIGH — sourced directly from file reads
- Drag-to-move current state: HIGH — sourced directly from StorageBrowser.vue source
- BreadcrumbBar extraction: HIGH — sourced directly from FolderBreadcrumb.vue
- Upload ref chain: HIGH — sourced directly from FileManagerView.vue + StorageBrowser.vue + DropZone.vue
- Keyboard shortcut home (App.vue): HIGH — sourced directly from App.vue
- Toast implementation: HIGH — stub contract is clear; implementation pattern is standard Pinia + Vue
- Test framework detection: MEDIUM — vite.config.js not read; assumed from Phase 8 PERF-01 install
- Heroicons icon paths (A3): LOW — training data, not verified
**Research date:** 2026-06-14
**Valid until:** 2026-07-14 (stable stack — no version-sensitive claims)