---
phase: 10-ux-interaction
plan: 13
type: execute
wave: 1
depends_on: []
files_modified:
  - frontend/src/components/ui/TreeItem.vue
  - frontend/src/components/storage/StorageBrowser.vue
  - frontend/src/App.vue
  - frontend/src/components/documents/SearchBar.vue
  - frontend/src/components/layout/OsDragOverlay.vue
  - frontend/src/__tests__/keyboard.test.js
  - frontend/src/components/ui/__tests__/TreeItem.test.js
  - frontend/src/components/storage/__tests__/StorageBrowser.showSearch.test.js
autonomous: true
requirements: [UX-02, UX-03, UX-05, UX-06, UX-07, UX-08, UX-09]
gap_closure: true

must_haves:
  truths:
    - "Sidebar Cloud/Folder sections show animated shimmer rows while async data loads — no 'Loading…' text visible"
    - "Admin routes (/admin/*) render with no AppSidebar — AdminLayout is the sole layout component"
    - "Pressing '/', 'U', or 'N' in the file manager dispatches to the actual FileManagerView instance — not a RouterView proxy"
    - "Pressing Escape in the search input clears the field and preserves focus — no browser-native blur"
    - "Dropping an OS file on the drag overlay triggers upload — capture-phase window listener fires before folder-row handlers"
    - "Search bar and sort controls are visible at the root of both local and cloud file browsers"
  artifacts:
    - path: "frontend/src/components/ui/TreeItem.vue"
      provides: "Three animate-pulse shimmer rows in the v-if='loading' branch"
      contains: "animate-pulse"
    - path: "frontend/src/components/storage/StorageBrowser.vue"
      provides: "showSearch computed returns true for mode === 'local' OR mode === 'cloud'"
    - path: "frontend/src/App.vue"
      provides: "Third template branch for admin routes (no AppSidebar); getFileManagerInstance() via matched.find for keyboard dispatch"
    - path: "frontend/src/components/documents/SearchBar.vue"
      provides: "@keydown.escape.prevent.stop suppresses browser native blur on type='search'"
    - path: "frontend/src/components/layout/OsDragOverlay.vue"
      provides: "window drop listener registered with capture=true; removeEventListener also passes true"
  key_links:
    - from: "frontend/src/App.vue (onKeydown)"
      to: "FileManagerView.focusSearch / triggerUpload / startNewFolder"
      via: "router.currentRoute.value.matched.find(r => r.instances?.default)?.instances?.default"
      pattern: "instances\\.default"
    - from: "frontend/src/components/layout/OsDragOverlay.vue"
      to: "window drop event"
      via: "addEventListener('drop', handler, true)"
      pattern: "addEventListener.*drop.*true"
---

<objective>
Close all 6 UAT gaps from 10-UAT.md that block Phase 10 sign-off: sidebar shimmer (Gap 1), search-at-root visibility (Gap 2), admin sidebar bleed (Gap 3), keyboard shortcuts broken by RouterView proxy (Gap 4 — covers tests 11, 12, 13), Escape-breaks-search (Gap 5), and OS drag-drop not uploading (Gap 6).

Purpose: Phase 10 is recorded complete in ROADMAP.md but UAT (10-UAT.md) shows 9 issues with 6 distinct root causes diagnosed. These targeted fixes close all 6 causes with minimal code change, restoring full UAT pass.

Output: Five production files modified, three test files added. No new dependencies. No regressions.
</objective>

<execution_context>
@/Users/nik/.claude/get-shit-done/workflows/execute-plan.md
@/Users/nik/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@/Users/nik/Documents/Progamming/document_scanner/.planning/PROJECT.md
@/Users/nik/Documents/Progamming/document_scanner/.planning/ROADMAP.md
@/Users/nik/Documents/Progamming/document_scanner/.planning/STATE.md
@/Users/nik/Documents/Progamming/document_scanner/.planning/phases/10-ux-interaction/10-UAT.md

<interfaces>
<!-- Key contracts the executor needs. Extracted from codebase. No codebase exploration needed. -->

