Moves phases 08–11 execution artifacts from .planning/phases/ to .planning/milestones/v0.2-phases/ to keep .planning/phases/ clean for the next milestone. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
62 KiB
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.vueuses<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.vuetakes explicit props:icon(SVG path string or AppIcon name),headline(string),subtext(string). - D-07: CTA is an optional named
#ctaslot. EmptyState.vue renders nothing where slot is empty.
AppIcon SVG Centralization (CODE-05)
- D-08: AppIcon.vue uses a
name → SVG path stringmap. No new dependency. - D-09: Outline/stroke only — no
variantprop, no solid variant. - D-10: Prop interface:
<AppIcon name="folder" class="w-4 h-4 text-amber-500" />.classforwarded 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
segmentsarray and passes it toBreadcrumbBar.vue. - D-12:
BreadcrumbBar.vueemits@navigate(index)— parent handles navigation. Last segment is non-clickable plain text. - D-13: Admin/settings/topics views provide static
segmentscomputed property. File manager and cloud folder views use folder storebreadcrumbarray.
Keyboard Shortcuts (UX-05 through UX-08)
- D-14: Global
keydownlistener inmounted()/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 atwindow/documentlevel, not insideStorageBrowser. - D-17: Drag-to-move drop highlight:
ring-2 ring-inset ring-amber-300on valid folder targets. - D-18:
DocumentCard.vuededicated drag handle element to preventdragend-then-clicknavigation.
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
Ushortcut 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 inDocumentPreviewModal.vue) - Vue built-ins:
<Teleport>,ref(),computed(),onMounted(),onUnmounted() - Tailwind
animate-pulsefor 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.vuealready uses<script setup>(Composition API) —onMounted()andonUnmounted()are available natively.App.vuealready holds thebrowserRefref indirectly via its<router-view>renderingFileManagerView.vue. The shortcut handler needs two refs that cross component boundaries:U→ needs access toFileManagerView.vue's upload inputN→ needs access toStorageBrowser.vue'sstartNewFolder()(exposed viadefineExpose)
- Adding a
AppKeyboardManager.vuechild would require passing those refs back up anyway, introducing unnecessary prop-drilling or a Pinia store for refs. DocumentPreviewModal.vueshows the exact same pattern inline in a single component (directdocument.addEventListenerinonMounted).- Conclusion: Add
onMounted/onUnmounted+onKeydownhandler to App.vue's<script setup>.
What refs are needed in App.vue:
searchInputRef— areffor the search<input>inSearchBar.vue. CurrentlySearchBar.vuedoes not expose its input ref. It will needdefineExpose({ focus() { inputEl.value?.focus() } })added, andFileManagerView.vuewill pass aref="searchBarRef"to<StorageBrowser>which in turn exposes afocusSearch()method.fileManagerRef— the route view inApp.vuerendersFileManagerView.vuevia<router-view>. App.vue can get a ref with<router-view ref="routeViewRef" />. Then theUshortcut callsrouteViewRef.triggerUpload?.()andNcallsrouteViewRef.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.vuehastriggerInput()but does NOT expose it viadefineExpose. It needsdefineExpose({ triggerInput })added.StorageBrowser.vuecurrently exposes onlystartNewFolderviadefineExpose. It needs to also exposetriggerUpload() { dropZoneRef.value?.triggerInput() }, requiring aref="dropZoneRef"on<DropZone>.FileManagerView.vueusesref="browserRef"on<StorageBrowser>. It needs to exposetriggerUpload() { browserRef.value?.triggerUpload() }viadefineExpose.- 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.vuehasstartNewFolder()already defined and already exposed viadefineExpose({ startNewFolder })(line 327).FileManagerView.vuealready callsbrowserRef?.startNewFolder()from its@new-folderhandler (line 18).FileManagerView.vuealready holdsref="browserRef"on<StorageBrowser>(line 17).
What's needed for N shortcut:
FileManagerView.vueneedsdefineExpose({ 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:
-
cogandcogDot— Settings icon in AppSidebar uses TWO<path>elements in one<svg>. AppIcon.vue needs to handle this. Solution: store as arraypaths: { cog: ['path1...', 'path2...'] }and render withv-for. Or create a single combineddvalue. Simpler: accept thatcogis the only dual-path icon and handle it withArray.isArray(paths[name])check. -
dots— FolderRow three-dot menu usesfill="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 afilledprop to AppIcon violating D-09; (c) replace the three-dot icon with a stroke equivalent. Recommendation: replace with the Heroicons strokeellipsis-verticalpath: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 2zand keep stroke conventions. [ASSUMED: this Heroicons path data — verify by checking heroicons.com before using] -
UploadProgress.vueicons — Usesfill="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. -
DocumentPreviewModal.vuespinner — Uses a non-standard spinner SVG with a<circle>element, not a<path>. Excluded from AppIcon.vue (already exists asAppSpinner.vuewhich 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):
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)— setsdraggingFile, configuresdataTransfer.effectAllowed = 'move'(lines 344-348)@dragover.preventon folder rows callsonFolderDragOver(folder.id, $event)— setsdragOverFolderId(line 88)@dragleaveon folder rows clearsdragOverFolderId(line 89)@drop.preventon folder rows callsonDropDocOnFolder(folder.id)whichemit('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:
onFolderDragOverhas a guardif (!draggingFile.value) return— this is correct and means the OS file drag (UX-09) will NOT accidentally trigger folder highlight. Good.FileManagerView.vuehandles@file-movewithdoMove(fileId, folderId)which callsdocsStore.moveToFolder(docId, folderId)but does NOT fire a toast on success (line 145-147).@dragendon file rows resets bothdraggingFileanddragOverFolderId(line 145) — correctly clears state after drag.- 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 amode === 'local'guard check).
What's needed to "wire end-to-end" per UX-11:
- Add
useToastStore().show('Document moved', 'success')toFileManagerView.doMove()on success. - Add
useToastStore().show('Move failed', 'error')on catch. - Verify the
@dragover.preventguard (mode === 'local') prevents cloud mode from activating (already:mode === 'local' ? onFolderDragOver(...) : null). - Add a
draggingdata ref toDocumentCard.vueto prevent click-after-drag navigation (D-18):dragging = falseindata(), settruein@dragstart, resetfalsein@dragend; guard@click:if (this.dragging) return. ButDocumentCard.vueis used inTopicsView(card grid), not inStorageBrowser's list view —StorageBrowserfile rows handle dragging directly.DocumentCard.vuedoes NOT currently havedraggable="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.vueexists atfrontend/src/components/folders/FolderBreadcrumb.vue- It already accepts a
:segmentsprop (Array of{ id, name }) - It already emits
@navigate(segmentId)— parent handles routing - It already renders:
Homebutton →›→ clickable segments → last segment as plain text - It already handles ellipsis for > 4 segments
What BreadcrumbBar.vue needs to add over FolderBreadcrumb.vue:
-
Static segments support — Admin views need segments like
[{ label: 'Admin' }, { label: 'Users' }]where there is noid(no navigation). The last segment is always non-clickable. Intermediate segments without anidare non-clickable too (for admin breadcrumbs). -
Renamed interface — current:
segments: [{ id, name }]→ proposed:segments: [{ id?, label }]— uselabelfor display (renamename→labelin the prop shape). The segmentidremains optional; segments withoutidrender as plain text. -
Home→ dynamic root label — FileManagerView uses "Home"; admin views don't want "Home" as the root. Add arootLabelprop (default:'Home') that replaces the hardcoded "Home" text.
Prop interface for BreadcrumbBar.vue:
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):
function show(message, type = 'success', duration = 4000) {
// No-op stub
}
What Phase 10 implements:
- Store — replace the no-op with reactive state:
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 }
- ToastContainer.vue — new component, mounted in
App.vue:
<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>
-
Where to mount ToastContainer: In
App.vuetemplate, alongside<router-view>. SinceApp.vueuses<script setup>, import and add<ToastContainer />to the template. -
Phase 8 call sites that must remain unchanged:
SettingsAccountTab.vue—useToastStore().show('Sessions revoked', 'success')TotpEnrollment.vue—useToastStore().show('TOTP enabled', 'success')
-
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
- Upload complete:
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:
<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)
<!-- 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)
// 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
// 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:
Nin 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)
-
UploadProgress.vue solid icons
- What we know:
UploadProgress.vueuses 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 0zandexclamationCircle) already in the AppIcon map. The visual difference (solid vs stroke) is minor at w-5 h-5 and the consistency benefit outweighs it.
- What we know:
-
Nshortcut in cloud folder view- What we know:
Nshould start new folder. ButCloudFolderView.vue(cloud file manager) does not have folder creation — cloud folders are managed by the provider. - What's unclear: Should
Nbe 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 exposestartNewFolder, nothing happens. No toast needed.
- What we know:
-
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 whenactiveTabchanges using acomputed(). 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, defineExposefrontend/src/views/FileManagerView.vue— browserRef pattern, upload handler, folder CRUDfrontend/src/components/layout/AppSidebar.vue— "New" button (UX-14), loading text, empty text, sidebar sectionsfrontend/src/stores/toast.js— Phase 8 stub, locked signaturefrontend/src/App.vue— layout structure,<script setup>usagefrontend/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
dattribute 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.jsonscripts
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)