From 71f55b84a9af7a1b8354685fdbae2d155537b742 Mon Sep 17 00:00:00 2001 From: curo1305 Date: Mon, 15 Jun 2026 20:43:58 +0200 Subject: [PATCH 1/4] test(10-10): promote OsDragOverlay stubs to 8 RED failing tests - Replace 7 it.todo stubs with 8 real tests covering UX-09 contract - Tests dispatch DragEvent/Event with synthetic dataTransfer on window - Covers: hidden by default, Files type guard, depth counter, nested enter/leave, drop emit, drop reset, z-[9998] class on overlay element - All 8 tests RED (OsDragOverlay.vue does not yet exist) --- .../layout/__tests__/OsDragOverlay.test.js | 163 +++++++++++++++++- 1 file changed, 155 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/layout/__tests__/OsDragOverlay.test.js b/frontend/src/components/layout/__tests__/OsDragOverlay.test.js index 904f0fc..bf2aad1 100644 --- a/frontend/src/components/layout/__tests__/OsDragOverlay.test.js +++ b/frontend/src/components/layout/__tests__/OsDragOverlay.test.js @@ -1,11 +1,158 @@ -import { describe, it } from 'vitest' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { mount } from '@vue/test-utils' +import OsDragOverlay from '../OsDragOverlay.vue' + +function makeDragEvent(type, types = ['Files'], files = []) { + const event = new Event(type, { bubbles: true, cancelable: true }) + Object.defineProperty(event, 'dataTransfer', { + value: { types, files, preventDefault: vi.fn() }, + writable: false, + }) + event.preventDefault = vi.fn() + return event +} describe('UX-09: OsDragOverlay shows when OS files are dragged over the window', () => { - it.todo('overlay hidden by default (dragDepth=0)') - it.todo('dragenter with dataTransfer.types including "Files" shows overlay') - it.todo('dragenter without "Files" type is ignored (in-app element drag)') - it.todo('dragleave decrements depth; overlay hides when depth reaches 0') - it.todo('drop event emits files-dropped with the file list') - it.todo('drop resets dragDepth to 0 and hides overlay') - it.todo('overlay is teleported to body with z-[9998] (below toast z-[9999])') + it('overlay hidden by default (dragDepth=0)', () => { + const w = mount(OsDragOverlay, { + attachTo: document.body, + global: { stubs: { Teleport: true, AppIcon: true } }, + }) + expect(w.vm.showOverlay).toBe(false) + expect(w.vm.dragDepth).toBe(0) + w.unmount() + }) + + it('dragenter with dataTransfer.types including "Files" shows overlay (dragDepth=1)', async () => { + const w = mount(OsDragOverlay, { + attachTo: document.body, + global: { stubs: { Teleport: true, AppIcon: true } }, + }) + const event = makeDragEvent('dragenter', ['Files']) + window.dispatchEvent(event) + await w.vm.$nextTick() + expect(w.vm.showOverlay).toBe(true) + expect(w.vm.dragDepth).toBe(1) + w.unmount() + }) + + it('dragenter without "Files" type is ignored (in-app element drag)', async () => { + const w = mount(OsDragOverlay, { + attachTo: document.body, + global: { stubs: { Teleport: true, AppIcon: true } }, + }) + const event = makeDragEvent('dragenter', ['text/plain']) + window.dispatchEvent(event) + await w.vm.$nextTick() + expect(w.vm.showOverlay).toBe(false) + expect(w.vm.dragDepth).toBe(0) + w.unmount() + }) + + it('dragleave decrements depth; overlay hides when depth reaches 0', async () => { + const w = mount(OsDragOverlay, { + attachTo: document.body, + global: { stubs: { Teleport: true, AppIcon: true } }, + }) + window.dispatchEvent(makeDragEvent('dragenter', ['Files'])) + await w.vm.$nextTick() + expect(w.vm.dragDepth).toBe(1) + expect(w.vm.showOverlay).toBe(true) + + window.dispatchEvent(new Event('dragleave')) + await w.vm.$nextTick() + expect(w.vm.dragDepth).toBe(0) + expect(w.vm.showOverlay).toBe(false) + w.unmount() + }) + + it('nested dragenter+dragleave maintains overlay until depth=0', async () => { + const w = mount(OsDragOverlay, { + attachTo: document.body, + global: { stubs: { Teleport: true, AppIcon: true } }, + }) + window.dispatchEvent(makeDragEvent('dragenter', ['Files'])) + window.dispatchEvent(makeDragEvent('dragenter', ['Files'])) + await w.vm.$nextTick() + expect(w.vm.dragDepth).toBe(2) + expect(w.vm.showOverlay).toBe(true) + + window.dispatchEvent(new Event('dragleave')) + await w.vm.$nextTick() + expect(w.vm.dragDepth).toBe(1) + expect(w.vm.showOverlay).toBe(true) + + window.dispatchEvent(new Event('dragleave')) + await w.vm.$nextTick() + expect(w.vm.dragDepth).toBe(0) + expect(w.vm.showOverlay).toBe(false) + w.unmount() + }) + + it('drop event emits files-dropped with the file list', async () => { + const w = mount(OsDragOverlay, { + attachTo: document.body, + global: { stubs: { Teleport: true, AppIcon: true } }, + }) + window.dispatchEvent(makeDragEvent('dragenter', ['Files'])) + await w.vm.$nextTick() + + const file = new File(['x'], 'a.txt', { type: 'text/plain' }) + const dropEvent = new Event('drop', { bubbles: true, cancelable: true }) + Object.defineProperty(dropEvent, 'dataTransfer', { + value: { files: [file] }, + writable: false, + }) + dropEvent.preventDefault = vi.fn() + window.dispatchEvent(dropEvent) + await w.vm.$nextTick() + + const emitted = w.emitted('files-dropped') + expect(emitted).toBeTruthy() + expect(emitted[0][0]).toHaveLength(1) + expect(emitted[0][0][0]).toBeInstanceOf(File) + w.unmount() + }) + + it('drop resets dragDepth to 0 and hides overlay', async () => { + const w = mount(OsDragOverlay, { + attachTo: document.body, + global: { stubs: { Teleport: true, AppIcon: true } }, + }) + window.dispatchEvent(makeDragEvent('dragenter', ['Files'])) + window.dispatchEvent(makeDragEvent('dragenter', ['Files'])) + window.dispatchEvent(makeDragEvent('dragenter', ['Files'])) + await w.vm.$nextTick() + expect(w.vm.dragDepth).toBe(3) + + const dropEvent = new Event('drop', { bubbles: true, cancelable: true }) + Object.defineProperty(dropEvent, 'dataTransfer', { + value: { files: [] }, + writable: false, + }) + dropEvent.preventDefault = vi.fn() + window.dispatchEvent(dropEvent) + await w.vm.$nextTick() + + expect(w.vm.dragDepth).toBe(0) + expect(w.vm.showOverlay).toBe(false) + + window.dispatchEvent(new Event('dragleave')) + await w.vm.$nextTick() + expect(w.vm.dragDepth).toBe(0) + w.unmount() + }) + + it('overlay is teleported to body with z-[9998] (below toast z-[9999])', async () => { + const w = mount(OsDragOverlay, { + attachTo: document.body, + }) + window.dispatchEvent(makeDragEvent('dragenter', ['Files'])) + await w.vm.$nextTick() + + const overlay = document.querySelector('[data-test="os-drag-overlay"]') + expect(overlay).toBeTruthy() + expect(overlay.classList.contains('z-[9998]')).toBe(true) + w.unmount() + }) }) From 20eceb898319228a43eba6341366e0c1893a781e Mon Sep 17 00:00:00 2001 From: curo1305 Date: Mon, 15 Jun 2026 20:44:39 +0200 Subject: [PATCH 2/4] =?UTF-8?q?feat(10-10):=20implement=20OsDragOverlay.vu?= =?UTF-8?q?e=20=E2=80=94=20OS=20file=20drag=20detection=20with=20depth=20c?= =?UTF-8?q?ounter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Options API component; emits files-dropped with file list - Depth-counter pattern (D-16) prevents flicker across child element boundaries - Guards against in-app drags via dataTransfer.types.includes('Files') check - window addEventListener/removeEventListener in mounted/beforeUnmount - Teleport to body; z-[9998] keeps overlay below ToastContainer z-[9999] - Fade transition via scoped CSS (.fade-enter-active/.fade-leave-active) - 8/8 tests GREEN --- .../src/components/layout/OsDragOverlay.vue | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 frontend/src/components/layout/OsDragOverlay.vue diff --git a/frontend/src/components/layout/OsDragOverlay.vue b/frontend/src/components/layout/OsDragOverlay.vue new file mode 100644 index 0000000..d26b469 --- /dev/null +++ b/frontend/src/components/layout/OsDragOverlay.vue @@ -0,0 +1,76 @@ + + + + + From 69bf40af3227dd3225f41d559e208c98541296b8 Mon Sep 17 00:00:00 2001 From: curo1305 Date: Mon, 15 Jun 2026 20:46:05 +0200 Subject: [PATCH 3/4] feat(10-10): wire OsDragOverlay into App.vue; expose handleOsDrop on FileManagerView MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - App.vue: import + mount after (overlay z-[9998] stays below toast z-[9999]) - App.vue: add onOsFilesDropped handler -> routeViewRef.value?.handleOsDrop?.(files) - FileManagerView: extend defineExpose to include handleOsDrop delegating to existing onFilesSelected({ files, autoClassify: true }) — no duplicate upload logic - Full test suite: 198 passed, 0 failures --- frontend/src/App.vue | 6 ++++++ frontend/src/views/FileManagerView.vue | 1 + 2 files changed, 7 insertions(+) diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 9f9658f..1d77e58 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -7,6 +7,7 @@ + From 026d3743c34c4fb76585590d358bd022e2626a0b Mon Sep 17 00:00:00 2001 From: curo1305 Date: Mon, 15 Jun 2026 20:47:32 +0200 Subject: [PATCH 4/4] =?UTF-8?q?docs(10-10):=20complete=20OS=20drag=20overl?= =?UTF-8?q?ay=20plan=20=E2=80=94=20OsDragOverlay.vue=20+=20App.vue=20wirin?= =?UTF-8?q?g=20+=208=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UX-09 fully implemented: depth-counter drag detection, Teleport overlay, FileManagerView.handleOsDrop wired through App.vue routeViewRef. 198/198 tests passing, 0 regressions. --- .../phases/10-ux-interaction/10-10-SUMMARY.md | 124 ++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 .planning/phases/10-ux-interaction/10-10-SUMMARY.md diff --git a/.planning/phases/10-ux-interaction/10-10-SUMMARY.md b/.planning/phases/10-ux-interaction/10-10-SUMMARY.md new file mode 100644 index 0000000..c274f5f --- /dev/null +++ b/.planning/phases/10-ux-interaction/10-10-SUMMARY.md @@ -0,0 +1,124 @@ +--- +phase: 10-ux-interaction +plan: "10" +subsystem: frontend/ux +tags: [os-drag, overlay, depth-counter, teleport, vitest, tdd, ux-09, wave-3] +dependency_graph: + requires: [10-04, 10-05, 10-06, 10-09] + provides: [UX-09-os-drag-overlay, OsDragOverlay-component, App.vue-osDrop-wiring, FileManagerView-handleOsDrop] + affects: [App.vue, FileManagerView.vue, OsDragOverlay.vue, OsDragOverlay.test.js] +tech_stack: + added: [] + patterns: [depth-counter-drag-detection, teleport-to-body, window-event-listeners, options-api-component, tdd-red-green] +key_files: + created: + - frontend/src/components/layout/OsDragOverlay.vue + modified: + - frontend/src/components/layout/__tests__/OsDragOverlay.test.js + - frontend/src/App.vue + - frontend/src/views/FileManagerView.vue +decisions: + - "Depth-counter pattern (dragDepth++) used instead of boolean flag to prevent flicker when cursor crosses child element boundaries" + - "dataTransfer.types.includes('Files') guard ensures in-app drags (file rows, folder rows) do not trigger the overlay" + - "OsDragOverlay placed after ToastContainer in App.vue template, maintaining z-[9998] < z-[9999] z-order invariant" + - "handleOsDrop delegates to existing onFilesSelected rather than duplicating upload logic — quota, progress, toast wiring all reused" + - "node_modules symlinked/installed in worktree frontend to make vitest available without polluting main checkout" +metrics: + duration_minutes: 6 + completed_date: "2026-06-15T18:46:33Z" + tasks_completed: 3 + tasks_total: 3 + files_created: 1 + files_modified: 3 +--- + +# Phase 10 Plan 10: OS File Drag Overlay Summary + +**One-liner:** Full-screen OS drag overlay (OsDragOverlay.vue) wired end-to-end via depth-counter pattern: window dragenter/drop events in App.vue route through routeViewRef to FileManagerView.handleOsDrop which calls the existing upload flow. + +## Tasks Completed + +| Task | Name | Commit | Files | +|------|------|--------|-------| +| 1 | Promote OsDragOverlay stubs to 8 RED failing tests | 71f55b8 | `layout/__tests__/OsDragOverlay.test.js` | +| 2 | Implement OsDragOverlay.vue (GREEN) | 20eceb8 | `layout/OsDragOverlay.vue` | +| 3 | Mount in App.vue; expose handleOsDrop in FileManagerView | 69bf40a | `App.vue`, `FileManagerView.vue` | + +## What Was Built + +### Task 1: RED Tests + +Replaced 7 `it.todo` stubs in `OsDragOverlay.test.js` with 8 real tests. The plan specified 8 tests while the stub file had 7 entries; the extra test covers "nested dragenter+dragleave" (depth-2 scenario, critical for the depth-counter correctness). Tests use synthetic DragEvent / Event dispatched on `window` with `Object.defineProperty` to attach fake `dataTransfer`. All 8 were RED before Task 2. + +### Task 2: OsDragOverlay.vue + +Options API component implementing UX-09: + +- `data`: `dragDepth: 0`, `showOverlay: false` +- `onDragEnter`: ignores events where `e.dataTransfer?.types.includes('Files')` is false (in-app drags); increments `dragDepth` and sets `showOverlay = true` +- `onDragLeave`: decrements via `Math.max(0, dragDepth - 1)`; hides when depth reaches 0 +- `onDragOver`: calls `e.preventDefault()` (required for drop to fire) +- `onDrop`: resets depth=0, hides overlay, emits `files-dropped` with `Array.from(e.dataTransfer.files)` +- `mounted()`/`beforeUnmount()`: register/remove 4 window listeners +- Template: `` + `` + overlay div with `z-[9998]` and `data-test="os-drag-overlay"` +- Scoped CSS: `.fade-enter-active/.fade-leave-active` with `transition: opacity 0.15s ease` + +### Task 3: Wiring + +**App.vue:** +- Added `import OsDragOverlay from './components/layout/OsDragOverlay.vue'` +- Added `` immediately after `` (line 10 vs line 9) +- Added `function onOsFilesDropped(files) { routeViewRef.value?.handleOsDrop?.(files) }` — double optional chain: no-ops on non-file-manager routes where `handleOsDrop` is not exposed + +**FileManagerView.vue:** +- Extended `defineExpose` block with `handleOsDrop: (files) => onFilesSelected({ files, autoClassify: true })` +- No duplicate upload logic — existing `onFilesSelected` function handles per-file UploadProgress items, quota error detection (e.status 413), and the summary toast + +## Verification Results + +| Check | Result | +|-------|--------| +| `vitest run OsDragOverlay` — 8 tests | PASS (all GREEN) | +| `vitest run keyboard` — 9 tests | PASS (no regression) | +| `vitest run FileManagerView` — 20 tests | PASS (no regression) | +| Full suite — 198 tests, 13 todo | PASS (0 failures) | +| OsDragOverlay placed AFTER ToastContainer | PASS (line 10 vs line 9) | +| OsDragOverlay z-[9998] < ToastContainer z-[9999] | PASS | +| FileManagerView.handleOsDrop reuses onFilesSelected | PASS | +| `routeViewRef.value?.handleOsDrop?.(files)` in App.vue | PASS | +| No duplicate upload logic | PASS | + +## Deviations from Plan + +### Auto-fixed Issues + +**1. [Rule 3 - Blocking] node_modules not available in git worktree** +- **Found during:** Task 1 +- **Issue:** The worktree has no `node_modules` directory; `npm run test` (using the global vitest) and the main repo's vitest binary both fail to resolve imports when the test root points at the worktree +- **Fix:** Ran `npm install` in the worktree's `frontend/` directory to create a local `node_modules` matching the `package.json`. Tests then run correctly with `worktree/frontend/node_modules/.bin/vitest` +- **Files modified:** none (runtime setup only) +- **Commit:** none (npm install not committed — `node_modules` is gitignored) + +## Known Stubs + +None. All files created and modified in this plan have complete implementations with no placeholder values, TODO markers, or hardcoded empty data flowing to the UI. + +## Threat Flags + +None. OsDragOverlay handles OS file drop at the browser level and delegates to the existing `onFilesSelected` → `docsStore.upload` path. No new network endpoints, auth paths, or schema changes are introduced. The existing upload path has quota enforcement, JWT auth headers, and error handling already in place. + +## TDD Gate Compliance + +- RED gate: commit `71f55b8` — 8 failing tests (`test(10-10): promote OsDragOverlay stubs...`) +- GREEN gate: commit `20eceb8` — implementation passes 8 tests (`feat(10-10): implement OsDragOverlay.vue...`) +- REFACTOR gate: not required (no cleanup needed after GREEN) + +## Self-Check: PASSED + +- `frontend/src/components/layout/OsDragOverlay.vue` — exists, contains `name: 'OsDragOverlay'`, `emits: ['files-dropped']`, `dragDepth: 0`, `dataTransfer?.types.includes('Files')`, 4 window listeners, ``, `z-[9998]`, no `