From frontend/src/App.vue (current full state — relevant sections):
```html
<!-- Template — only two branches today: -->
<AuthLayout v-if="route.meta.layout === 'auth'" />
<div v-else class="flex h-screen overflow-hidden">
  <AppSidebar />
  <main class="flex-1 overflow-y-auto">
    <router-view ref="routeViewRef" />   <!-- resolves to RouterView proxy, NOT FileManagerView -->
  </main>
</div>
<ToastContainer />
<OsDragOverlay @files-dropped="onOsFilesDropped" />
```
```js
// script setup
import { ref, onMounted, onUnmounted } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const routeViewRef = ref(null)         // ← remove this

function onOsFilesDropped(files) {
  routeViewRef.value?.handleOsDrop?.(files)  // ← fix to use getFileManagerInstance()
}
function onKeydown(e) {
  // guard: INPUT/TEXTAREA/SELECT/contentEditable + dialog check omitted here
  routeViewRef.value?.focusSearch?.()    // line 37 ← fix
  routeViewRef.value?.clearSearch?.()    // line 40 ← fix
  routeViewRef.value?.triggerUpload?.()  // line 43 ← fix
  routeViewRef.value?.startNewFolder?.() // line 46 ← fix
}
```

From frontend/src/router/index.js — admin route meta:
```js
{
  path: '/admin',
  component: () => import('../layouts/AdminLayout.vue'),
  meta: { requiresAdmin: true },   // only on parent; children resolved via matched.some()
  children: [/* AdminOverviewView, AdminUsersView, AdminQuotasView, AdminAiView, AdminAuditView */]
}
```

From frontend/src/components/ui/TreeItem.vue — loading branch to replace (lines 48-52):
```html
<div
  v-if="loading"
  class="text-xs text-gray-400 py-1"
  :style="{ paddingLeft: `${(depth + 1) * 12 + 8}px` }"
>
  Loading…
</div>
```

Shimmer pattern to replicate (from AppSidebar.vue lines 60-64):
```html
<div v-if="loadingRoots" class="pl-7 py-1 space-y-1">
  <div v-for="n in 3" :key="`sk-f-${n}`" class="flex items-center gap-2 py-1">
    <div class="w-4 h-4 bg-gray-100 rounded animate-pulse shrink-0"></div>
    <div class="h-3 bg-gray-100 rounded animate-pulse" :style="{ width: (50 + n * 15) + 'px' }"></div>
  </div>
</div>
```

From frontend/src/components/storage/StorageBrowser.vue line 287:
```js
const showSearch = computed(() => props.mode === 'local' && props.breadcrumb.length > 0)
// TARGET STATE:
const showSearch = computed(() => props.mode === 'local' || props.mode === 'cloud')
```

From frontend/src/components/documents/SearchBar.vue line 11:
```html
@keydown.escape="emit('update:modelValue', '')"
<!-- TARGET STATE: -->
@keydown.escape.prevent.stop="emit('update:modelValue', '')"
```

From frontend/src/components/layout/OsDragOverlay.vue (Options API — lines 52-63):
```js
mounted() {
  window.addEventListener('dragenter', this.onDragEnter)
  window.addEventListener('dragleave', this.onDragLeave)
  window.addEventListener('dragover', this.onDragOver)
  window.addEventListener('drop', this.onDrop)            // ← bubble phase; must become capture
},
beforeUnmount() {
  window.removeEventListener('dragenter', this.onDragEnter)
  window.removeEventListener('dragleave', this.onDragLeave)
  window.removeEventListener('dragover', this.onDragOver)
  window.removeEventListener('drop', this.onDrop)         // ← must match with true
}
```

From frontend/src/views/FileManagerView.vue defineExpose (lines 190-196):
```js
// These ARE correct — all methods are exposed. The problem is App.vue never reaches this instance.
defineExpose({
  focusSearch: () => browserRef.value?.focusSearch?.(),
  triggerUpload: () => browserRef.value?.triggerUpload?.(),
  startNewFolder: () => browserRef.value?.startNewFolder?.(),
  clearSearch: () => browserRef.value?.clearSearch?.(),
  handleOsDrop: (files) => onFilesSelected({ files, autoClassify: true }),
})
```

