diff --git a/.planning/phases/10-ux-interaction/10-13-SUMMARY.md b/.planning/phases/10-ux-interaction/10-13-SUMMARY.md
new file mode 100644
index 0000000..f73fcbc
--- /dev/null
+++ b/.planning/phases/10-ux-interaction/10-13-SUMMARY.md
@@ -0,0 +1,116 @@
+---
+phase: 10
+plan: 13
+subsystem: frontend-ux
+tags: [gap-closure, uat, shimmer, keyboard, admin-layout, drag-drop, search]
+dependency_graph:
+ requires: [10-01, 10-05, 10-06, 10-07, 10-08, 10-12]
+ provides: [uat-gap-closure-all-6, phase-10-sign-off]
+ affects: [TreeItem.vue, StorageBrowser.vue, App.vue, SearchBar.vue, OsDragOverlay.vue]
+tech_stack:
+ added: []
+ patterns:
+ - "router.currentRoute.value.matched.find(r => r.instances?.default) for direct component instance access"
+ - "window.addEventListener capture=true for drag-drop above bubble-phase folder handlers"
+ - "@keydown.escape.prevent.stop to suppress type=search native clear+blur"
+key_files:
+ created:
+ - frontend/src/components/ui/__tests__/TreeItem.test.js
+ - frontend/src/components/storage/__tests__/StorageBrowser.showSearch.test.js
+ 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
+decisions:
+ - "Use matched.find(r => r.instances?.default) to access FileManagerView instance directly instead of routeViewRef (which resolves to RouterView proxy)"
+ - "Shimmer test uses refresh() flow (expanded=true + reload) since toggleExpand sets expanded only after load completes"
+ - "StorageBrowser showSearch: OR condition (local OR cloud) instead of AND with breadcrumb length"
+metrics:
+ duration: ~15 min
+ completed: 2026-06-16
+ tasks_completed: 4
+ files_changed: 8
+---
+
+# Phase 10 Plan 13: UAT Gap Closure — All 6 Root Causes Summary
+
+Closed all 6 UAT gaps that blocked Phase 10 sign-off. Five production files modified surgically, three test files added, 219 tests passing.
+
+## What Was Built
+
+Targeted fixes for 6 root-cause gaps from UAT (`10-UAT.md`):
+
+- **Gap 1 — Sidebar shimmer**: `TreeItem.vue` loading branch replaced with 3 animate-pulse shimmer rows (icon + text placeholder pattern from AppSidebar.vue). `Loading…` text eliminated.
+- **Gap 2 — Search at root**: `StorageBrowser.vue` `showSearch` computed changed from `mode==='local' && breadcrumb.length > 0` to `mode==='local' || mode==='cloud'`. Search and sort controls now visible at the root of both local and cloud browsers.
+- **Gap 3 — Admin sidebar bleed**: `App.vue` gained a `v-else-if` branch for admin routes that renders only `` with no AppSidebar or main wrapper.
+- **Gap 4 — Keyboard shortcuts broken**: `App.vue` `routeViewRef` (which resolves to RouterView proxy) replaced with `getFileManagerInstance()` that uses `router.currentRoute.value.matched.find(r => r.instances?.default)?.instances?.default`. All keyboard dispatch calls (`/`, Escape, U, N) and OS drop handler updated.
+- **Gap 5 — Escape clears search but loses focus**: `SearchBar.vue` escape handler changed from `@keydown.escape` to `@keydown.escape.prevent.stop`. `.prevent` stops browser's native clear+blur on `type="search"` inputs, `.stop` prevents bubbling to App.vue's global handler.
+- **Gap 6 — OS drag-drop not uploading**: `OsDragOverlay.vue` `drop` listener changed to capture phase (`addEventListener('drop', this.onDrop, true)`). Both `addEventListener` and `removeEventListener` carry the `true` third argument so cleanup works correctly.
+
+## Task Commits
+
+| Task | Commit | Description |
+|------|--------|-------------|
+| 1 | 76785b4 | Shimmer rows (TreeItem.vue) + showSearch fix (StorageBrowser.vue) |
+| 2 | 5972a62 | Admin sidebar bleed + keyboard instance resolution (App.vue) |
+| 3 | bac5dcf | Escape modifier (SearchBar.vue) + capture-phase drop (OsDragOverlay.vue) |
+| 4 | 339f5a0 | Regression tests for all 6 gaps |
+
+## Deviations from Plan
+
+### Auto-adjusted Issues
+
+**1. [Rule 1 - Bug] TreeItem shimmer test used refresh() flow instead of direct toggle**
+- **Found during:** Task 4
+- **Issue:** `toggleExpand()` sets `expanded=true` only AFTER `load()` completes. While `loading=true`, `expanded` is still `false`, so `v-if="expanded"` hides the shimmer block. No state where `expanded=true && loading=true` can be reached via the initial expand path.
+- **Fix:** Test uses `refresh()` call (which loads while already expanded) to reach the `expanded=true && loading=true` state.
+- **Files modified:** `frontend/src/components/ui/__tests__/TreeItem.test.js`
+- **Commit:** 339f5a0
+
+**2. [Worktree path] Early edits went to main repo instead of worktree**
+- **Found during:** Task 1-2 (first commit attempt)
+- **Issue:** First two Edit calls used the main repo path (`/Users/nik/Documents/Progamming/document_scanner/...`) instead of the worktree path (`/Users/nik/Documents/Progamming/document_scanner/.claude/worktrees/agent-a2c859712240996ff/...`). The changes committed to the main repo's `main` branch.
+- **Fix:** Reverted approach: re-applied all changes to the worktree files using the correct absolute paths. The main repo has two extra commits (f9e5a31, 42ab542) that duplicate the task 1-2 changes — those will be resolved at merge/orchestrator level.
+- **Commits affected:** 76785b4, 5972a62 are the correct worktree commits.
+
+## Test Results
+
+```
+Test Files 30 passed (30)
+ Tests 219 passed (219)
+ Duration ~2.7s
+```
+
+8 new tests added:
+- `TreeItem.test.js`: 3 tests (shimmer visible, no Loading text, Empty branch unchanged)
+- `StorageBrowser.showSearch.test.js`: 4 tests (local root, local non-root, cloud root, shared=false)
+- `keyboard.test.js`: 1 new test (Gap 4 instance resolution via router-view)
+
+## Known Stubs
+
+None — all gaps are wired to real component behavior.
+
+## Threat Flags
+
+None — all changes are display-only template modifications and event handler configuration. No new network endpoints, auth paths, or schema changes introduced.
+
+## Self-Check: PASSED
+
+Files created/modified:
+- [x] `frontend/src/components/ui/TreeItem.vue` — animate-pulse present, Loading text absent
+- [x] `frontend/src/components/storage/StorageBrowser.vue` — showSearch uses || cloud
+- [x] `frontend/src/App.vue` — routeViewRef removed, requiresAdmin branch added
+- [x] `frontend/src/components/documents/SearchBar.vue` — .prevent.stop on escape
+- [x] `frontend/src/components/layout/OsDragOverlay.vue` — true capture arg on drop
+- [x] `frontend/src/__tests__/keyboard.test.js` — Gap 4 test appended
+- [x] `frontend/src/components/ui/__tests__/TreeItem.test.js` — created
+- [x] `frontend/src/components/storage/__tests__/StorageBrowser.showSearch.test.js` — created
+
+Commits verified:
+- [x] 76785b4 — Task 1
+- [x] 5972a62 — Task 2
+- [x] bac5dcf — Task 3
+- [x] 339f5a0 — Task 4
diff --git a/frontend/src/__tests__/keyboard.test.js b/frontend/src/__tests__/keyboard.test.js
index a67df28..9e0cdba 100644
--- a/frontend/src/__tests__/keyboard.test.js
+++ b/frontend/src/__tests__/keyboard.test.js
@@ -136,3 +136,24 @@ describe('UX-08: "N" starts new folder input', () => {
expect(() => w.vm.startNewFolder()).not.toThrow()
})
})
+
+describe('Gap 4: getFileManagerInstance resolves to actual component, not RouterView proxy', () => {
+ it('matched.find(r => r.instances?.default) returns an object with focusSearch defined when mounted via router-view', async () => {
+ setActivePinia(createPinia())
+ const router = makeRouter()
+ await router.push('/')
+ await router.isReady()
+ // Mount via a router-view wrapper so Vue Router populates r.instances.default
+ const { defineComponent, h } = await import('vue')
+ const { RouterView } = await import('vue-router')
+ const App = defineComponent({ render: () => h(RouterView) })
+ mount(App, {
+ global: { plugins: [router], stubs: { QuotaBar: true, AppSpinner: true } },
+ })
+ await flushPromises()
+ const instance = router.currentRoute.value.matched.find(r => r.instances?.default)?.instances?.default
+ expect(instance).not.toBeNull()
+ expect(instance).toBeDefined()
+ expect(typeof instance.focusSearch).toBe('function')
+ })
+})
diff --git a/frontend/src/components/documents/SearchBar.vue b/frontend/src/components/documents/SearchBar.vue
index fb08074..2bee61e 100644
--- a/frontend/src/components/documents/SearchBar.vue
+++ b/frontend/src/components/documents/SearchBar.vue
@@ -8,7 +8,7 @@
aria-label="Search documents"
class="border border-gray-300 rounded-lg px-3 py-2 text-sm w-56 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
@input="emit('update:modelValue', $event.target.value)"
- @keydown.escape="emit('update:modelValue', '')"
+ @keydown.escape.prevent.stop="emit('update:modelValue', '')"
/>
diff --git a/frontend/src/components/layout/OsDragOverlay.vue b/frontend/src/components/layout/OsDragOverlay.vue
index d26b469..fcac7a0 100644
--- a/frontend/src/components/layout/OsDragOverlay.vue
+++ b/frontend/src/components/layout/OsDragOverlay.vue
@@ -53,13 +53,13 @@ export default {
window.addEventListener('dragenter', this.onDragEnter)
window.addEventListener('dragleave', this.onDragLeave)
window.addEventListener('dragover', this.onDragOver)
- window.addEventListener('drop', this.onDrop)
+ window.addEventListener('drop', this.onDrop, true)
},
beforeUnmount() {
window.removeEventListener('dragenter', this.onDragEnter)
window.removeEventListener('dragleave', this.onDragLeave)
window.removeEventListener('dragover', this.onDragOver)
- window.removeEventListener('drop', this.onDrop)
+ window.removeEventListener('drop', this.onDrop, true)
},
}
diff --git a/frontend/src/components/storage/__tests__/StorageBrowser.showSearch.test.js b/frontend/src/components/storage/__tests__/StorageBrowser.showSearch.test.js
new file mode 100644
index 0000000..227f7c9
--- /dev/null
+++ b/frontend/src/components/storage/__tests__/StorageBrowser.showSearch.test.js
@@ -0,0 +1,53 @@
+import { describe, it, expect, vi } from 'vitest'
+import { mount } from '@vue/test-utils'
+import { createPinia, setActivePinia } from 'pinia'
+import StorageBrowser from '../StorageBrowser.vue'
+
+const globalStubs = {
+ BreadcrumbBar: true,
+ SearchBar: true,
+ SortControls: true,
+ DropZone: true,
+ UploadProgress: true,
+ TopicBadge: true,
+ AppIcon: true,
+ EmptyState: true,
+}
+
+function mountBrowser(mode, breadcrumb = []) {
+ setActivePinia(createPinia())
+ return mount(StorageBrowser, {
+ props: {
+ mode,
+ breadcrumb,
+ documents: [],
+ folders: [],
+ files: [],
+ topicColorFn: () => '#000',
+ loading: false,
+ },
+ global: { stubs: globalStubs },
+ })
+}
+
+describe('Gap 2: showSearch visible at root for local and cloud modes', () => {
+ it("mode='local', breadcrumb=[] → showSearch is true (root-level local)", () => {
+ const w = mountBrowser('local', [])
+ expect(w.vm.showSearch).toBe(true)
+ })
+
+ it("mode='local', breadcrumb=[{name:'Folder'}] → showSearch is true (non-root local)", () => {
+ const w = mountBrowser('local', [{ name: 'Folder' }])
+ expect(w.vm.showSearch).toBe(true)
+ })
+
+ it("mode='cloud', breadcrumb=[] → showSearch is true (cloud root)", () => {
+ const w = mountBrowser('cloud', [])
+ expect(w.vm.showSearch).toBe(true)
+ })
+
+ it("mode='shared' → showSearch is false", () => {
+ const w = mountBrowser('shared', [])
+ expect(w.vm.showSearch).toBe(false)
+ })
+})
diff --git a/frontend/src/components/ui/__tests__/TreeItem.test.js b/frontend/src/components/ui/__tests__/TreeItem.test.js
new file mode 100644
index 0000000..732845e
--- /dev/null
+++ b/frontend/src/components/ui/__tests__/TreeItem.test.js
@@ -0,0 +1,68 @@
+import { describe, it, expect, vi } from 'vitest'
+import { mount, flushPromises } from '@vue/test-utils'
+import { nextTick } from 'vue'
+import TreeItem from '../TreeItem.vue'
+
+vi.mock('../AppIcon.vue', () => ({ default: { template: '' } }))
+
+function mountItem(overrides = {}) {
+ return mount(TreeItem, {
+ props: {
+ label: 'Test',
+ loadChildren: vi.fn().mockResolvedValue([]),
+ expandable: true,
+ ...overrides,
+ },
+ global: { stubs: { RouterLink: true } },
+ })
+}
+
+describe('Gap 1: sidebar shimmer rows', () => {
+ it('renders animate-pulse elements when loading=true (expanded via refresh)', async () => {
+ // First expand the item (so expanded=true, childrenLoaded=true)
+ let resolveFn1
+ const loadChildren = vi.fn()
+ .mockReturnValueOnce(Promise.resolve([])) // first load resolves immediately
+ .mockReturnValue(new Promise(resolve => { resolveFn1 = resolve })) // refresh never resolves
+ const w = mountItem({ loadChildren })
+ // Expand by awaiting toggleExpand — sets expanded=true after load completes
+ const setupState = w.vm.$.setupState
+ await setupState.toggleExpand()
+ await flushPromises()
+ // Now call refresh() without awaiting — it calls load() which sets loading=true while expanded stays true
+ setupState.refresh() // NOT awaited — loading=true while promise is pending
+ await nextTick() // let Vue update the DOM
+ const pulseEls = w.findAll('.animate-pulse')
+ expect(pulseEls.length).toBeGreaterThanOrEqual(2)
+ // Cleanup
+ if (resolveFn1) resolveFn1([])
+ await flushPromises()
+ })
+
+ it('does not render "Loading" text when loading=true (expanded via refresh)', async () => {
+ let resolveFn1
+ const loadChildren = vi.fn()
+ .mockReturnValueOnce(Promise.resolve([]))
+ .mockReturnValue(new Promise(resolve => { resolveFn1 = resolve }))
+ const w = mountItem({ loadChildren })
+ const setupState = w.vm.$.setupState
+ await setupState.toggleExpand()
+ await flushPromises()
+ setupState.refresh()
+ await nextTick()
+ expect(w.text()).not.toContain('Loading')
+ if (resolveFn1) resolveFn1([])
+ await flushPromises()
+ })
+})
+
+describe('Gap 1 regression: non-loading branches unchanged', () => {
+ it('shows Empty text when loadChildren resolves to [] and item is expanded', async () => {
+ const w = mountItem({ loadChildren: vi.fn().mockResolvedValue([]) })
+ const toggleExpand = w.vm.$.setupState.toggleExpand
+ await toggleExpand()
+ await flushPromises()
+ await nextTick()
+ expect(w.text()).toContain('Empty')
+ })
+})