Files
kite/.planning/phases/10-ux-interaction/10-RESEARCH.md
T

924 lines
62 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 (RESOLVED)
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)