From frontend/src/__tests__/keyboard.test.js (existing structure):
Uses mountFileManager() helper, vi.mock('../api/client.js', ...), vi.mock('../stores/auth.js', ...),
vi.mock('../stores/topics.js', ...), setActivePinia(createPinia()), createRouter+createMemoryHistory.
</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: Sidebar shimmer rows (TreeItem.vue) and search-at-root (StorageBrowser.vue)</name>
  <files>
    frontend/src/components/ui/TreeItem.vue
    frontend/src/components/storage/StorageBrowser.vue
  </files>
  <action>
    TreeItem.vue — replace the v-if="loading" div (lines 48-52) with a shimmer block. The replacement wraps three shimmer rows in a single container div. Apply the indent :style on the container (same binding the removed div had: `{ paddingLeft: \`\${(depth + 1) * 12 + 8}px\` }`). The container also gets `class="py-1 space-y-1"`. Each of the three inner rows uses `class="flex items-center gap-2 py-1"` and contains two children:
    - Icon placeholder: `class="w-4 h-4 bg-gray-100 rounded animate-pulse shrink-0"`
    - Text placeholder: `class="h-3 bg-gray-100 rounded animate-pulse"` with `:style="{ width: (50 + n * 15) + 'px' }"`
    Use `v-for="n in 3"` with `:key="\`sk-t-\${n}\`"` on the inner row div.
    Do not change any other branch (v-else-if loadError, v-else-if children.length===0, slot children) or any script section.

    StorageBrowser.vue — change line 287 only. Change:
      `const showSearch = computed(() => props.mode === 'local' && props.breadcrumb.length > 0)`
    to:
      `const showSearch = computed(() => props.mode === 'local' || props.mode === 'cloud')`
    No template changes needed — SearchBar and SortControls already share v-if="showSearch".
  </action>
  <verify>
    <automated>cd /Users/nik/Documents/Progamming/document_scanner/frontend && grep -c "animate-pulse" src/components/ui/TreeItem.vue && grep "showSearch" src/components/storage/StorageBrowser.vue | grep "cloud"</automated>
  </verify>
  <done>
    - `grep -c "animate-pulse" src/components/ui/TreeItem.vue` returns at least 2
    - `grep -c "Loading" src/components/ui/TreeItem.vue` returns 0
    - `grep "showSearch" src/components/storage/StorageBrowser.vue` contains `props.mode === 'cloud'`
  </done>
</task>

