Files
kite/.planning/milestones/v0.2-phases/10-ux-interaction/10-RESEARCH.md
T
curo1305andClaude Sonnet 4.6 123ae5b29b chore: archive v0.2 phase directories to milestones/v0.2-phases/
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>
2026-06-17 14:34:52 +02:00

62 KiB
Raw Blame History

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

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

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:

  1. 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 }
  1. 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>
  1. Where to mount ToastContainer: In App.vue template, alongside <router-view>. Since App.vue uses <script setup>, import and add <ToastContainer /> to the template.

  2. Phase 8 call sites that must remain unchanged:

    • SettingsAccountTab.vueuseToastStore().show('Sessions revoked', 'success')
    • TotpEnrollment.vueuseToastStore().show('TOTP enabled', 'success')
  3. 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:

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