<task type="auto">
  <name>Task 2: Admin sidebar bleed and keyboard instance resolution (App.vue)</name>
  <files>frontend/src/App.vue</files>
  <action>
    Two root causes, one file. Apply both changes together.

    CHANGE A — Admin sidebar bleed (Gap 3):
    Insert a v-else-if branch in the template between the auth branch and the user-layout div. The new branch condition is `route.matched.some(r => r.meta.requiresAdmin)`. When true it renders only `<router-view />` — no AppSidebar, no main wrapper. The complete corrected template root (inside the component root, excluding ToastContainer and OsDragOverlay which stay unchanged):

      1. `<AuthLayout v-if="route.meta.layout === 'auth'" />`
      2. `<router-view v-else-if="route.matched.some(r => r.meta.requiresAdmin)" />`
      3. `<div v-else class="flex h-screen overflow-hidden">` ... AppSidebar + main + router-view (no ref attribute) ... `</div>`

    Note: the router-view in branch 3 must NOT carry `ref="routeViewRef"` — refs on router-view resolve to the RouterView proxy (same root cause as Gap 4). Remove the ref attribute.

    CHANGE B — Keyboard instance resolution (Gap 4, also fixes tests 12 and 13):
    The `routeViewRef` ref and its usage must be replaced with a direct instance lookup:
    1. Add `useRouter` to the `vue-router` import: `import { useRoute, useRouter } from 'vue-router'`
    2. Add `const router = useRouter()` after `const route = useRoute()`
    3. Add helper: `function getFileManagerInstance() { return router.currentRoute.value.matched.find(r => r.instances?.default)?.instances?.default ?? null }`
    4. Remove `const routeViewRef = ref(null)` — no longer used
    5. Remove `ref` from the `ref` import if it is now unused (check — ref was only used for routeViewRef)
    6. Replace all `routeViewRef.value?.X?.()` calls in onKeydown with `getFileManagerInstance()?.X?.()`
    7. Replace `routeViewRef.value?.handleOsDrop?.(files)` in onOsFilesDropped with `getFileManagerInstance()?.handleOsDrop?.(files)`

    After both changes, `ref` from vue should be removed from the import line if nothing else uses it (check first — if ToastContainer or OsDragOverlay use a local ref, keep it; otherwise remove to avoid lint warnings).
  </action>
  <verify>
    <automated>cd /Users/nik/Documents/Progamming/document_scanner/frontend && grep "routeViewRef" src/App.vue; echo "exit:$?"; grep "instances.default" src/App.vue; grep "requiresAdmin" src/App.vue</automated>
  </verify>
  <done>
    - `grep "routeViewRef" src/App.vue` returns no output (fully removed)
    - `grep "instances.default" src/App.vue` returns the getFileManagerInstance() line
    - `grep "requiresAdmin" src/App.vue` returns the v-else-if template branch
    - `grep "useRouter" src/App.vue` appears in import and instantiation
  </done>
</task>

<task type="auto">
  <name>Task 3: Escape modifier (SearchBar.vue) and capture-phase drop (OsDragOverlay.vue)</name>
  <files>
    frontend/src/components/documents/SearchBar.vue
    frontend/src/components/layout/OsDragOverlay.vue
  </files>
  <action>
    SearchBar.vue — one character change on line 11. Add `.prevent.stop` modifiers to `@keydown.escape`:
    Before: `@keydown.escape="emit('update:modelValue', '')"`
    After:  `@keydown.escape.prevent.stop="emit('update:modelValue', '')"`
    `.prevent` stops the browser's native clear+blur behavior on type="search" inputs. `.stop` stops bubbling to App.vue's global keydown handler (which would call clearSearch() a second time). No other changes.

    OsDragOverlay.vue — two lines change in the Options API lifecycle hooks:
    In mounted(): change `window.addEventListener('drop', this.onDrop)` to `window.addEventListener('drop', this.onDrop, true)`
    In beforeUnmount(): change `window.removeEventListener('drop', this.onDrop)` to `window.removeEventListener('drop', this.onDrop, true)`

    The third argument `true` registers the listener in capture phase, meaning it runs before any bubble-phase handlers on child elements (including `@drop.prevent` on folder rows in StorageBrowser.vue). The removeEventListener MUST also pass `true` — EventTarget tracks bubble-phase and capture-phase registrations separately, so without `true` the cleanup call would fail silently and the listener would leak.

    Do not change dragenter, dragleave, or dragover listeners — those remain in bubble phase and work correctly.
  </action>
  <verify>
    <automated>cd /Users/nik/Documents/Progamming/document_scanner/frontend && grep "keydown.escape" src/components/documents/SearchBar.vue && grep -n "addEventListener.*drop\|removeEventListener.*drop" src/components/layout/OsDragOverlay.vue</automated>
  </verify>
  <done>
    - `grep "keydown.escape" src/components/documents/SearchBar.vue` shows `.prevent.stop` in the modifier chain
    - `grep "addEventListener.*drop" src/components/layout/OsDragOverlay.vue` shows `true` as third argument
    - `grep "removeEventListener.*drop" src/components/layout/OsDragOverlay.vue` shows `true` as third argument
  </done>
</task>

<task type="auto" tdd="true">
  <name>Task 4: Regression tests for all 6 gaps</name>
  <files>
    frontend/src/__tests__/keyboard.test.js
    frontend/src/components/ui/__tests__/TreeItem.test.js
    frontend/src/components/storage/__tests__/StorageBrowser.showSearch.test.js
  </files>
  <behavior>
    TreeItem shimmer:
    - When loading=true and expanded=true → rendered HTML contains elements with class "animate-pulse"
    - When loading=true and expanded=true → text "Loading" does not appear in rendered HTML

    StorageBrowser showSearch:
    - mode='local', breadcrumb=[] → showSearch is true (root-level local, previously false)
    - mode='local', breadcrumb=[{name:'Folder'}] → showSearch is true (non-root local)
    - mode='cloud', breadcrumb=[] → showSearch is true (cloud root, previously always false)
    - mode='shared' → showSearch is false

    App.vue keyboard dispatch:
    - matched.find(r => r.instances?.default)?.instances?.default on a mounted FileManagerView router returns an object with focusSearch defined
  </behavior>
  <action>
    keyboard.test.js — append a new describe block at the end of the existing file (do not modify or move existing describes):

    "Gap 4: getFileManagerInstance resolves to actual component, not RouterView proxy" — using the existing mountFileManager() helper and makeRouter(), after mounting FileManagerView and flushing promises, access `router.currentRoute.value.matched.find(r => r.instances?.default)?.instances?.default` and assert that it is not null and has a `focusSearch` property. This proves the resolution mechanism the refactored App.vue uses is correct. Import `useRouter` from vue-router at the top if not already imported — check existing imports first.

    TreeItem.test.js — create at `frontend/src/components/ui/__tests__/TreeItem.test.js`:
    - Import: `describe, it, expect, vi` from vitest; `mount, flushPromises` from @vue/test-utils; TreeItem from the component
    - Mock AppIcon: `vi.mock('../AppIcon.vue', () => ({ default: { template: '<span/>' } }))`
    - Helper: `mountExpanded(overrides = {})` — mounts TreeItem with `label="Test"`, `loadChildren: vi.fn().mockResolvedValue([])`, `expandable: true` and spreads overrides. After mount, call `w.vm.toggleExpand()` via `w.vm.$.setupState.toggleExpand()` (or trigger the expand button click) and flush promises to reach expanded state with children loaded.
    - For the loading=true test: mount with a loadChildren that returns a promise that never resolves (`new Promise(() => {})`). Click the expand button to trigger load. Before flushing, assert the HTML. The loading ref will be true while the promise is pending.
    - describe "Gap 1: sidebar shimmer rows": two tests as per behavior block above.
    - describe "Gap 1 regression: non-loading branches unchanged": one test asserting that with loadChildren resolving to [] and expanded, the "Empty" text appears (sibling v-else-if branch guard).

    StorageBrowser.showSearch.test.js — create at `frontend/src/components/storage/__tests__/StorageBrowser.showSearch.test.js`:
    - Heavy stub setup: all child components that StorageBrowser imports must be stubbed (SearchBar, SortControls, DocumentCard, FolderRow, EmptyState, AppIcon, BreadcrumbBar, DropZone, etc.) — use `stubs: { SearchBar: true, SortControls: true, ... }` in global mount options or individual vi.mock calls.
    - Mock all API calls used on mount (listDocuments, listFolders, etc.) via vi.mock('../../../api/client.js', ...).
    - After mount with given props, access `w.vm.showSearch` directly as a computed.
    - Props for mount: at minimum `mode`, `breadcrumb`, `documents: []`, `folders: []`, `topicColorFn: () => '#000'`, `loading: false`.
    - Four it() blocks as per behavior block above.
    - Wrap in `describe("Gap 2: showSearch visible at root for local and cloud modes", ...)`.

    All new tests run as part of `npm run test -- --run` without additional flags.
  </action>
  <verify>
    <automated>cd /Users/nik/Documents/Progamming/document_scanner/frontend && npm run test -- --reporter=verbose --run 2>&1 | grep -E "(PASS|FAIL|SKIP| ✓ | × | ✗ )" | tail -50</automated>
  </verify>
  <done>
    - All new describe blocks appear in output with passing test indicators
    - `npm run test -- --run` exits 0 with zero failures
    - `grep -c "animate-pulse" src/components/ui/__tests__/TreeItem.test.js` returns at least 1
    - `grep -c "showSearch" src/components/storage/__tests__/StorageBrowser.showSearch.test.js` returns at least 4
  </done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| OS file drag → window drop (capture) | Files arrive via browser DataTransfer API — browser-enforced; no network boundary crossed; payload handled only client-side before going through existing authenticated upload flow |
| Admin route layout selection | Template branch selection is display-only — backend enforces admin access via get_current_admin dep on every admin endpoint; changing template rendering creates no auth regression |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-10gc-01 | Tampering | OsDragOverlay capture-phase drop handler | accept | Handler reads only e.dataTransfer.files (browser-controlled), resets overlay state, emits to parent — no direct server interaction; upload goes through existing authenticated API path unchanged |
| T-10gc-02 | Spoofing | router.currentRoute.value.matched.find().instances.default | accept | Read-only Vue internals lookup; no user-supplied data flows through this path; cannot be spoofed via URL manipulation — Vue populates instances after component mount, not from route params |
| T-10gc-03 | Elevation of Privilege | App.vue v-else-if admin branch | accept | Branch adds a missing layout guard (removes AppSidebar for admin); it does not grant or deny route access — navigation guard in router/index.js is unchanged and continues to enforce requiresAdmin check |
| T-10gc-SC | Tampering | npm/pip/cargo installs | accept | No new packages installed — all fixes are template/script edits to existing files |
</threat_model>

<verification>
Manual UAT re-run checklist (all must pass before marking complete):

Gap 1 — Sidebar shimmer: Expand a cloud provider tree item in the sidebar while data loads. Animated shimmer rows appear; "Loading…" text is absent.

Gap 2 — Search at root: Navigate to / (file manager root, no folder entered). Search bar and sort controls are visible in the content area header.

Gap 3 — Admin sidebar: Navigate to /admin/users in a logged-in admin session. Only the AdminLayout and AdminSidebar render — AppSidebar (user nav) is absent.

Gap 4 — Keyboard shortcuts: With the file manager at root, press '/'. Search bar receives cursor focus. Press 'U'. File picker dialog opens. Press 'N'. New folder inline input appears.

Gap 5 — Escape behavior: Type text into the search bar. Press Escape. Field clears. Cursor remains in the field. Type again immediately — new characters appear (no re-click required).

Gap 6 — OS drag-drop: Drag a file from Finder/Explorer over the browser window. Overlay appears. Release the file. Upload begins (progress visible or toast appears).

Automated gate:
`cd frontend && npm run test -- --run` — exits 0, zero failures, no skipped tests regressed.
</verification>

<success_criteria>
- All 6 UAT gaps confirmed closed by manual re-run of the 6 scenarios above
- `npm run test -- --run` exits 0 with zero failures across the full suite (baseline: 211 passing)
- Five production files modified with surgical changes only — no refactors, no feature additions
- Three new test files committed with passing tests covering each gap
- `grep -c "Loading" frontend/src/components/ui/TreeItem.vue` returns 0
- `grep "showSearch" frontend/src/components/storage/StorageBrowser.vue` matches the `|| props.mode === 'cloud'` form
- `grep "routeViewRef" frontend/src/App.vue` returns empty
- `grep "requiresAdmin" frontend/src/App.vue` returns the v-else-if template branch line
- `grep "addEventListener.*drop.*true" frontend/src/components/layout/OsDragOverlay.vue` returns a match
- `grep "removeEventListener.*drop.*true" frontend/src/components/layout/OsDragOverlay.vue` returns a match
- `grep "prevent.stop" frontend/src/components/documents/SearchBar.vue` returns the escape handler line
</success_criteria>

<output>
Create `/Users/nik/Documents/Progamming/document_scanner/.planning/phases/10-ux-interaction/10-13-SUMMARY.md` when done.
</output>
