docs(10): create phase plan — 12 plans, 6 waves, UX-01..14 + CODE-05

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
curo1305
2026-06-15 06:55:11 +02:00
co-authored by Claude Sonnet 4.6
parent c84dcb77c1
commit d3d3f711eb
16 changed files with 4611 additions and 2 deletions
+31 -1
View File
@@ -647,7 +647,37 @@ _Started: 2026-06-07_
3. Dragging files from the OS onto any part of the browser window (not just a drop zone) shows a full-screen overlay and uploads them on drop
4. Every upload, delete, share, revoke, and rename action produces a toast notification that auto-dismisses after 4 seconds and does not block interaction with the page
5. All views display a breadcrumb rendered by a single shared component; the breadcrumb reflects the full navigation path and updates on every route change
**Plans**: TBD
**Plans**: 12 plans (6 waves)
**Wave 0** — Foundation components + xfail test stubs (parallel)
- [ ] 10-01-PLAN.md — AppIcon.vue + tests (CODE-05 foundation)
- [ ] 10-02-PLAN.md — EmptyState.vue + tests (UX-01 foundation)
- [ ] 10-03-PLAN.md — BreadcrumbBar.vue + tests (UX-12 foundation)
- [ ] 10-04-PLAN.md — Toast store + ToastContainer.vue + App.vue mount + tests (UX-10 foundation)
- [ ] 10-05-PLAN.md — Wave 0 xfail test stubs for UX-02..09, UX-11, UX-13, UX-14
**Wave 1** *(blocked on Wave 0 foundation components)* — Wire EmptyState, skeletons, BreadcrumbBar, UX-14, toast call sites (parallel)
- [ ] 10-06-PLAN.md — StorageBrowser + FileManagerView + CloudFolderView wiring (skeleton, EmptyState, BreadcrumbBar swap, FolderBreadcrumb deletion, toast call sites) — UX-02, UX-01 (storage), UX-10 (file actions), UX-12 (file manager)
- [ ] 10-07-PLAN.md — AppSidebar wiring (skeleton, EmptyState micro, UX-14 removal) — UX-03, UX-01 (sidebar), UX-14
- [ ] 10-08-PLAN.md — Admin views + Settings + SharedView + CloudStorageView (skeleton, EmptyState, BreadcrumbBar static segments) — UX-04, UX-01 (remaining), UX-12 (admin)
**Wave 2** *(blocked on Wave 1)* — Keyboard shortcuts
- [ ] 10-09-PLAN.md — Global keydown in App.vue + ref chain through FileManagerView/StorageBrowser/DropZone/SearchBar — UX-05, UX-06, UX-07, UX-08
**Wave 3** *(blocked on Wave 2)* — OS drag overlay
- [ ] 10-10-PLAN.md — OsDragOverlay.vue + App.vue mount + FileManagerView.handleOsDrop — UX-09
**Wave 4** *(blocked on Waves 1-3)* — Drag-to-move completion + dropdown clipping fixes
- [ ] 10-11-PLAN.md — Click-after-drag guard in StorageBrowser + Teleport-based folder picker (StorageBrowser, DocumentCard) + FolderRow three-dot menu — UX-11, UX-13
**Wave 5** *(blocked on Wave 4)* — SVG centralization across the codebase
- [ ] 10-12-PLAN.md — Replace all inline `<svg>` blocks in ~29 files with `<AppIcon name="..." class="..." />` — CODE-05
**UI hint**: yes
---
@@ -0,0 +1,169 @@
---
phase: 10-ux-interaction
plan: 01
type: execute
wave: 0
depends_on: []
files_modified:
- frontend/src/components/ui/AppIcon.vue
- frontend/src/components/ui/__tests__/AppIcon.test.js
autonomous: true
requirements: [CODE-05]
must_haves:
truths:
- "AppIcon renders the correct <svg> path for any name in the icon map"
- "AppIcon forwards the consumer's class attribute to the outer <svg> element"
- "AppIcon supports both single-path and dual-path (Array) icons (cog uses two paths)"
- "Unknown icon names trigger console.warn in dev mode and render nothing"
artifacts:
- path: "frontend/src/components/ui/AppIcon.vue"
provides: "Centralized icon registry component"
contains: "ICON_PATHS"
- path: "frontend/src/components/ui/__tests__/AppIcon.test.js"
provides: "AppIcon unit tests"
key_links:
- from: "AppIcon.vue script"
to: "ICON_PATHS map"
via: "computed resolvedPaths"
pattern: "ICON_PATHS\\[this\\.name\\]"
---
<objective>
Create `frontend/src/components/ui/AppIcon.vue` — the single source of truth for all SVG icon paths used in DocuVault. This is the foundation for the CODE-05 SVG migration that happens in Wave 5.
Purpose: Eliminate ~66 inline `<svg>` blocks across 29 files by centralizing the icon paths into one map. All existing SVGs use the same outline/stroke style (`fill="none" stroke="currentColor" viewBox="0 0 24 24"` with `stroke-linecap="round" stroke-linejoin="round" stroke-width="2"`) — AppIcon matches this exactly so the migration is a drop-in replacement.
Output: `AppIcon.vue` (Options API, ~30 named icons including the dual-path `cog`) + a Vitest unit test covering name→path rendering, class forwarding, dual-path handling, and unknown-name warning.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@CLAUDE.md
@.planning/phases/10-ux-interaction/10-CONTEXT.md
@.planning/phases/10-ux-interaction/10-RESEARCH.md
@.planning/phases/10-ux-interaction/10-PATTERNS.md
@frontend/src/components/ui/AppSpinner.vue
@frontend/src/components/layout/AppSidebar.vue
@frontend/src/components/folders/__tests__/FolderBreadcrumb.test.js
<interfaces>
Per D-08, D-09, D-10 (CONTEXT.md):
- Props: `name` (String, required)
- inheritAttrs: false
- $attrs.class forwarded to outer <svg>
- SVG attrs: `fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"`
- Path attrs: `stroke-linecap="round" stroke-linejoin="round" stroke-width="2"`
- Array path support for `cog` (dual path)
- Unknown name: console.warn in import.meta.env.DEV; render nothing (v-if guard)
Project Vitest layout (verified from existing tests at frontend/src/components/folders/__tests__/FolderBreadcrumb.test.js):
- Tests live in __tests__/ alongside the component
- Uses `@vue/test-utils` `mount`
- Run command: `cd frontend && npm run test`
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Create AppIcon.test.js with failing tests for the contract</name>
<files>frontend/src/components/ui/__tests__/AppIcon.test.js</files>
<read_first>
- frontend/src/components/folders/__tests__/FolderBreadcrumb.test.js (test style — vitest + @vue/test-utils mount)
- .planning/phases/10-ux-interaction/10-PATTERNS.md (AppIcon target structure)
- .planning/phases/10-ux-interaction/10-RESEARCH.md §"Component Inventory §1: SVG Audit" (full icon name list)
</read_first>
<behavior>
- Test 1: `renders <svg> with the correct d attribute for a known single-path icon (folder)` — mount with name="folder", assert svg.path has d starting with "M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v9"
- Test 2: `forwards consumer class to the <svg> element` — mount with name="folder" and attrs class "w-4 h-4 text-amber-500", assert wrapper.find('svg').classes() contains those classes
- Test 3: `renders TWO <path> elements for a dual-path icon (cog)` — mount with name="cog", assert wrapper.findAll('path').length === 2
- Test 4: `renders the standard SVG attrs (fill=none, stroke=currentColor, viewBox=0 0 24 24)` — mount any name, assert wrapper.find('svg').attributes('fill') === 'none' and stroke === 'currentColor' and viewBox === '0 0 24 24'
- Test 5: `renders no svg and calls console.warn when name is unknown` — spy on console.warn, mount name="bogus-name", assert wrapper.find('svg').exists() === false and console.warn called once with a message containing "bogus-name"
- Test 6: `path elements use stroke-linecap=round, stroke-linejoin=round, stroke-width=2` — mount name="folder", read path attributes
</behavior>
<action>
Create `frontend/src/components/ui/__tests__/AppIcon.test.js`. Import AppIcon from `../AppIcon.vue` (file does NOT exist yet — tests will fail on import, that is correct). Use `import { describe, it, expect, vi } from 'vitest'` and `import { mount } from '@vue/test-utils'`. Follow the test-file style in `frontend/src/components/folders/__tests__/FolderBreadcrumb.test.js`. Implement all 6 tests above as concrete assertions. For console.warn, set `import.meta.env.DEV = true` if needed via a `vi.stubGlobal` or rely on Vitest's default DEV=true environment. Do NOT create AppIcon.vue yet — this is the RED phase.
</action>
<verify>
<automated>cd frontend && npm run test -- --run AppIcon</automated>
Expected: test file is collected; all 6 tests FAIL with "Cannot resolve module ../AppIcon.vue" or "Failed to resolve component". This is the RED state.
</verify>
<acceptance_criteria>
- File `frontend/src/components/ui/__tests__/AppIcon.test.js` exists
- File contains exactly 6 `it(...)` blocks inside one `describe('AppIcon', ...)` block
- Running `cd frontend && npm run test -- --run AppIcon` shows 6 failing tests (RED — AppIcon.vue does not exist yet)
- Tests use `mount` from `@vue/test-utils` (not `shallowMount`)
- Tests use `vi.spyOn(console, 'warn')` or `vi.fn()` to assert the dev warning
</acceptance_criteria>
<done>Test file exists with 6 failing tests describing the AppIcon contract.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Implement AppIcon.vue with the full ICON_PATHS map</name>
<files>frontend/src/components/ui/AppIcon.vue</files>
<read_first>
- frontend/src/components/ui/__tests__/AppIcon.test.js (the failing tests from Task 1)
- frontend/src/components/ui/AppSpinner.vue (single-purpose SVG analog)
- .planning/phases/10-ux-interaction/10-PATTERNS.md §"AppIcon.vue" (full target structure)
- .planning/phases/10-ux-interaction/10-RESEARCH.md §"Component Inventory §1: SVG Audit" (complete d-attr values)
</read_first>
<behavior>
- Component is Options API per CLAUDE.md (project convention)
- Component name: `AppIcon`
- `inheritAttrs: false`
- Props: `name: { type: String, required: true }`
- Computed `resolvedPaths()` returns `ICON_PATHS[this.name] ?? null`; if null AND `import.meta.env.DEV`, calls `console.warn('[AppIcon] Unknown icon name: "' + this.name + '"')`
- Template: `<svg v-if="resolvedPaths" :class="$attrs.class" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">` containing either a `<template v-if="Array.isArray(resolvedPaths)">` rendering `<path v-for>` (for cog), else a single `<path>`
- All `<path>` elements MUST include `stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="..."`
</behavior>
<action>
Create `frontend/src/components/ui/AppIcon.vue` using the Options API template from `10-PATTERNS.md §"AppIcon.vue"`. Declare a module-scope `const ICON_PATHS = { ... }` with EXACTLY these 31 keys (28 single + 1 dual + 2 added):
Single-path keys (use d-values exactly from 10-RESEARCH.md §Component Inventory §1):
`plus`, `folder`, `folderMove`, `pencil`, `trash`, `share`, `document`, `fileDoc`, `chevronRight`, `chevronDown`, `tag`, `inbox`, `cloud`, `shield`, `logout`, `home`, `users`, `chartBar`, `clipboardList`, `upload`, `x`, `checkCircle`, `exclamationCircle`, `warning`, `copy`, `check`, `checkMark`, `refresh`, `pencilEdit`, `lightBulb`, `search`, `dots`.
Dual-path key (Array value, two strings — see RESEARCH.md):
`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', 'M15 12a3 3 0 11-6 0 3 3 0 016 0z']`
For the `search` key, use d = `'M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0'` (per D-05 Heroicons outline search). For `dots`, use the stroke replacement d = `'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'` (Pattern: per D-09 outline-only convention — replaces FolderRow fill-based dots).
Use the exact template structure from 10-PATTERNS.md AppIcon.vue section (lines 47-73 of the analog block). NO comments inside the file describing what the code does (CLAUDE.md non-negotiable). Do NOT add a `default` prop value for name; required is the contract.
</action>
<verify>
<automated>cd frontend && npm run test -- --run AppIcon</automated>
Expected: All 6 tests from Task 1 PASS (GREEN).
</verify>
<acceptance_criteria>
- File `frontend/src/components/ui/AppIcon.vue` exists
- File contains module-scope `const ICON_PATHS` with at least 30 keys (verify: `grep -c "':" frontend/src/components/ui/AppIcon.vue` returns ≥ 30 or count keys via parse)
- File contains `inheritAttrs: false`
- File contains `import.meta.env.DEV` reference
- File contains `Array.isArray(resolvedPaths)` check
- Running `cd frontend && npm run test -- --run AppIcon` exits 0 with 6 passing tests (GREEN)
- File uses Options API (`export default { name: 'AppIcon', ... }`) per CLAUDE.md, NOT `<script setup>`
- `cog` key value is an Array of length 2
</acceptance_criteria>
<done>AppIcon.vue exists with full icon map; all 6 tests pass.</done>
</task>
</tasks>
<verification>
- `cd frontend && npm run test -- --run AppIcon` exits 0
- `grep -E "ICON_PATHS\\s*=" frontend/src/components/ui/AppIcon.vue` returns 1 match
- `grep -E "inheritAttrs:\\s*false" frontend/src/components/ui/AppIcon.vue` returns 1 match
- `grep -E "Array\\.isArray\\(resolvedPaths\\)" frontend/src/components/ui/AppIcon.vue` returns 1 match
- `grep -c "'.*':.*'M" frontend/src/components/ui/AppIcon.vue` returns ≥ 28 (single-path icon count, excluding the array `cog`)
</verification>
<success_criteria>
AppIcon.vue is the single source of truth for icon paths. Wave 5's SVG migration (10-12) can replace every inline `<svg>` block with `<AppIcon name="..." class="..." />` and the visual result matches the existing rendering pixel-for-pixel (same fill/stroke/viewBox/path conventions).
</success_criteria>
<output>
Create `.planning/phases/10-ux-interaction/10-01-SUMMARY.md` when done with: what was built, the final ICON_PATHS key count, and any deviations from the planned key list.
</output>
@@ -0,0 +1,171 @@
---
phase: 10-ux-interaction
plan: 02
type: execute
wave: 0
depends_on: []
files_modified:
- frontend/src/components/ui/EmptyState.vue
- frontend/src/components/ui/__tests__/EmptyState.test.js
autonomous: true
requirements: [UX-01]
must_haves:
truths:
- "EmptyState renders the headline, subtext, and optional icon based on props"
- "EmptyState renders nothing in the CTA area when the #cta slot is empty"
- "EmptyState supports size='sm' for sidebar micro states and size='md' (default) for full centered layout"
artifacts:
- path: "frontend/src/components/ui/EmptyState.vue"
provides: "Shared empty-state component used in 7+ contexts"
- path: "frontend/src/components/ui/__tests__/EmptyState.test.js"
provides: "EmptyState unit tests"
key_links:
- from: "EmptyState.vue template"
to: "AppIcon.vue"
via: "import + <AppIcon :name=\"icon\" />"
pattern: "import AppIcon"
---
<objective>
Create `frontend/src/components/ui/EmptyState.vue` — a shared, props-driven empty-state component that replaces every inline "No items yet" / "Nothing here" pattern across StorageBrowser, SharedView, CloudStorageView, AppSidebar, AdminAuditView.
Purpose: Per D-06/D-07, every zero-content context gets its own icon + headline + subtext + optional CTA slot. No per-view "no items" text remains in the app after Wave 1 wiring.
Output: `EmptyState.vue` (Options API, props: icon/headline/subtext/size, #cta slot) + Vitest unit tests covering prop rendering, slot rendering, and size variants.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@CLAUDE.md
@.planning/phases/10-ux-interaction/10-CONTEXT.md
@.planning/phases/10-ux-interaction/10-RESEARCH.md
@.planning/phases/10-ux-interaction/10-PATTERNS.md
@frontend/src/components/folders/__tests__/FolderBreadcrumb.test.js
@frontend/src/views/SharedView.vue
@frontend/src/components/layout/AppSidebar.vue
<interfaces>
Per D-06, D-07 (CONTEXT.md):
- Props:
- `icon` (String, default null) — name from AppIcon registry (e.g. 'folder', 'inbox', 'cloud', 'search')
- `headline` (String, required) — main message text
- `subtext` (String, default '') — secondary description
- `size` (String, default 'md') — accepts 'sm' for sidebar micro states or 'md' for default centered layout
- Named `#cta` slot — renders nothing when slot is empty (use `$slots.cta` check or `<slot name="cta">` with no fallback content)
- Component is Options API (CLAUDE.md non-negotiable for new components)
- Depends on AppIcon.vue (from 10-01) — import as child component
Size class mapping (from 10-PATTERNS.md):
| size | container | icon | headline | subtext |
|------|-----------|------|----------|---------|
| md (default) | `text-center py-10 px-4` | `w-8 h-8 mx-auto mb-3 text-gray-300` | `text-sm font-medium text-gray-500` | `text-xs text-gray-400 mt-1` |
| sm | `flex items-center gap-2 py-1 text-xs text-gray-400` | `w-3.5 h-3.5 shrink-0` | `''` (empty — inherits from container) | `'hidden'` (sidebar micro states show no subtext) |
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Create EmptyState.test.js with failing tests</name>
<files>frontend/src/components/ui/__tests__/EmptyState.test.js</files>
<read_first>
- frontend/src/components/folders/__tests__/FolderBreadcrumb.test.js (test style)
- .planning/phases/10-ux-interaction/10-PATTERNS.md §"EmptyState.vue" (target structure + class table)
</read_first>
<behavior>
- Test 1: `renders headline text from the headline prop` — mount with headline='Nothing here', assert wrapper.text() includes 'Nothing here'
- Test 2: `renders subtext when provided; omits when empty` — mount with subtext='hello', assert text includes 'hello'; mount with no subtext, assert no second <p>
- Test 3: `renders AppIcon when icon prop is set; renders no icon when null` — mount with icon='folder' and stubs AppIcon, assert AppIcon component is found; mount with icon=null, assert no AppIcon
- Test 4: `renders nothing in the CTA area when #cta slot is empty` — mount with no slots, assert wrapper does NOT contain any <button> or <a> elements
- Test 5: `renders the #cta slot content when provided` — mount with slots: { cta: '<button data-test="cta-btn">Upload</button>' }, assert wrapper.find('[data-test="cta-btn"]').exists() === true
- Test 6: `size='sm' applies the sidebar micro layout (flex container with gap-2)` — mount with size='sm', assert root element classList contains 'flex' and 'gap-2'
- Test 7: `default size (md) applies the centered layout (text-center py-10)` — mount default, assert classList contains 'text-center' and 'py-10'
</behavior>
<action>
Create `frontend/src/components/ui/__tests__/EmptyState.test.js`. Use Vitest + @vue/test-utils. Stub the AppIcon component via `global.stubs: { AppIcon: true }` in mount options so tests don't depend on Wave 0 task ordering. Tests will fail until Task 2 creates EmptyState.vue. Write all 7 tests above as concrete `it(...)` blocks.
</action>
<verify>
<automated>cd frontend && npm run test -- --run EmptyState</automated>
Expected: tests collected; all 7 FAIL with module-not-found (RED phase).
</verify>
<acceptance_criteria>
- File `frontend/src/components/ui/__tests__/EmptyState.test.js` exists
- File contains exactly 7 `it(...)` blocks
- Running `cd frontend && npm run test -- --run EmptyState` shows 7 failing tests
- Tests stub AppIcon via mount options (no hard dependency on AppIcon.vue existing)
</acceptance_criteria>
<done>Test file exists with 7 failing tests describing the EmptyState contract.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Implement EmptyState.vue</name>
<files>frontend/src/components/ui/EmptyState.vue</files>
<read_first>
- frontend/src/components/ui/__tests__/EmptyState.test.js (the failing tests)
- .planning/phases/10-ux-interaction/10-PATTERNS.md §"EmptyState.vue" (full target structure incl. class table)
- frontend/src/views/SharedView.vue (existing inline empty-state pattern being replaced)
- frontend/src/components/layout/AppSidebar.vue (existing sidebar micro pattern: pl-7 py-1 text-xs text-gray-400)
</read_first>
<behavior>
- Options API component named `EmptyState`
- Registers `AppIcon` as a child component (imported from `./AppIcon.vue`)
- Props: `icon` (String, default null), `headline` (String, required), `subtext` (String, default ''), `size` (String, default 'md')
- Computed `containerClass`, `iconClass`, `headlineClass`, `subtextClass` returning the strings from the class table in 10-PATTERNS.md
- Template renders, in order: container `<div>`, AppIcon (when icon truthy), headline `<p>`, subtext `<p>` (when subtext truthy AND size !== 'sm' — sidebar micro hides subtext via the `hidden` class), `<slot name="cta" />`
</behavior>
<action>
Create `frontend/src/components/ui/EmptyState.vue` using the Options API target structure from `10-PATTERNS.md §"EmptyState.vue"`. Import `AppIcon` from `./AppIcon.vue`. Use the exact class mapping table from PATTERNS:
- size='md' container: `'text-center py-10 px-4'`; icon: `'w-8 h-8 mx-auto mb-3 text-gray-300'`; headline: `'text-sm font-medium text-gray-500'`; subtext: `'text-xs text-gray-400 mt-1'`
- size='sm' container: `'flex items-center gap-2 py-1 text-xs text-gray-400'`; icon: `'w-3.5 h-3.5 shrink-0'`; headline: `''`; subtext: `'hidden'`
Template structure:
```
<template>
<div :class="containerClass">
<AppIcon v-if="icon" :name="icon" :class="iconClass" />
<p :class="headlineClass">{{ headline }}</p>
<p v-if="subtext" :class="subtextClass">{{ subtext }}</p>
<slot name="cta" />
</div>
</template>
```
Do NOT add explanatory comments. Use Options API (CLAUDE.md).
</action>
<verify>
<automated>cd frontend && npm run test -- --run EmptyState</automated>
Expected: all 7 tests PASS (GREEN).
</verify>
<acceptance_criteria>
- File `frontend/src/components/ui/EmptyState.vue` exists
- File imports AppIcon from `./AppIcon.vue`
- File contains `name: 'EmptyState'`
- File contains all 4 computed properties: `containerClass`, `iconClass`, `headlineClass`, `subtextClass`
- File contains `<slot name="cta" />`
- All 7 tests pass: `cd frontend && npm run test -- --run EmptyState` exits 0
- File uses Options API, not `<script setup>`
</acceptance_criteria>
<done>EmptyState.vue implemented; all 7 tests green.</done>
</task>
</tasks>
<verification>
- `cd frontend && npm run test -- --run EmptyState` exits 0 with 7 passing tests
- `grep -E "name:\\s*'EmptyState'" frontend/src/components/ui/EmptyState.vue` returns 1 match
- `grep -E "import AppIcon" frontend/src/components/ui/EmptyState.vue` returns 1 match
- `grep -E "slot name=\"cta\"" frontend/src/components/ui/EmptyState.vue` returns 1 match
- `grep -v '^#' frontend/src/components/ui/EmptyState.vue | grep -c '<script setup>'` returns 0 (Options API enforced)
</verification>
<success_criteria>
EmptyState.vue is ready to be wired into all 9 empty-state contexts identified in 10-RESEARCH.md §Component Inventory §3. Wave 1 plans 10-06, 10-07, 10-08 will replace inline empty-state divs with `<EmptyState icon="..." headline="..." subtext="..." />` blocks (plus #cta slot where applicable).
</success_criteria>
<output>
Create `.planning/phases/10-ux-interaction/10-02-SUMMARY.md` when done.
</output>
@@ -0,0 +1,169 @@
---
phase: 10-ux-interaction
plan: 03
type: execute
wave: 0
depends_on: []
files_modified:
- frontend/src/components/ui/BreadcrumbBar.vue
- frontend/src/components/ui/__tests__/BreadcrumbBar.test.js
autonomous: true
requirements: [UX-12]
must_haves:
truths:
- "BreadcrumbBar renders the rootLabel as the first clickable segment when showRoot is true"
- "BreadcrumbBar does NOT render the root segment when showRoot is false (admin/settings views)"
- "The last segment is always rendered as plain non-clickable text"
- "Segments without an id render as plain text (admin static segments)"
- "Clicking an intermediate segment emits navigate(segment.id); clicking root emits navigate(null)"
- ">4 segments collapse to first + ellipsis + last two"
artifacts:
- path: "frontend/src/components/ui/BreadcrumbBar.vue"
provides: "Shared breadcrumb component used by file manager, cloud, admin, settings views"
- path: "frontend/src/components/ui/__tests__/BreadcrumbBar.test.js"
provides: "BreadcrumbBar unit tests"
key_links:
- from: "BreadcrumbBar.vue"
to: "AppIcon.vue"
via: "import for chevronRight separator"
pattern: "<AppIcon name=\"chevronRight\""
---
<objective>
Create `frontend/src/components/ui/BreadcrumbBar.vue` — a shared breadcrumb component that generalizes `FolderBreadcrumb.vue` to also serve admin, settings, and topics views. The existing `FolderBreadcrumb.vue` will be deleted in Wave 1 (plan 10-06) after BreadcrumbBar is wired everywhere.
Purpose: Per D-11/D-12/D-13, every view computes its own `segments` array. Admin views (e.g. `Admin Users`) and settings (`Settings Account`) need static segments without a "Home" root. File manager and cloud views use folder store breadcrumb data with a "Home" or "Cloud" root.
Output: `BreadcrumbBar.vue` (Options API, props segments/rootLabel/showRoot, emits navigate) + Vitest unit tests adapted from the existing FolderBreadcrumb tests.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@CLAUDE.md
@.planning/phases/10-ux-interaction/10-CONTEXT.md
@.planning/phases/10-ux-interaction/10-RESEARCH.md
@.planning/phases/10-ux-interaction/10-PATTERNS.md
@frontend/src/components/folders/FolderBreadcrumb.vue
@frontend/src/components/folders/__tests__/FolderBreadcrumb.test.js
<interfaces>
Per D-11, D-12, D-13 (CONTEXT.md):
- Props:
- `segments` (Array, default []) — array of `{ id?, label }` objects
- Items without `id` render as plain non-clickable text (admin static segments)
- Last item is ALWAYS plain non-clickable text regardless of `id`
- `rootLabel` (String, default 'Home') — text for the root button when showRoot=true
- `showRoot` (Boolean, default true) — when false, omit the root button entirely
- Emits: `navigate` with `segment.id` (intermediate click) or `null` (root click)
- Last segment: plain `<span>`, no click handler
- >4 segments: collapse pattern `[first, {id:'ellipsis', label:'…'}, ...last_two]`
Reference implementation (FolderBreadcrumb.vue):
- Uses `<script setup>` (we will use Options API per CLAUDE.md for THIS new component — exception in PATTERNS.md says BreadcrumbBar may use script setup since it replaces a setup component; pick Options API anyway for consistency with EmptyState/AppIcon)
- Renders `<nav aria-label>` > `<ol>` > `<li>` segments
Segment shape change:
- FolderBreadcrumb uses `{ id, name }` — BreadcrumbBar uses `{ id?, label }`
- Wave 1 plans will map `{id, name}``{id, label: name}` at the call sites
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Create BreadcrumbBar.test.js with failing tests</name>
<files>frontend/src/components/ui/__tests__/BreadcrumbBar.test.js</files>
<read_first>
- frontend/src/components/folders/__tests__/FolderBreadcrumb.test.js (reference test patterns)
- frontend/src/components/folders/FolderBreadcrumb.vue (current behavior reference)
- .planning/phases/10-ux-interaction/10-PATTERNS.md §"BreadcrumbBar.vue"
</read_first>
<behavior>
- Test 1: `renders rootLabel button when showRoot=true` — mount with rootLabel='Home', segments=[], assert wrapper.find('button').text() === 'Home'
- Test 2: `does NOT render root button when showRoot=false` — mount with showRoot=false, segments=[{label: 'Users'}], assert wrapper.findAll('button').length === 0
- Test 3: `clicking root button emits navigate(null)` — mount default with empty segments, click first button, assert emitted.navigate[0] === [null]
- Test 4: `last segment renders as a non-clickable <span>` — mount with segments=[{id:'a', label:'A'}, {id:'b', label:'B'}], assert text contains 'B' AND wrapper.findAll('button').filter(b => b.text() === 'B').length === 0
- Test 5: `clicking intermediate segment emits navigate(segment.id)` — mount with segments=[{id:'r1', label:'Root'}, {id:'f1', label:'Test'}], click the 'Root' button, assert emitted.navigate[0] === ['r1']
- Test 6: `segments without id render as plain text even when intermediate` — mount with segments=[{label:'Admin'}, {label:'Users'}], showRoot=false, assert NO buttons exist (both render as spans)
- Test 7: `>4 segments collapse to first + ellipsis + last two` — mount with 5 segments, assert text contains the first segment label, '…', and the last 2 labels but NOT segments 2-3
- Test 8: `rootLabel defaults to "Home"` — mount with no rootLabel prop, assert first button text === 'Home'
- Test 9: `custom rootLabel "Cloud" renders correctly` — mount with rootLabel='Cloud', assert first button text === 'Cloud'
- Test 10: `renders chevronRight separator between segments` — mount with segments=[{id:'a', label:'A'}, {id:'b', label:'B'}] and stubs: { AppIcon: true }, assert AppIcon stubs are present (count >= 1)
</behavior>
<action>
Create `frontend/src/components/ui/__tests__/BreadcrumbBar.test.js`. Adapt the test style from `frontend/src/components/folders/__tests__/FolderBreadcrumb.test.js`. Stub AppIcon via `global: { stubs: { AppIcon: true } }`. Use the segment shape `{ id, label }` (NOT `{ id, name }`). Write all 10 tests above. Tests fail until Task 2.
</action>
<verify>
<automated>cd frontend && npm run test -- --run BreadcrumbBar</automated>
Expected: 10 tests collected; all fail (RED).
</verify>
<acceptance_criteria>
- File `frontend/src/components/ui/__tests__/BreadcrumbBar.test.js` exists with 10 `it(...)` blocks
- Tests use segment shape `{ id, label }` exclusively
- Running `cd frontend && npm run test -- --run BreadcrumbBar` shows 10 failing tests
</acceptance_criteria>
<done>10 failing tests in place describing the BreadcrumbBar contract.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Implement BreadcrumbBar.vue</name>
<files>frontend/src/components/ui/BreadcrumbBar.vue</files>
<read_first>
- frontend/src/components/ui/__tests__/BreadcrumbBar.test.js (failing tests)
- frontend/src/components/folders/FolderBreadcrumb.vue (reference template — lines 1-44)
- .planning/phases/10-ux-interaction/10-PATTERNS.md §"BreadcrumbBar.vue"
</read_first>
<behavior>
- Options API component `BreadcrumbBar`, registers `AppIcon` from `./AppIcon.vue`
- Props: `segments` (Array, default []), `rootLabel` (String, default 'Home'), `showRoot` (Boolean, default true)
- Emits: `['navigate']`
- Computed `visibleSegments`: if `segments.length > 4` returns `[segments[0], { id: 'ellipsis', label: '…' }, ...segments.slice(-2)]`; else returns `segments`
- Template renders `<nav aria-label="Navigation"><ol class="flex items-center gap-1 text-sm flex-wrap">`
- When `showRoot`: render the root `<li><button @click="$emit('navigate', null)">{{ rootLabel }}</button></li>`
- For each segment in visibleSegments with index:
- Separator `<li aria-hidden><AppIcon name="chevronRight" class="w-3 h-3 text-gray-400" /></li>` shown only when there is something to the left (showRoot OR idx > 0)
- If `segment.id === 'ellipsis'`: `<li><span>…</span></li>`
- If `idx === visibleSegments.length - 1`: `<li><span class="text-gray-900 font-medium">{{ segment.label }}</span></li>`
- Else if `segment.id`: `<li><button @click="$emit('navigate', segment.id)" class="text-indigo-600 hover:underline font-medium">{{ segment.label }}</button></li>`
- Else: `<li><span class="text-gray-500">{{ segment.label }}</span></li>` (no id, no click)
</behavior>
<action>
Create `frontend/src/components/ui/BreadcrumbBar.vue` using Options API. Import AppIcon from `./AppIcon.vue`. Implement exactly the template described in the behavior block. Match the FolderBreadcrumb visual style (text-indigo-600, hover:underline for clickable; text-gray-900 font-medium for the last segment; text-gray-400 for separator). Use `<AppIcon name="chevronRight" class="w-3 h-3 text-gray-400" />` for separators (NO inline svg — this is the new icon centralization paradigm). Skip the separator before the first segment when `!showRoot`. No comments inside the file.
</action>
<verify>
<automated>cd frontend && npm run test -- --run BreadcrumbBar</automated>
Expected: all 10 tests PASS.
</verify>
<acceptance_criteria>
- File `frontend/src/components/ui/BreadcrumbBar.vue` exists
- File imports AppIcon from `./AppIcon.vue`
- File contains `name: 'BreadcrumbBar'`
- Template contains `<AppIcon name="chevronRight"` (separator uses AppIcon, not inline svg)
- Template contains `aria-label="Navigation"` (or similar) on the `<nav>`
- All 10 tests pass
- File uses Options API
</acceptance_criteria>
<done>BreadcrumbBar.vue implemented; 10 tests green.</done>
</task>
</tasks>
<verification>
- `cd frontend && npm run test -- --run BreadcrumbBar` exits 0 with 10 passing tests
- `grep -E "name:\\s*'BreadcrumbBar'" frontend/src/components/ui/BreadcrumbBar.vue` returns 1 match
- `grep -E "rootLabel" frontend/src/components/ui/BreadcrumbBar.vue` returns ≥ 2 matches (prop + template)
- `grep -E "showRoot" frontend/src/components/ui/BreadcrumbBar.vue` returns ≥ 2 matches
- `grep -E "<AppIcon name=\"chevronRight\"" frontend/src/components/ui/BreadcrumbBar.vue` returns ≥ 1 match
</verification>
<success_criteria>
BreadcrumbBar.vue is ready to replace FolderBreadcrumb.vue everywhere. Wave 1 plan 10-06 will swap the import in StorageBrowser.vue, update FileManagerView/CloudFolderView to map breadcrumb to `{id, label}`, wire admin/settings views to pass static segments, and delete `FolderBreadcrumb.vue`.
</success_criteria>
<output>
Create `.planning/phases/10-ux-interaction/10-03-SUMMARY.md` when done.
</output>
@@ -0,0 +1,271 @@
---
phase: 10-ux-interaction
plan: 04
type: execute
wave: 0
depends_on: []
files_modified:
- frontend/src/stores/toast.js
- frontend/src/stores/__tests__/toast.test.js
- frontend/src/components/ui/ToastContainer.vue
- frontend/src/components/ui/__tests__/ToastContainer.test.js
- frontend/src/App.vue
autonomous: true
requirements: [UX-10]
must_haves:
truths:
- "Calling useToastStore().show('msg', 'success', 4000) appends a toast and auto-dismisses after the duration"
- "Calling dismiss(id) removes that toast from the array immediately"
- "Existing Phase 8 call sites (SettingsAccountTab, TotpEnrollment) work unchanged with the locked signature show(message, type, duration)"
- "ToastContainer renders one DOM node per toast, teleported to body, with bottom-right positioning"
- "ToastContainer is mounted at App.vue and visible across all routes (including admin layout)"
artifacts:
- path: "frontend/src/stores/toast.js"
provides: "Reactive toast store with toasts array + show + dismiss"
contains: "toasts"
- path: "frontend/src/components/ui/ToastContainer.vue"
provides: "Visual renderer for the toast stack"
- path: "frontend/src/stores/__tests__/toast.test.js"
provides: "Toast store tests (timers + signature)"
- path: "frontend/src/components/ui/__tests__/ToastContainer.test.js"
provides: "ToastContainer rendering tests"
key_links:
- from: "App.vue"
to: "ToastContainer.vue"
via: "<ToastContainer /> mounted alongside <router-view>"
pattern: "<ToastContainer"
- from: "ToastContainer.vue"
to: "useToastStore"
via: "store.toasts subscription"
pattern: "toastStore\\.toasts"
---
<objective>
Implement the toast notification system (per D-01..D-04): reactive store, visual container, and mount in App.vue. This replaces the Phase 8 no-op stub WITHOUT changing the locked `show(message, type, duration)` signature so that Phase 8 call sites (`SettingsAccountTab.vue`, `TotpEnrollment.vue`) continue to work.
Purpose: Wire UX-10 (toast notification system). Phase 8 only stubbed the store; Phase 10 makes toasts actually visible. Toasts appear bottom-right, stack upward (D-01), use colored left-border accent + inline icon per type (D-02), are teleported to body (D-03).
Output:
- `stores/toast.js` — reactive `toasts` array, `show` action (auto-dismiss via setTimeout), `dismiss` action
- `components/ui/ToastContainer.vue` — Teleport-to-body container with TransitionGroup
- `App.vue` — mount `<ToastContainer />`
- Vitest tests for both store and container
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@CLAUDE.md
@.planning/phases/10-ux-interaction/10-CONTEXT.md
@.planning/phases/10-ux-interaction/10-RESEARCH.md
@.planning/phases/10-ux-interaction/10-PATTERNS.md
@frontend/src/stores/toast.js
@frontend/src/App.vue
@frontend/src/components/ui/SearchableModelSelect.vue
@frontend/src/components/settings/SettingsAccountTab.vue
@frontend/src/components/auth/TotpEnrollment.vue
<interfaces>
**Toast store (setup-store form — D-04 LOCKED signature):**
```js
import { ref } from 'vue'
import { defineStore } from 'pinia'
export const useToastStore = defineStore('toast', () => {
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 })
if (duration > 0) setTimeout(() => dismiss(id), duration)
}
function dismiss(id) {
toasts.value = toasts.value.filter(t => t.id !== id)
}
return { toasts, show, dismiss }
})
```
**Type → visual classes (D-02):**
| type | accent (left-bar) | icon name | icon color |
|------|-------------------|-----------|------------|
| success | bg-green-500 | checkCircle | text-green-500 |
| error | bg-red-500 | exclamationCircle | text-red-500 |
| warning | bg-amber-400 | warning | text-amber-500 |
| info | bg-sky-400 | exclamationCircle | text-sky-500 |
**Container layout (D-01, D-03):**
- Teleport target: `to="body"`
- Wrapper: `fixed bottom-4 right-4 z-[9999] flex flex-col-reverse gap-2 pointer-events-none`
- Each toast: `pointer-events-auto flex items-center gap-3 bg-white rounded-xl shadow-lg border border-gray-100 overflow-hidden max-w-sm min-w-[280px]` with `@click="dismiss(toast.id)"`
**App.vue mount point (current state):**
- App.vue uses `<script setup>` (Composition API exception per CLAUDE.md)
- `<router-view />` lives inside `<main>` for non-auth routes
- Add `<ToastContainer />` AFTER the layout `<div>` so it floats over all routes including AuthLayout
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Write failing tests for toast store + ToastContainer</name>
<files>frontend/src/stores/__tests__/toast.test.js, frontend/src/components/ui/__tests__/ToastContainer.test.js</files>
<read_first>
- frontend/src/stores/toast.js (current Phase 8 stub)
- frontend/src/stores/__tests__/cloudConnections.test.js (Pinia test setup pattern)
- .planning/phases/10-ux-interaction/10-PATTERNS.md §"toast.js" and §"ToastContainer.vue"
</read_first>
<behavior>
**toast.test.js (6 tests):**
- Test 1: `show(msg, type, duration) appends a toast with the given fields` — setActivePinia + create store, call show('Hi', 'success', 4000), assert store.toasts.length === 1 and the toast has message='Hi', type='success', duration=4000
- Test 2: `show defaults type to 'success' and duration to 4000` — call show('Hi'), assert toasts[0].type === 'success' and toasts[0].duration === 4000
- Test 3: `auto-dismisses after duration using fake timers` — use vi.useFakeTimers(), call show('Hi', 'success', 4000), assert length===1, advance time by 4000ms via vi.advanceTimersByTime(4000), assert length===0
- Test 4: `dismiss(id) removes the toast immediately` — show, capture id from toasts[0].id, call dismiss(id), assert length===0
- Test 5: `duration=0 disables auto-dismiss` — vi.useFakeTimers(), show('Hi', 'success', 0), advance 10000ms, assert length still === 1
- Test 6: `multiple toasts stack with unique ids` — call show three times, assert toasts.length === 3 and all ids are distinct
**ToastContainer.test.js (4 tests):**
- Test 1: `renders nothing when store.toasts is empty` — mount with empty store, assert wrapper.find('[data-test="toast"]').exists() === false (or assert no .bg-white toast cards)
- Test 2: `renders one element per toast` — populate store with 2 toasts, mount, assert wrapper.findAll('[data-test="toast"]').length === 2 (or count by class)
- Test 3: `clicking a toast calls dismiss(id)` — populate store with 1 toast, mount, click the toast, assert store.toasts.length === 0
- Test 4: `accent class matches type (success → bg-green-500)` — populate store with 1 success toast, mount, find the accent bar element, assert its classList includes 'bg-green-500'
</behavior>
<action>
Create both test files.
For `frontend/src/stores/__tests__/toast.test.js`: use `import { setActivePinia, createPinia } from 'pinia'` and `beforeEach(() => setActivePinia(createPinia()))`. Use `vi.useFakeTimers()` / `vi.useRealTimers()` for timer tests.
For `frontend/src/components/ui/__tests__/ToastContainer.test.js`: setActivePinia + createPinia in beforeEach. Mount ToastContainer with `global.stubs: { AppIcon: true, Teleport: true }` (Teleport stub disables the body-teleport so wrapper.find works on the rendered DOM). Add `data-test="toast"` attribute to the toast element template (Task 2). Use a manual fixture data-test selector OR identify the toast via a stable class like `pointer-events-auto`.
Tests fail until Task 2 implements the store + container.
</action>
<verify>
<automated>cd frontend && npm run test -- --run toast</automated>
Expected: tests collected; ~10 failures total across the two files.
</verify>
<acceptance_criteria>
- `frontend/src/stores/__tests__/toast.test.js` exists with 6 `it(...)` blocks
- `frontend/src/components/ui/__tests__/ToastContainer.test.js` exists with 4 `it(...)` blocks
- Toast store tests use vi.useFakeTimers() correctly (no flaky time-based assertions)
- ToastContainer tests stub Teleport and AppIcon
- Running `cd frontend && npm run test -- --run toast` shows ~10 failing tests
</acceptance_criteria>
<done>10 failing tests covering store and container contracts.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Implement toast.js store + ToastContainer.vue + mount in App.vue</name>
<files>frontend/src/stores/toast.js, frontend/src/components/ui/ToastContainer.vue, frontend/src/App.vue</files>
<read_first>
- frontend/src/stores/toast.js (current stub — DO NOT change exported signature)
- frontend/src/stores/__tests__/toast.test.js (tests from Task 1)
- frontend/src/components/ui/__tests__/ToastContainer.test.js (tests from Task 1)
- frontend/src/App.vue (current state — uses <script setup>)
- frontend/src/components/ui/SearchableModelSelect.vue (Teleport pattern reference)
- .planning/phases/10-ux-interaction/10-PATTERNS.md §"toast.js" and §"ToastContainer.vue"
- frontend/src/components/settings/SettingsAccountTab.vue (Phase 8 call site — must keep working)
- frontend/src/components/auth/TotpEnrollment.vue (Phase 8 call site — must keep working)
</read_first>
<behavior>
1. `stores/toast.js`: setup-store form (D-04 LOCKED — keep `defineStore('toast', () => {...})`). Returns `{ toasts, show, dismiss }`. Signature MUST remain `show(message, type = 'success', duration = 4000)`.
2. `components/ui/ToastContainer.vue`: Options API, registers `AppIcon`. Computed maps for `accentClass(type)`, `iconName(type)`, `iconColorClass(type)` using the Type table in <interfaces>. Template wraps everything in `<Teleport to="body">` with `<TransitionGroup name="toast">`. Each toast div has `data-test="toast"`, `@click="toastStore.dismiss(toast.id)"`, and includes (in order) a left accent bar div, an `<AppIcon>`, and the `{{ toast.message }}` paragraph.
3. `App.vue`: Add `import ToastContainer from './components/ui/ToastContainer.vue'` to the `<script setup>` block. Add `<ToastContainer />` to the template — place it AFTER the existing `<div v-else>` so it floats over all routes. Add the `<style>` block (scoped or global) for `.toast-enter-active`, `.toast-enter-from`, `.toast-leave-active`, `.toast-leave-to` providing a simple fade+translate transition.
</behavior>
<action>
**Step A — toast.js:**
Replace the current stub with the implementation from `10-PATTERNS.md §"toast.js"`. Keep the file structure identical to the stub (default export `defineStore('toast', () => {...})`). Implementation:
```js
import { ref } from 'vue'
import { defineStore } from 'pinia'
export const useToastStore = defineStore('toast', () => {
const toasts = ref([])
function show(message, type = 'success', duration = 4000) {
const id = Date.now() + Math.random()
toasts.value.push({ id, message, type, duration })
if (duration > 0) setTimeout(() => dismiss(id), duration)
}
function dismiss(id) {
toasts.value = toasts.value.filter(t => t.id !== id)
}
return { toasts, show, dismiss }
})
```
**Step B — ToastContainer.vue:**
Create using Options API. Methods `accentClass(type)`, `iconName(type)`, `iconColorClass(type)` returning the table values in <interfaces>. The toast item template:
```
<div
v-for="toast in toastStore.toasts"
:key="toast.id"
data-test="toast"
class="pointer-events-auto flex items-center gap-3 bg-white rounded-xl shadow-lg border border-gray-100 overflow-hidden max-w-sm min-w-[280px] cursor-pointer"
@click="toastStore.dismiss(toast.id)"
>
<div class="w-1 self-stretch shrink-0" :class="accentClass(toast.type)"></div>
<AppIcon :name="iconName(toast.type)" class="w-5 h-5 shrink-0" :class="iconColorClass(toast.type)" />
<p class="text-sm text-gray-800 flex-1 py-3 pr-4">{{ toast.message }}</p>
</div>
```
Wrap in `<Teleport to="body"><div class="fixed bottom-4 right-4 z-[9999] flex flex-col-reverse gap-2 pointer-events-none"><TransitionGroup name="toast">...</TransitionGroup></div></Teleport>`.
Setup the store via `import { useToastStore } from '../../stores/toast.js'` and expose it as `toastStore` in `data() { return { toastStore: useToastStore() } }` (Options API store wiring; project uses relative paths — no `@/` alias configured).
Add a `<style scoped>` block defining `.toast-enter-active`, `.toast-leave-active { transition: all 0.2s ease }`, `.toast-enter-from, .toast-leave-to { opacity: 0; transform: translateX(20px) }`.
**Step C — App.vue:**
Modify App.vue (currently `<script setup>`). Add to imports:
```js
import ToastContainer from './components/ui/ToastContainer.vue'
```
Add to template AFTER the closing `</div>` of the layout wrapper:
```
<ToastContainer />
```
Keep the existing structure (AuthLayout v-if / div v-else / AppSidebar / main / router-view) entirely intact. Do NOT remove or rename anything in App.vue beyond adding the import and the `<ToastContainer />` element.
NO comments in any file.
</action>
<verify>
<automated>cd frontend && npm run test -- --run toast &amp;&amp; cd frontend && npm run test -- --run ToastContainer</automated>
Expected: 10 tests pass (6 store + 4 container). The pre-existing tests `SettingsAccountTab.test.js` and `TotpEnrollment.test.js` continue to pass because the `show` signature is unchanged.
</verify>
<acceptance_criteria>
- `frontend/src/stores/toast.js` contains `ref([])` and `setTimeout(() => dismiss(id), duration)` lines
- `frontend/src/stores/toast.js` exports `useToastStore` with `{ toasts, show, dismiss }` returned
- The `show` function signature is unchanged: `show(message, type = 'success', duration = 4000)` (grep: `grep -E "function show\\(message,\\s*type\\s*=\\s*'success',\\s*duration\\s*=\\s*4000\\)" frontend/src/stores/toast.js` returns 1)
- `frontend/src/components/ui/ToastContainer.vue` exists, imports AppIcon, uses `<Teleport to="body">`, uses `<TransitionGroup name="toast">`, includes `data-test="toast"` attribute on the toast element
- `frontend/src/App.vue` contains `import ToastContainer` and `<ToastContainer />` in the template (grep: `grep -c "ToastContainer" frontend/src/App.vue` returns ≥ 2)
- `cd frontend && npm run test -- --run toast` exits 0
- `cd frontend && npm run test -- --run ToastContainer` exits 0
- `cd frontend && npm run test -- --run SettingsAccountTab` still exits 0 (Phase 8 regression check)
- `cd frontend && npm run test -- --run TotpEnrollment` still exits 0 (Phase 8 regression check)
</acceptance_criteria>
<done>Toast store reactive, ToastContainer rendering, App.vue mounted, all tests green, Phase 8 sites unchanged.</done>
</task>
</tasks>
<verification>
- `cd frontend && npm run test -- --run toast` exits 0
- `cd frontend && npm run test -- --run ToastContainer` exits 0
- `cd frontend && npm run test -- --run SettingsAccountTab` exits 0 (regression)
- `cd frontend && npm run test -- --run TotpEnrollment` exits 0 (regression)
- `grep -E "show\\(message,\\s*type\\s*=\\s*'success',\\s*duration\\s*=\\s*4000\\)" frontend/src/stores/toast.js` returns 1 (locked signature preserved)
- `grep -E "<ToastContainer" frontend/src/App.vue` returns 1
- `grep -E "Teleport to=\"body\"" frontend/src/components/ui/ToastContainer.vue` returns 1
</verification>
<success_criteria>
The toast system is fully wired and visible across the app. Wave 1 plan 10-09 can add `useToastStore().show('Document moved', 'success')` calls inside `FileManagerView.vue` action handlers and a real toast will appear bottom-right. Phase 8 call sites (`SettingsAccountTab`, `TotpEnrollment`) work unchanged.
</success_criteria>
<output>
Create `.planning/phases/10-ux-interaction/10-04-SUMMARY.md` when done.
</output>
@@ -0,0 +1,297 @@
---
phase: 10-ux-interaction
plan: 05
type: execute
wave: 0
depends_on: []
files_modified:
- frontend/src/components/storage/__tests__/StorageBrowser.skeleton.test.js
- frontend/src/components/layout/__tests__/AppSidebar.empty.test.js
- frontend/src/views/admin/__tests__/AdminAuditView.skeleton.test.js
- frontend/src/views/admin/__tests__/AdminUsersView.skeleton.test.js
- frontend/src/__tests__/keyboard.test.js
- frontend/src/components/layout/__tests__/OsDragOverlay.test.js
- frontend/src/components/storage/__tests__/StorageBrowser.dragmove.test.js
- frontend/src/components/ui/__tests__/dropdown.test.js
autonomous: true
requirements: [UX-02, UX-03, UX-04, UX-05, UX-06, UX-07, UX-08, UX-09, UX-11, UX-13, UX-14]
must_haves:
truths:
- "Wave 0 xfail test stubs exist for every requirement that is implemented in Waves 1-4"
- "Each stub is marked .skip or .todo so the test runner reports unrun tests instead of false greens"
- "Each requirement ID is referenced by at least one stub"
artifacts:
- path: "frontend/src/__tests__/keyboard.test.js"
provides: "Wave 0 xfail stubs for UX-05..08 keyboard shortcuts"
- path: "frontend/src/components/storage/__tests__/StorageBrowser.skeleton.test.js"
provides: "Wave 0 xfail stubs for UX-02 skeleton + UX-13 dropdown teleport"
key_links:
- from: "Each stub describe() block"
to: "Requirement ID in test title or comment"
via: "describe('UX-XX: ...')"
pattern: "describe.*UX-"
---
<objective>
Create Wave 0 test stubs (Nyquist pattern — RED tests before implementation) for the requirements that are wired in Waves 1-4. This ensures:
1. The test runner reports unrun tests for every requirement (no false greens)
2. Wave 1-4 implementations have a place to "promote" stubs to real assertions
3. Coverage of all 15 phase requirements is provable up-front
This plan covers the 11 requirements NOT covered by Wave 0 foundation plans (10-01..10-04 already include their tests):
- Foundation already covered by their own plans: UX-01 (EmptyState — 10-02), UX-10 (Toast — 10-04), UX-12 (BreadcrumbBar — 10-03), CODE-05 (AppIcon — 10-01)
- This plan covers: UX-02, UX-03, UX-04, UX-05, UX-06, UX-07, UX-08, UX-09, UX-11, UX-13, UX-14
Output: 8 stub test files, each with `it.skip` or `it.todo` placeholders annotating the requirement IDs and expected behavior. Wave 1-4 implementing plans will replace `.skip`/`.todo` with real tests.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@CLAUDE.md
@.planning/phases/10-ux-interaction/10-CONTEXT.md
@.planning/phases/10-ux-interaction/10-RESEARCH.md
@.planning/phases/10-ux-interaction/10-VALIDATION.md
@frontend/src/components/folders/__tests__/FolderBreadcrumb.test.js
<interfaces>
Vitest stubs use `it.todo('description')` or `it.skip('description', ...)`. Each stub file should `describe('UX-XX: requirement summary', ...)` with one or more `it.todo` placeholders for the behaviors that Wave 1-4 will implement.
Test file locations (mirror the source structure):
- `frontend/src/components/storage/__tests__/StorageBrowser.skeleton.test.js` — UX-02 (skeleton rows) + UX-13 (folder picker dropdown)
- `frontend/src/components/storage/__tests__/StorageBrowser.dragmove.test.js` — UX-11 (drag-to-move toast trigger + ring highlight)
- `frontend/src/components/layout/__tests__/AppSidebar.empty.test.js` — UX-03 (sidebar skeleton + empty micro states) + UX-14 (sidebar New button removed)
- `frontend/src/views/admin/__tests__/AdminAuditView.skeleton.test.js` — UX-04 (audit log skeleton rows)
- `frontend/src/views/admin/__tests__/AdminUsersView.skeleton.test.js` — UX-04 (users table skeleton rows)
- `frontend/src/__tests__/keyboard.test.js` — UX-05..08 (global keyboard shortcuts)
- `frontend/src/components/layout/__tests__/OsDragOverlay.test.js` — UX-09 (OS file drag overlay depth-counter)
- `frontend/src/components/ui/__tests__/dropdown.test.js` — UX-13 (Teleport+getBoundingClientRect dropdown positioning)
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Create skeleton + dropdown stubs (UX-02, UX-03, UX-04, UX-13, UX-14)</name>
<files>
frontend/src/components/storage/__tests__/StorageBrowser.skeleton.test.js,
frontend/src/components/layout/__tests__/AppSidebar.empty.test.js,
frontend/src/views/admin/__tests__/AdminAuditView.skeleton.test.js,
frontend/src/views/admin/__tests__/AdminUsersView.skeleton.test.js,
frontend/src/components/ui/__tests__/dropdown.test.js
</files>
<read_first>
- frontend/src/components/folders/__tests__/FolderBreadcrumb.test.js (style reference)
- .planning/phases/10-ux-interaction/10-VALIDATION.md (test map)
- .planning/phases/10-ux-interaction/10-RESEARCH.md §"Pitfall 7: Skeleton Row Height" and §"Component Inventory §2: Dropdown Clipping Audit"
</read_first>
<action>
Create 5 stub test files. Each file is a single `describe('UX-XX: requirement', ...)` block with `it.todo(...)` entries. Use `import { describe, it } from 'vitest'` at the top.
**File A — `frontend/src/components/storage/__tests__/StorageBrowser.skeleton.test.js`:**
```
import { describe, it } from 'vitest'
describe('UX-02: StorageBrowser shows skeleton rows during loading', () => {
it.todo('renders 5+ skeleton rows when loading=true (replaces Loading… text)')
it.todo('skeleton rows use grid-cols-[2rem_1fr_6rem_8rem_6rem] matching real row grid')
it.todo('skeleton rows use animate-pulse')
it.todo('Loading… text is absent when loading=true (skeleton replaces it)')
})
describe('UX-13: StorageBrowser folder picker uses Teleport + getBoundingClientRect', () => {
it.todo('folder picker dropdown is teleported to body (escapes overflow container)')
it.todo('dropdown position reflects getBoundingClientRect of the trigger button')
it.todo('window scroll while open recalculates position')
})
```
**File B — `frontend/src/components/layout/__tests__/AppSidebar.empty.test.js`:**
```
import { describe, it } from 'vitest'
describe('UX-03: AppSidebar shows skeleton placeholders while loading', () => {
it.todo('renders skeleton rows in folder section when loadingRoots=true')
it.todo('renders skeleton rows in topics section when topicsStore.loading=true')
it.todo('renders skeleton rows in cloud section when loadingCloudConnections=true')
it.todo('Loading… text is removed from all three sections')
})
describe('UX-01 (sidebar micro): EmptyState size=sm appears when each section is empty', () => {
it.todo('folders empty: EmptyState size=sm with icon=folder')
it.todo('topics empty: EmptyState size=sm with icon=tag')
it.todo('cloud empty: EmptyState size=sm with icon=cloud and a Settings link in #cta')
})
describe('UX-14: AppSidebar no longer renders an inline "New" folder button', () => {
it.todo('no <button> with text "New" exists in the rendered template')
it.todo('startNewFolder/cancelNewFolder/submitNewFolder methods are removed')
it.todo('newFolderName/showNewFolderInput state is removed')
})
```
**File C — `frontend/src/views/admin/__tests__/AdminAuditView.skeleton.test.js`:**
```
import { describe, it } from 'vitest'
describe('UX-04: AdminAuditView shows skeleton table rows during loading', () => {
it.todo('renders 5+ skeleton <tr> rows when loading=true')
it.todo('skeleton rows have 5 columns matching the real table header')
it.todo('animated spinner Loading audit log… text is removed')
})
describe('UX-01 (audit empty): EmptyState renders when entries is empty after load', () => {
it.todo('EmptyState icon=clipboardList headline="No entries found" appears when entries.length===0 and not loading')
it.todo('clear-filters button rendered in #cta slot')
})
```
**File D — `frontend/src/views/admin/__tests__/AdminUsersView.skeleton.test.js`:**
```
import { describe, it } from 'vitest'
describe('UX-04: AdminUsersView shows skeleton table rows during loading', () => {
it.todo('renders 5+ skeleton <tr> rows when loading=true')
it.todo('skeleton rows have 6 columns matching the real table header')
it.todo('animated spinner Loading users… text is removed')
})
```
**File E — `frontend/src/components/ui/__tests__/dropdown.test.js`:**
```
import { describe, it } from 'vitest'
describe('UX-13: Teleport + getBoundingClientRect dropdown pattern across components', () => {
it.todo('DocumentCard folder picker uses Teleport to body')
it.todo('DocumentCard folder picker position matches trigger getBoundingClientRect')
it.todo('FolderRow three-dot menu uses Teleport to body')
it.todo('FolderRow three-dot menu repositions on window scroll')
})
```
Do not implement any real tests — these are stubs Wave 1/3 will promote.
</action>
<verify>
<automated>cd frontend && npm run test -- --run skeleton.test &amp;&amp; cd frontend && npm run test -- --run dropdown.test &amp;&amp; cd frontend && npm run test -- --run AppSidebar.empty</automated>
Expected: tests collected, all `.todo` are reported but not failing (Vitest reports `.todo` as "skipped/pending" — they do not fail the suite).
</verify>
<acceptance_criteria>
- All 5 files exist at the specified paths
- Each file contains at least one `describe('UX-XX: ...')` block whose name starts with the requirement ID
- Total `it.todo(...)` count across the 5 files ≥ 20
- Running `cd frontend && npm run test -- --run` does NOT fail because of these files (they only contain `.todo`)
- Grep verification: `grep -rE "describe\\('UX-0[234]" frontend/src/components/storage/__tests__/StorageBrowser.skeleton.test.js frontend/src/components/layout/__tests__/AppSidebar.empty.test.js frontend/src/views/admin/__tests__/AdminAuditView.skeleton.test.js frontend/src/views/admin/__tests__/AdminUsersView.skeleton.test.js | wc -l` ≥ 4
- Grep: `grep -rE "describe\\('UX-1[34]" frontend/src/components/ui/__tests__/dropdown.test.js frontend/src/components/layout/__tests__/AppSidebar.empty.test.js | wc -l` ≥ 2
</acceptance_criteria>
<done>5 skeleton/empty/dropdown stub files exist covering UX-02, UX-03, UX-04, UX-13, UX-14.</done>
</task>
<task type="auto">
<name>Task 2: Create keyboard + OS-drag + drag-to-move stubs (UX-05..09, UX-11)</name>
<files>
frontend/src/__tests__/keyboard.test.js,
frontend/src/components/layout/__tests__/OsDragOverlay.test.js,
frontend/src/components/storage/__tests__/StorageBrowser.dragmove.test.js
</files>
<read_first>
- .planning/phases/10-ux-interaction/10-VALIDATION.md (behavioral contracts)
- .planning/phases/10-ux-interaction/10-RESEARCH.md §"Common Pitfalls" 2, 3, 12
</read_first>
<action>
Create 3 stub test files:
**File F — `frontend/src/__tests__/keyboard.test.js`:**
```
import { describe, it } from 'vitest'
describe('UX-05: "/" focuses the search bar', () => {
it.todo('dispatching keydown "/" calls focus() on SearchBar input via App.vue routeViewRef chain')
it.todo('"/" does NOT redirect when an INPUT element has focus (guard: document.activeElement.tagName)')
})
describe('UX-06: "Escape" clears active search (when no input focused)', () => {
it.todo('keydown Escape clears searchQuery via FileManagerView.clearSearch()')
it.todo('Escape does NOT clear search while inside a focused INPUT')
it.todo('Escape does NOT double-trigger when DocumentPreviewModal is open (modal owns its Escape handler)')
})
describe('UX-07: "U" triggers the upload picker', () => {
it.todo('keydown "u" calls DropZone.triggerInput() through the ref chain (App→FileManagerView→StorageBrowser→DropZone)')
it.todo('"U" does NOT fire when input is focused')
it.todo('"U" is a no-op on routes that do not expose triggerUpload (optional chain)')
})
describe('UX-08: "N" starts new folder input', () => {
it.todo('keydown "n" calls StorageBrowser.startNewFolder() via ref chain')
it.todo('"N" does NOT fire when input is focused')
it.todo('"N" is a no-op on CloudFolderView (does not expose startNewFolder)')
})
```
**File G — `frontend/src/components/layout/__tests__/OsDragOverlay.test.js`:**
```
import { describe, it } from 'vitest'
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])')
})
```
**File H — `frontend/src/components/storage/__tests__/StorageBrowser.dragmove.test.js`:**
```
import { describe, it } from 'vitest'
describe('UX-11: Drag-to-move document onto folder row', () => {
it.todo('dragOverFolderId set on @dragover applies bg-amber-50 ring-2 ring-inset ring-amber-300 to that folder row')
it.todo('drop on folder emits file-move with { fileId, folderId }')
it.todo('dragend resets draggingFile to null after nextTick (click-after-drag guard)')
it.todo('click on file row is suppressed when draggingFile is non-null')
})
describe('UX-11 (toast wiring): doMove emits toast', () => {
it.todo('successful moveToFolder triggers useToastStore.show("Document moved", "success")')
it.todo('failed moveToFolder triggers useToastStore.show("Move failed: ...", "error")')
})
```
</action>
<verify>
<automated>cd frontend && npm run test -- --run keyboard.test &amp;&amp; cd frontend && npm run test -- --run OsDragOverlay.test &amp;&amp; cd frontend && npm run test -- --run dragmove.test</automated>
Expected: tests collected; all .todo entries reported as pending; suite does not fail.
</verify>
<acceptance_criteria>
- All 3 files exist at the specified paths
- `keyboard.test.js` contains describe blocks for UX-05, UX-06, UX-07, UX-08 (4 distinct requirement IDs)
- `OsDragOverlay.test.js` contains describe for UX-09
- `StorageBrowser.dragmove.test.js` contains describe for UX-11
- Total `it.todo` count across the 3 files ≥ 18
- `cd frontend && npm run test -- --run` does not fail because of these files
- Grep: `grep -E "describe\\('UX-0[5678]" frontend/src/__tests__/keyboard.test.js | wc -l` returns 4
</acceptance_criteria>
<done>3 stub files exist covering UX-05..09 + UX-11.</done>
</task>
</tasks>
<verification>
- All 8 stub test files exist at the specified paths (5 from Task 1 + 3 from Task 2)
- `cd frontend && npm run test -- --run` continues to pass (no `.todo` produces a failure)
- Each of UX-02, UX-03, UX-04, UX-05, UX-06, UX-07, UX-08, UX-09, UX-11, UX-13, UX-14 appears in at least one stub `describe(...)` block
- Coverage check: `grep -rE "UX-0[23456789]|UX-1[1345]|UX-14" frontend/src/__tests__/keyboard.test.js frontend/src/components/storage/__tests__/StorageBrowser.skeleton.test.js frontend/src/components/storage/__tests__/StorageBrowser.dragmove.test.js frontend/src/components/layout/__tests__/AppSidebar.empty.test.js frontend/src/views/admin/__tests__/AdminAuditView.skeleton.test.js frontend/src/views/admin/__tests__/AdminUsersView.skeleton.test.js frontend/src/components/layout/__tests__/OsDragOverlay.test.js frontend/src/components/ui/__tests__/dropdown.test.js | wc -l` returns ≥ 11
</verification>
<success_criteria>
Every requirement implemented in Waves 1-4 has a placeholder test that will be promoted to a real assertion when the implementing wave runs. Wave verification can detect when a requirement has been forgotten by checking that the corresponding `.todo` was upgraded to a real `it(...)`.
</success_criteria>
<output>
Create `.planning/phases/10-ux-interaction/10-05-SUMMARY.md` when done. Include the total count of `it.todo` stubs created.
</output>
@@ -0,0 +1,406 @@
---
phase: 10-ux-interaction
plan: 06
type: execute
wave: 1
depends_on: [10-02, 10-03, 10-04, 10-05]
files_modified:
- frontend/src/components/storage/StorageBrowser.vue
- frontend/src/views/FileManagerView.vue
- frontend/src/views/CloudFolderView.vue
- frontend/src/components/folders/FolderBreadcrumb.vue
- frontend/src/components/folders/__tests__/FolderBreadcrumb.test.js
- frontend/src/components/storage/__tests__/StorageBrowser.skeleton.test.js
autonomous: true
requirements: [UX-02, UX-01, UX-10, UX-12]
must_haves:
truths:
- "StorageBrowser shows 5+ animated skeleton rows when loading=true (no Loading… text)"
- "Empty file list renders <EmptyState> with the appropriate icon/headline/subtext per context (root, folder, search)"
- "BreadcrumbBar replaces FolderBreadcrumb in StorageBrowser; FolderBreadcrumb.vue is deleted in this plan"
- "FileManagerView maps foldersStore.breadcrumb to [{id, label}] before passing as segments"
- "CloudFolderView maps its breadcrumb to [{id, label}] before passing as segments"
- "FileManagerView toast wiring fires on document delete and document move success/failure"
artifacts:
- path: "frontend/src/components/storage/StorageBrowser.vue"
provides: "Updated with skeleton + EmptyState + BreadcrumbBar"
- path: "frontend/src/views/FileManagerView.vue"
provides: "Updated breadcrumb mapping + toast call sites"
- path: "frontend/src/views/CloudFolderView.vue"
provides: "Updated breadcrumb mapping"
key_links:
- from: "StorageBrowser.vue"
to: "BreadcrumbBar.vue"
via: "import + <BreadcrumbBar :segments=\"breadcrumb\" />"
pattern: "import BreadcrumbBar"
- from: "FileManagerView.vue"
to: "useToastStore"
via: "show() called in doMove and doDeleteDoc"
pattern: "useToastStore"
---
<objective>
Wire the Wave 0 foundation components into `StorageBrowser.vue`, `FileManagerView.vue`, and `CloudFolderView.vue`. This plan completes UX-02 (skeleton rows), the StorageBrowser parts of UX-01 (EmptyState), the file-manager parts of UX-12 (BreadcrumbBar swap and FolderBreadcrumb deletion), and the FileManagerView parts of UX-10 (toast call sites).
Per CLAUDE.md "no dead code", FolderBreadcrumb.vue and its test file are deleted in the SAME commit as the BreadcrumbBar swap.
Output:
- StorageBrowser: 5-row skeleton (UX-02), three EmptyState variants (root/folder/search), import swap FolderBreadcrumb → BreadcrumbBar
- FileManagerView: breadcrumb `{id, name}``{id, label}` mapping, toast.show() on doMove + doDeleteDoc success/error
- CloudFolderView: breadcrumb mapping + BreadcrumbBar with rootLabel='Cloud'
- FolderBreadcrumb.vue + FolderBreadcrumb.test.js DELETED
- StorageBrowser.skeleton.test.js stub promoted to real tests
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@CLAUDE.md
@.planning/phases/10-ux-interaction/10-CONTEXT.md
@.planning/phases/10-ux-interaction/10-RESEARCH.md
@.planning/phases/10-ux-interaction/10-PATTERNS.md
@frontend/src/components/storage/StorageBrowser.vue
@frontend/src/views/FileManagerView.vue
@frontend/src/views/CloudFolderView.vue
@frontend/src/components/folders/FolderBreadcrumb.vue
@frontend/src/components/ui/EmptyState.vue
@frontend/src/components/ui/BreadcrumbBar.vue
@frontend/src/stores/toast.js
<interfaces>
**StorageBrowser already uses `<script setup>`** — modifications stay in that style. Skeleton classes from RESEARCH.md §Pitfall 7:
- Container: `grid grid-cols-[2rem_1fr_6rem_8rem_6rem] gap-3 px-4 py-2.5 items-center border-b border-gray-100`
- Col 1: `<div class="w-7 h-7 bg-gray-100 rounded-lg animate-pulse"></div>`
- Col 2: `<div class="h-4 bg-gray-100 rounded animate-pulse w-2/3"></div>`
- Col 3: `<div class="h-3 bg-gray-100 rounded animate-pulse hidden md:block"></div>`
- Col 4: `<div class="h-3 bg-gray-100 rounded animate-pulse hidden sm:block"></div>`
- Col 5: `<div class="w-14 h-3 bg-gray-100 rounded animate-pulse"></div>`
**EmptyState contexts in StorageBrowser** (from RESEARCH.md §Component Inventory §3):
| Condition | icon | headline | subtext |
|-----------|------|----------|---------|
| Root: !currentFolderId && lists empty && !searchQuery | folder | "Nothing here yet" | "Create a folder or upload your first file to get started." |
| In folder: currentFolderId && lists empty && !searchQuery | document | "This folder is empty" | "Upload files above or create a sub-folder." |
| Search: searchQuery && lists empty | search | `No results for "{{ searchQuery }}"` | "Try a different search term or clear the filter." |
StorageBrowser does not know `currentFolderId` directly — keep using the existing `emptyMessage` and `emptyHint` props, but change FileManagerView to pass per-context strings, OR replace the inline empty divs with three explicit conditionals inside StorageBrowser using `breadcrumb.length > 0` as a proxy for "in folder".
**Decision:** keep the props (emptyMessage, emptyHint) but render via `<EmptyState>` block. Use `breadcrumb.length === 0` (root) vs `> 0` (in folder) as the discriminator inside StorageBrowser for the icon name (folder vs document). For search no-results, the searchQuery check overrides.
**BreadcrumbBar wiring:**
- StorageBrowser passes through `:segments="breadcrumb"` — parents must already map name→label
- FileManagerView template: `:breadcrumb="mappedBreadcrumb"` where `mappedBreadcrumb` = computed mapping `foldersStore.breadcrumb` to `[{id: f.id, label: f.name}]`
- CloudFolderView: existing `breadcrumb` computed already returns `{id, name}` — add a `mappedBreadcrumb` computed
- Pass `rootLabel="Home"` for local mode and `rootLabel="Cloud"` for cloud mode (handled by passing through a prop or hardcoding in StorageBrowser based on `mode`)
**Toast call sites in FileManagerView (per RESEARCH.md §Toast System):**
- `doMove(docId, folderId)`: on success → `useToastStore().show('Document moved', 'success')`; on catch → `useToastStore().show('Move failed: ' + (e.message || 'unknown error'), 'error')`
- `doDeleteDoc(docId)`: on success → `useToastStore().show('Document deleted', 'success')`; on catch → `useToastStore().show('Delete failed: ' + (e.message || 'unknown error'), 'error')`
- (Upload toasts are handled in plan 10-07 to keep file ownership clean? — NO, FileManagerView owns upload too. Defer upload toast wiring to this plan as well.)
- `onFilesSelected({files, autoClassify})`: after `Promise.allSettled`, count items with `done=true` and items with errors, and show one toast: `show(`${successCount} of ${files.length} file(s) uploaded`, successCount === files.length ? 'success' : 'warning')` and for each item with `item.error` set, show `show('Upload failed: ' + item.error, 'error')`.
**FolderBreadcrumb.vue deletion:**
- Delete `frontend/src/components/folders/FolderBreadcrumb.vue`
- Delete `frontend/src/components/folders/__tests__/FolderBreadcrumb.test.js`
- Grep verify no remaining import: `grep -r "FolderBreadcrumb" frontend/src/` returns 0 matches after edits
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Promote StorageBrowser.skeleton.test.js stubs to real tests</name>
<files>frontend/src/components/storage/__tests__/StorageBrowser.skeleton.test.js</files>
<read_first>
- frontend/src/components/storage/__tests__/StorageBrowser.skeleton.test.js (Wave 0 stub from plan 10-05)
- frontend/src/components/storage/StorageBrowser.vue (current state)
- .planning/phases/10-ux-interaction/10-PATTERNS.md §"5-column StorageBrowser skeleton"
</read_first>
<behavior>
Replace the `.todo` entries (UX-02 group) with real assertions:
- Test 1: `renders 5 skeleton rows when loading=true and lists empty` — mount StorageBrowser with loading=true, folders=[], files=[], stub BreadcrumbBar/SearchBar/SortControls/DropZone/UploadProgress/TopicBadge. Assert `wrapper.findAll('.animate-pulse').length >= 5` (or assert a count of skeleton row containers >= 5).
- Test 2: `Loading… text is absent when loading=true` — mount loading=true, assert `wrapper.text()` does NOT include 'Loading…'.
- Test 3: `skeleton rows are NOT rendered when loading=false` — mount loading=false, folders=[], files=[], assert `wrapper.findAll('.animate-pulse').length === 0`.
- Test 4: `skeleton row grid matches grid-cols-[2rem_1fr_6rem_8rem_6rem]` — mount loading=true, assert at least one element with class `grid-cols-[2rem_1fr_6rem_8rem_6rem]` exists in the skeleton block.
Keep the UX-13 `it.todo` stubs as-is (deferred to plan 10-12).
</behavior>
<action>
Modify `frontend/src/components/storage/__tests__/StorageBrowser.skeleton.test.js`. Replace ONLY the UX-02 `describe` block's `it.todo` entries with the 4 real tests above. Keep the UX-13 describe block untouched (its tests are promoted in plan 10-12). Import `StorageBrowser` from `../StorageBrowser.vue` and use `import { mount } from '@vue/test-utils'`. Stub child components via `global.stubs: { BreadcrumbBar: true, SearchBar: true, SortControls: true, DropZone: true, UploadProgress: true, TopicBadge: true }`. Create a fresh Pinia instance per test using `setActivePinia(createPinia())` if needed.
</action>
<verify>
<automated>cd frontend && npm run test -- --run StorageBrowser.skeleton</automated>
Expected: 4 UX-02 tests FAIL (RED — StorageBrowser still shows Loading… text); 3 UX-13 .todo entries stay pending.
</verify>
<acceptance_criteria>
- File still exists with both `describe('UX-02: ...')` and `describe('UX-13: ...')` blocks
- The UX-02 describe block contains 4 real `it(...)` tests (no `.todo` for UX-02)
- The UX-13 describe block still contains `.todo` entries
- Running `cd frontend && npm run test -- --run StorageBrowser.skeleton` shows 4 failing UX-02 tests
</acceptance_criteria>
<done>4 RED tests in place for UX-02 skeleton behavior.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Update StorageBrowser.vue — skeleton, EmptyState, BreadcrumbBar swap</name>
<files>frontend/src/components/storage/StorageBrowser.vue</files>
<read_first>
- frontend/src/components/storage/StorageBrowser.vue (current state — uses <script setup>)
- frontend/src/components/storage/__tests__/StorageBrowser.skeleton.test.js (failing tests)
- frontend/src/components/ui/BreadcrumbBar.vue (interface)
- frontend/src/components/ui/EmptyState.vue (interface)
- .planning/phases/10-ux-interaction/10-PATTERNS.md §"StorageBrowser.vue" + §"5-column StorageBrowser skeleton"
- .planning/phases/10-ux-interaction/10-RESEARCH.md §"Component Inventory §3"
</read_first>
<behavior>
1. Import swap: replace `import FolderBreadcrumb from '../folders/FolderBreadcrumb.vue'` with `import BreadcrumbBar from '../ui/BreadcrumbBar.vue'`; also import `EmptyState`.
2. Template: replace `<FolderBreadcrumb :segments="breadcrumb" ...>` with `<BreadcrumbBar :segments="breadcrumb" :root-label="mode === 'cloud' ? 'Cloud' : 'Home'" @navigate="$emit('breadcrumb-navigate', $event)" />`.
3. Replace the three inline empty-state divs (lines 226-241 of current file) with `<EmptyState>` blocks based on the discriminators:
- Search no-results: `<EmptyState v-else-if="!loading && searchQuery && (folders.length + files.length) === 0" icon="search" :headline="'No results for ' + JSON.stringify(searchQuery)" subtext="Try a different search term or clear the filter."><template #cta><button @click="$emit('search-change', '')" class="mt-3 text-sm text-indigo-600 hover:underline">Clear search</button></template></EmptyState>`
- In-folder empty (breadcrumb non-empty): `<EmptyState v-else-if="!loading && breadcrumb.length > 0 && folders.length === 0 && files.length === 0 && !showNewFolderInput" icon="document" :headline="emptyMessage" :subtext="emptyHint" />`
- Root empty: `<EmptyState v-else-if="!loading && breadcrumb.length === 0 && folders.length === 0 && files.length === 0 && !showNewFolderInput" icon="folder" :headline="emptyMessage" :subtext="emptyHint" />`
(Use the `emptyMessage` / `emptyHint` props for headline/subtext so existing parent prop-driven configuration still works.)
4. Replace the `<div v-if="loading" ...>Loading…</div>` element (current line 244) with a `<template v-if="loading">` block containing 5 skeleton row divs matching the grid:
```
<template v-if="loading">
<div v-for="n in 5" :key="`sk-${n}`" class="px-4 py-2.5 grid grid-cols-[2rem_1fr_6rem_8rem_6rem] gap-3 items-center border-b border-gray-100">
<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>
</template>
```
Ensure the empty-state blocks use `v-else-if` so they only render when `!loading`.
</behavior>
<action>
Edit `frontend/src/components/storage/StorageBrowser.vue`:
**Step 1 — Imports (in `<script setup>` block):**
Replace `import FolderBreadcrumb from '../folders/FolderBreadcrumb.vue'` with `import BreadcrumbBar from '../ui/BreadcrumbBar.vue'` and add `import EmptyState from '../ui/EmptyState.vue'`.
**Step 2 — Template breadcrumb usage:**
Replace lines 7-10 (the `<FolderBreadcrumb>` block) with:
```
<BreadcrumbBar
:segments="breadcrumb"
:root-label="mode === 'cloud' ? 'Cloud' : 'Home'"
@navigate="$emit('breadcrumb-navigate', $event)"
/>
```
**Step 3 — Replace empty state and loading blocks (current lines 226-244):**
Replace the existing three blocks (lines 226-244) with:
```
<template v-if="loading">
<div
v-for="n in 5"
:key="`sk-${n}`"
class="px-4 py-2.5 grid grid-cols-[2rem_1fr_6rem_8rem_6rem] gap-3 items-center border-b border-gray-100"
>
<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>
</template>
<EmptyState
v-else-if="searchQuery && folders.length === 0 && files.length === 0"
icon="search"
:headline="`No results for &quot;${searchQuery}&quot;`"
subtext="Try a different search term or clear the filter."
>
<template #cta>
<button @click="$emit('search-change', '')" class="mt-3 text-sm text-indigo-600 hover:underline">
Clear search
</button>
</template>
</EmptyState>
<EmptyState
v-else-if="breadcrumb.length > 0 && folders.length === 0 && files.length === 0 && !showNewFolderInput"
icon="document"
:headline="emptyMessage"
:subtext="emptyHint"
/>
<EmptyState
v-else-if="folders.length === 0 && files.length === 0 && !showNewFolderInput"
icon="folder"
:headline="emptyMessage"
:subtext="emptyHint"
/>
```
No comments. Keep the rest of StorageBrowser.vue unchanged.
</action>
<verify>
<automated>cd frontend && npm run test -- --run StorageBrowser.skeleton</automated>
Expected: 4 UX-02 tests PASS.
</verify>
<acceptance_criteria>
- `grep -E "import BreadcrumbBar from '../ui/BreadcrumbBar.vue'" frontend/src/components/storage/StorageBrowser.vue` returns 1
- `grep -E "import EmptyState from '../ui/EmptyState.vue'" frontend/src/components/storage/StorageBrowser.vue` returns 1
- `grep -E "import FolderBreadcrumb" frontend/src/components/storage/StorageBrowser.vue` returns 0
- `grep -E "<FolderBreadcrumb" frontend/src/components/storage/StorageBrowser.vue` returns 0
- `grep -E "<BreadcrumbBar" frontend/src/components/storage/StorageBrowser.vue` returns 1
- `grep -E "<EmptyState" frontend/src/components/storage/StorageBrowser.vue` returns 3 (root, folder, search)
- `grep -v '^#' frontend/src/components/storage/StorageBrowser.vue | grep -c "Loading…"` returns 0
- `grep -E "animate-pulse" frontend/src/components/storage/StorageBrowser.vue` returns ≥ 5
- `cd frontend && npm run test -- --run StorageBrowser.skeleton` exits 0 (UX-02 GREEN)
</acceptance_criteria>
<done>StorageBrowser has skeleton, EmptyState, and BreadcrumbBar wired; UX-02 tests green.</done>
</task>
<task type="auto">
<name>Task 3: Update FileManagerView + CloudFolderView for breadcrumb mapping + toast wiring; delete FolderBreadcrumb</name>
<files>
frontend/src/views/FileManagerView.vue,
frontend/src/views/CloudFolderView.vue,
frontend/src/components/folders/FolderBreadcrumb.vue,
frontend/src/components/folders/__tests__/FolderBreadcrumb.test.js
</files>
<read_first>
- frontend/src/views/FileManagerView.vue (current state — uses <script setup>)
- frontend/src/views/CloudFolderView.vue (current breadcrumb computed)
- frontend/src/stores/folders.js (foldersStore.breadcrumb shape)
- frontend/src/stores/toast.js (useToastStore signature)
- .planning/phases/10-ux-interaction/10-PATTERNS.md §"FileManagerView.vue"
</read_first>
<action>
**Step A — Modify `frontend/src/views/FileManagerView.vue`:**
Add `import { useToastStore } from '../stores/toast.js'` near the other imports.
Add a `computed` for `mappedBreadcrumb`:
```js
const mappedBreadcrumb = computed(() =>
(foldersStore.breadcrumb || []).map(f => ({ id: f.id, label: f.name }))
)
```
Change the template `:breadcrumb="foldersStore.breadcrumb"` to `:breadcrumb="mappedBreadcrumb"` on the `<StorageBrowser>` element.
Update `doMove`:
```js
async function doMove(docId, folderId) {
const toast = useToastStore()
try {
await docsStore.moveToFolder(docId, folderId)
toast.show('Document moved', 'success')
} catch (e) {
toast.show('Move failed: ' + (e.message || 'unknown error'), 'error')
}
}
```
Update `doDeleteDoc`:
```js
async function doDeleteDoc(docId) {
const toast = useToastStore()
try {
await docsStore.remove(docId)
toast.show('Document deleted', 'success')
} catch (e) {
toast.show('Delete failed: ' + (e.message || 'unknown error'), 'error')
}
}
```
Update `onFilesSelected` to fire a summary toast at the end:
```js
async function onFilesSelected({ files, autoClassify }) {
const folderId = currentFolderId.value
const toast = useToastStore()
const promises = files.map(file => {
const item = reactive({ name: file.name, done: false, error: null, quotaError: null, topics: null })
uploadQueue.value.unshift(item)
return docsStore.upload(file, autoClassify, folderId)
.then(({ doc }) => { item.done = true; item.topics = doc.topics ?? [] })
.catch(e => {
if (e.status === 413 && e.payload) item.quotaError = e.payload
else item.error = e.message
})
})
await Promise.allSettled(promises)
await topicsStore.fetchTopics()
const succeeded = uploadQueue.value.slice(0, files.length).filter(i => i.done).length
if (succeeded === files.length) {
toast.show(`${succeeded} file(s) uploaded`, 'success')
} else if (succeeded > 0) {
toast.show(`${succeeded} of ${files.length} file(s) uploaded`, 'warning')
} else {
toast.show('Upload failed', 'error')
}
}
```
(Important: the upload error per-item already populates `item.error`; UploadProgress already renders these. The summary toast is in addition.)
**Step B — Modify `frontend/src/views/CloudFolderView.vue`:**
Add a `mappedBreadcrumb` computed mapping `breadcrumb.value` (or the existing computed) to `[{id, label: name}]`. Pass `:breadcrumb="mappedBreadcrumb"` instead of `:breadcrumb="breadcrumb"`. Leave everything else unchanged.
**Step C — Delete FolderBreadcrumb files:**
Delete both files:
- `frontend/src/components/folders/FolderBreadcrumb.vue`
- `frontend/src/components/folders/__tests__/FolderBreadcrumb.test.js`
Use the file deletion tool or `rm` via Bash. Confirm with `ls frontend/src/components/folders/`.
No comments added. No `console.error` removed from doMove/doDeleteDoc — leave existing behavior intact aside from the toast addition. Actually: REMOVE the `console.error(e.message)` lines because the toast now communicates the error to the user.
Per D-11/D-13: the `mappedBreadcrumb` computed is also used in any test for FileManagerView — update those tests in the next step.
Final grep verification: `grep -r "FolderBreadcrumb" frontend/src/` returns 0 matches.
</action>
<verify>
<automated>cd frontend && npm run test -- --run FileManagerView &amp;&amp; cd frontend && npm run test -- --run StorageBrowser.skeleton &amp;&amp; cd frontend && npm run test -- --run BreadcrumbBar &amp;&amp; cd frontend && npm run test -- --run toast</automated>
Expected: all relevant test suites pass. FolderBreadcrumb tests no longer collected (file deleted).
</verify>
<acceptance_criteria>
- File `frontend/src/components/folders/FolderBreadcrumb.vue` no longer exists
- File `frontend/src/components/folders/__tests__/FolderBreadcrumb.test.js` no longer exists
- `grep -r "FolderBreadcrumb" frontend/src/` returns 0 lines
- `grep -E "useToastStore" frontend/src/views/FileManagerView.vue` returns ≥ 4 occurrences (import + 3 action handlers minimum)
- `grep -E "toast\\.show\\('Document moved'" frontend/src/views/FileManagerView.vue` returns 1 match
- `grep -E "toast\\.show\\('Document deleted'" frontend/src/views/FileManagerView.vue` returns 1 match
- `grep -E "mappedBreadcrumb" frontend/src/views/FileManagerView.vue` returns ≥ 2 matches
- `grep -E "mappedBreadcrumb" frontend/src/views/CloudFolderView.vue` returns ≥ 2 matches
- `grep -E "console\\.error" frontend/src/views/FileManagerView.vue` returns ≤ 1 (or 0 if previously only present in doMove/doDeleteDoc)
- Suites pass: FileManagerView, StorageBrowser.skeleton, BreadcrumbBar, toast all exit 0
</acceptance_criteria>
<done>FileManagerView + CloudFolderView wired; FolderBreadcrumb deleted; toast call sites live; all tests green.</done>
</task>
</tasks>
<verification>
- `cd frontend && npm run test -- --run StorageBrowser.skeleton` exits 0
- `cd frontend && npm run test -- --run FileManagerView` exits 0
- `cd frontend && npm run test -- --run BreadcrumbBar` exits 0 (regression)
- `cd frontend && npm run test -- --run toast` exits 0 (regression)
- `grep -r "FolderBreadcrumb" frontend/src/` returns 0 lines (dead code removed)
- `cd frontend && npm run test -- --run` (full suite) exits 0 — no other suite broke
- StorageBrowser.vue contains exactly 3 `<EmptyState>` blocks and exactly 1 `<BreadcrumbBar>` block
- StorageBrowser.vue contains 5 skeleton `<div>`s with `animate-pulse`
- Loading… text removed (grep returns 0 in StorageBrowser.vue)
</verification>
<success_criteria>
Loading the file manager with `loading=true` shows 5 animated skeleton rows. After load completes:
- Empty root folder → "Nothing here yet" with folder icon
- Empty sub-folder → "This folder is empty" with document icon
- Search with no matches → "No results for {query}" with search icon and Clear button
Breadcrumbs use the shared BreadcrumbBar with rootLabel="Home"/"Cloud" per mode. Document move/delete/upload actions fire toasts.
</success_criteria>
<output>
Create `.planning/phases/10-ux-interaction/10-06-SUMMARY.md` when done. List which behaviors became GREEN and the exact line ranges modified.
</output>
@@ -0,0 +1,267 @@
---
phase: 10-ux-interaction
plan: 07
type: execute
wave: 1
depends_on: [10-02, 10-03, 10-05]
files_modified:
- frontend/src/components/layout/AppSidebar.vue
- frontend/src/components/layout/__tests__/AppSidebar.empty.test.js
autonomous: true
requirements: [UX-03, UX-01, UX-14]
must_haves:
truths:
- "AppSidebar renders sidebar-indent skeleton placeholders for folders, topics, and cloud sections while loading"
- "AppSidebar renders EmptyState size='sm' for each empty section (folders, topics, cloud)"
- "AppSidebar no longer contains a New button for creating root folders (UX-14)"
- "AppSidebar no longer contains startNewFolder/cancelNewFolder/submitNewFolder methods or the related state (showNewFolderInput, newFolderName, newFolderError)"
- "StorageBrowser's own startNewFolder (file manager) is UNTOUCHED"
artifacts:
- path: "frontend/src/components/layout/AppSidebar.vue"
provides: "Updated sidebar with skeleton, EmptyState, no inline New button"
key_links:
- from: "AppSidebar.vue"
to: "EmptyState.vue"
via: "<EmptyState size=\"sm\" :icon=\"...\" />"
pattern: "<EmptyState size=\"sm\""
---
<objective>
Wire UX-03 (sidebar skeletons), UX-01 (sidebar EmptyState micro states), and UX-14 (remove sidebar "New" folder button + related state/methods) in `frontend/src/components/layout/AppSidebar.vue`.
Per the planning_guidance, UX-14 removal targets ONLY AppSidebar's own folder-creation flow. The file manager's own inline new-folder UI (`StorageBrowser.startNewFolder`) is UNTOUCHED — it remains the canonical way to create folders.
Output: AppSidebar.vue with skeleton placeholders matching TreeItem indent (pl-7), three EmptyState size='sm' micro states (folders/topics/cloud), and the "New" button + helper methods + state removed.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@CLAUDE.md
@.planning/phases/10-ux-interaction/10-CONTEXT.md
@.planning/phases/10-ux-interaction/10-RESEARCH.md
@.planning/phases/10-ux-interaction/10-PATTERNS.md
@frontend/src/components/layout/AppSidebar.vue
@frontend/src/components/ui/EmptyState.vue
@frontend/src/components/ui/TreeItem.vue
@frontend/src/components/storage/StorageBrowser.vue
<interfaces>
**AppSidebar.vue uses Options API** — current file, see PATTERNS.md §"AppSidebar.vue".
**Sidebar skeleton pattern (PATTERNS.md §3 Sidebar skeleton):**
```html
<div class="pl-7 py-1 space-y-1">
<div v-for="n in 3" :key="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>
```
**Three sections requiring skeleton + EmptyState (RESEARCH.md §Component Inventory §3):**
| Section | Loading condition | Empty condition | EmptyState icon | EmptyState headline | EmptyState CTA |
|---------|-------------------|-----------------|-----------------|---------------------|----------------|
| Folders | `loadingRoots` | `foldersStore.rootFolders.length === 0` | folder | "Create a folder in the file manager" | (none) |
| Topics | `topicsStore.loading` | `topicsStore.topics.length === 0` | tag | "No topics yet" | (none) |
| Cloud | `loadingCloudConnections` | `activeCloudConnections.length === 0` | cloud | "Connect in Settings" | router-link to /settings |
All EmptyStates use `size="sm"`.
**UX-14 removal targets in AppSidebar.vue:**
- Template: the `<button @click="startNewFolder">New</button>` element near the folder section header (current lines 75-82)
- Template: the inline new-folder `<div v-if="showNewFolderInput">` block (current lines 87-98)
- Script: methods `startNewFolder()`, `cancelNewFolder()`, `submitNewFolder()` (current lines 289-312)
- Script: state `showNewFolderInput`, `newFolderName`, `newFolderError`
- Update the empty-folder text block (current lines 102-103) to remove the `&& !showNewFolderInput` condition since that variable no longer exists.
**StorageBrowser invariant:** `frontend/src/components/storage/StorageBrowser.vue` still has its own `startNewFolder` function, `showNewFolderInput` state, and the inline new-folder input row — DO NOT touch any of these.
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Promote AppSidebar.empty.test.js stubs to real tests</name>
<files>frontend/src/components/layout/__tests__/AppSidebar.empty.test.js</files>
<read_first>
- frontend/src/components/layout/__tests__/AppSidebar.empty.test.js (Wave 0 stub)
- frontend/src/components/layout/AppSidebar.vue (current state)
- frontend/src/stores/folders.js, frontend/src/stores/topics.js, frontend/src/stores/cloudConnections.js (store shapes for mocking)
</read_first>
<behavior>
Replace `.todo` entries with real assertions (skeleton, EmptyState micro, UX-14 absence):
- UX-03 group (3 tests):
1. `renders folder skeleton rows when loadingRoots is true` — mount AppSidebar with foldersStore.loadingRoots=true (use a setup or component data override), assert `wrapper.findAll('.animate-pulse').length >= 3` within the folders section
2. `renders topics skeleton rows when topicsStore.loading is true` — similar assertion for topics
3. `renders cloud skeleton rows when loadingCloudConnections is true` — similar assertion for cloud
- UX-01 sidebar micro (3 tests):
4. `renders <EmptyState size="sm" icon="folder"> in folders section when empty` — mount with all loading=false and empty store arrays, stub EmptyState, assert presence in DOM via component lookup
5. `renders <EmptyState size="sm" icon="tag"> in topics section when empty` — similar
6. `renders <EmptyState size="sm" icon="cloud"> in cloud section when empty with #cta router-link` — similar
- UX-14 group (3 tests):
7. `template does NOT include a "New" button in the folder section header` — assert no `<button>` element in the rendered AppSidebar has text content equal to 'New'
8. `component does NOT expose startNewFolder method` — Vue Options API: mount component, assert `wrapper.vm.startNewFolder` is undefined
9. `component data does NOT include showNewFolderInput` — assert `wrapper.vm.showNewFolderInput` is undefined
For mocking stores, use a `createMockPinia` helper or use `setActivePinia(createPinia())` and override store state after creation (e.g., `useFoldersStore().rootFolders = []`).
</behavior>
<action>
Modify `frontend/src/components/layout/__tests__/AppSidebar.empty.test.js`. Replace each `it.todo(...)` from Task 1's stub set with the real tests above. Use `import { mount } from '@vue/test-utils'`, `import { setActivePinia, createPinia } from 'pinia'`, and stub `router-link` + `EmptyState` + `AppIcon` + `TreeItem` + `FolderTreeItem` + `CloudProviderTreeItem` via `global.stubs`.
Tests fail initially because AppSidebar still has the old structure.
</action>
<verify>
<automated>cd frontend && npm run test -- --run AppSidebar.empty</automated>
Expected: 9 tests fail (RED).
</verify>
<acceptance_criteria>
- File contains exactly 9 `it(...)` tests across 3 describe blocks (UX-03, UX-01, UX-14)
- No `.todo` entries remain
- Tests stub child components via `global.stubs`
- All 9 tests are RED
</acceptance_criteria>
<done>9 RED tests describe the AppSidebar contract.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Edit AppSidebar.vue — remove UX-14 elements, add skeleton + EmptyState wiring</name>
<files>frontend/src/components/layout/AppSidebar.vue</files>
<read_first>
- frontend/src/components/layout/AppSidebar.vue (current state)
- frontend/src/components/layout/__tests__/AppSidebar.empty.test.js (failing tests)
- frontend/src/components/ui/EmptyState.vue (interface)
- .planning/phases/10-ux-interaction/10-PATTERNS.md §"AppSidebar.vue"
- .planning/phases/10-ux-interaction/10-RESEARCH.md §"Component Inventory §3" (sidebar micro EmptyState specs)
</read_first>
<behavior>
1. UX-14 removals (script and template):
- Remove `<button @click="startNewFolder">New</button>` near folder section header
- Remove the inline new-folder `<div v-if="showNewFolderInput">` block
- Remove the data properties `showNewFolderInput`, `newFolderName`, `newFolderError`
- Remove the methods `startNewFolder`, `cancelNewFolder`, `submitNewFolder`
- Remove any `nextTick(() => this.$refs.newFolderInputRef?.focus())` or related ref code in mounted/methods
2. Skeleton wiring (UX-03):
- Replace each `<div ... text-xs text-gray-400>Loading…</div>` placeholder with a `<div class="pl-7 py-1 space-y-1">` skeleton block containing 3 `<div v-for="n in 3">` rows (icon + text block, animate-pulse), per the PATTERNS template.
- Three locations: folders section, cloud section, topics section.
3. EmptyState wiring (UX-01 sidebar micro):
- Folders empty: replace `<div class="pl-7 py-1 text-xs text-gray-400">No folders yet</div>` with `<EmptyState size="sm" icon="folder" headline="Create a folder in the file manager" />`
- Topics empty: replace existing text with `<EmptyState size="sm" icon="tag" headline="No topics yet" />`
- Cloud empty: replace with:
```
<EmptyState size="sm" icon="cloud" headline="Connect in Settings">
<template #cta>
<router-link to="/settings" class="ml-1 text-indigo-600 hover:underline">Settings</router-link>
</template>
</EmptyState>
```
4. Register EmptyState as a component import: `import EmptyState from '../ui/EmptyState.vue'` + add to `components: { ... }` in the Options API export.
</behavior>
<action>
Edit `frontend/src/components/layout/AppSidebar.vue`:
**Step 1 — Imports:**
Add `import EmptyState from '../ui/EmptyState.vue'` near the existing imports. Add `EmptyState` to the `components: { ... }` registration object in the Options API `export default`.
**Step 2 — Remove UX-14 elements:**
- Delete the entire `<button @click="startNewFolder">...New...</button>` element (current ~lines 75-82)
- Delete the entire `<div v-if="showNewFolderInput">...</div>` inline new-folder block (current ~lines 87-98)
- In the `data()` return, remove the three properties: `showNewFolderInput: false`, `newFolderName: ''`, `newFolderError: ''`
- In the `methods: { ... }` object, remove `startNewFolder`, `cancelNewFolder`, `submitNewFolder`
- Remove the corresponding template `ref="newFolderInputRef"` if present
**Step 3 — Folder section update:**
- Replace the `<div v-if="loadingRoots" class="pl-7 py-1 text-xs text-gray-400">Loading…</div>` element with:
```
<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>
```
- Replace the `<div v-else-if="foldersStore.rootFolders.length === 0 && !showNewFolderInput" class="pl-7 py-1 text-xs text-gray-400">No folders yet</div>` with:
```
<EmptyState
v-else-if="foldersStore.rootFolders.length === 0"
size="sm"
icon="folder"
headline="Create a folder in the file manager"
class="pl-7"
/>
```
(Drop the `&& !showNewFolderInput` since the variable no longer exists.)
**Step 4 — Cloud section update:**
- Replace `<div v-if="loadingCloudConnections" class="pl-7 py-1 text-xs text-gray-400">Loading…</div>` with the same 3-row skeleton (with `key="sk-c-${n}"`).
- Replace `<div v-else-if="activeCloudConnections.length === 0" class="pl-7 py-1 text-xs text-gray-400">No cloud storage connected</div>` with:
```
<EmptyState
v-else-if="activeCloudConnections.length === 0"
size="sm"
icon="cloud"
headline="Connect in Settings"
class="pl-7"
>
<template #cta>
<router-link to="/settings" class="ml-1 text-indigo-600 hover:underline">Settings</router-link>
</template>
</EmptyState>
```
**Step 5 — Topics section update:**
- Replace `<div v-if="topicsStore.loading" class="px-3 py-1 text-xs text-gray-400">Loading…</div>` with a 3-row skeleton (with `key="sk-t-${n}"`), using `class="px-3 py-1 space-y-1"` for the outer wrapper to match the existing topics indent.
- Replace `<div v-else-if="topicsStore.topics.length === 0" class="px-3 py-1 text-xs text-gray-400">No topics yet</div>` with:
```
<EmptyState
v-else-if="topicsStore.topics.length === 0"
size="sm"
icon="tag"
headline="No topics yet"
class="px-3"
/>
```
No comments. Preserve all other functionality (Topics, Shared, Admin, Settings, sign-out, etc.) untouched.
</action>
<verify>
<automated>cd frontend && npm run test -- --run AppSidebar.empty</automated>
Expected: all 9 tests PASS.
</verify>
<acceptance_criteria>
- `grep -E "import EmptyState" frontend/src/components/layout/AppSidebar.vue` returns 1
- `grep -E "<EmptyState\\s+v-else-if" frontend/src/components/layout/AppSidebar.vue` returns 3
- `grep -E "size=\"sm\"" frontend/src/components/layout/AppSidebar.vue` returns ≥ 3
- `grep -E "icon=\"folder\"|icon=\"tag\"|icon=\"cloud\"" frontend/src/components/layout/AppSidebar.vue` returns 3 (one per section)
- `grep -v '^#' frontend/src/components/layout/AppSidebar.vue | grep -c "Loading…"` returns 0
- `grep -E "startNewFolder|cancelNewFolder|submitNewFolder" frontend/src/components/layout/AppSidebar.vue` returns 0
- `grep -E "showNewFolderInput|newFolderName|newFolderError" frontend/src/components/layout/AppSidebar.vue` returns 0
- `grep -E "animate-pulse" frontend/src/components/layout/AppSidebar.vue` returns ≥ 6 (3 sections × 2 elements per skeleton row × at least one row)
- `grep -v '^#' frontend/src/components/layout/AppSidebar.vue | grep -E '>\\s*New\\s*</button>'` returns 0 (no "New" button)
- StorageBrowser.vue invariant: `grep -E "function startNewFolder" frontend/src/components/storage/StorageBrowser.vue` still returns 1 (untouched)
- `cd frontend && npm run test -- --run AppSidebar.empty` exits 0
- Full sidebar tests pass: `cd frontend && npm run test -- --run AppSidebar` exits 0
</acceptance_criteria>
<done>AppSidebar updated; UX-03 + UX-01 (micro) + UX-14 all GREEN; StorageBrowser's own startNewFolder preserved.</done>
</task>
</tasks>
<verification>
- `cd frontend && npm run test -- --run AppSidebar.empty` exits 0
- StorageBrowser invariant intact: `grep -E "function startNewFolder" frontend/src/components/storage/StorageBrowser.vue` returns 1
- `grep -E "Loading…" frontend/src/components/layout/AppSidebar.vue` returns 0 (loading text replaced by skeletons)
- AppSidebar EmptyState count: `grep -c "<EmptyState" frontend/src/components/layout/AppSidebar.vue` returns 3
</verification>
<success_criteria>
The sidebar shows shimmering skeleton rows while loading folders/topics/cloud connections. When loading completes and any section has no items, a compact icon + label appears (with a Settings link for cloud). The inline "New folder" button is gone — folder creation is accessible only from the file manager toolbar, as required by UX-14.
</success_criteria>
<output>
Create `.planning/phases/10-ux-interaction/10-07-SUMMARY.md` when done.
</output>
@@ -0,0 +1,311 @@
---
phase: 10-ux-interaction
plan: 08
type: execute
wave: 1
depends_on: [10-02, 10-03, 10-05]
files_modified:
- frontend/src/views/admin/AdminAuditView.vue
- frontend/src/views/admin/AdminUsersView.vue
- frontend/src/views/admin/AdminQuotasView.vue
- frontend/src/views/admin/AdminAiView.vue
- frontend/src/views/admin/AdminOverviewView.vue
- frontend/src/views/SettingsView.vue
- frontend/src/views/SharedView.vue
- frontend/src/views/CloudStorageView.vue
- frontend/src/views/admin/__tests__/AdminAuditView.skeleton.test.js
- frontend/src/views/admin/__tests__/AdminUsersView.skeleton.test.js
autonomous: true
requirements: [UX-04, UX-01, UX-12]
must_haves:
truths:
- "AdminAuditView shows >=5 skeleton <tr> rows during loading"
- "AdminUsersView shows >=5 skeleton <tr> rows during loading"
- "AdminAuditView empty state uses EmptyState icon=clipboardList with a Clear filters CTA"
- "SharedView empty state uses EmptyState icon=inbox"
- "CloudStorageView empty state uses EmptyState icon=cloud with a Settings router-link CTA"
- "Each admin view + SettingsView renders a BreadcrumbBar with the static segments per the wiring table (D-13)"
artifacts:
- path: "frontend/src/views/admin/AdminAuditView.vue"
provides: "Audit view with skeleton + EmptyState + BreadcrumbBar"
- path: "frontend/src/views/admin/AdminUsersView.vue"
provides: "Users view with skeleton + BreadcrumbBar"
- path: "frontend/src/views/SettingsView.vue"
provides: "Settings view with BreadcrumbBar reflecting active tab"
key_links:
- from: "Each admin view"
to: "BreadcrumbBar.vue"
via: "static segments computed"
pattern: "<BreadcrumbBar"
---
<objective>
Wire UX-04 (skeleton table rows in admin tables), the remaining UX-01 (EmptyState in SharedView, CloudStorageView, AdminAuditView), and the admin/settings parts of UX-12 (BreadcrumbBar static segments) across the admin views, SettingsView, SharedView, and CloudStorageView.
Per D-13: each view computes its own segments array. Admin/settings/topics views use `showRoot=false` so they do NOT show a "Home" button.
Output:
- AdminUsersView, AdminAuditView, AdminQuotasView, AdminAiView, AdminOverviewView: each gains a `<BreadcrumbBar>` block at the top
- AdminUsersView + AdminAuditView: skeleton table rows replace loading spinner
- AdminAuditView empty state replaced with EmptyState
- SettingsView: BreadcrumbBar with `Settings > {activeTab}` static segments
- SharedView: EmptyState replaces inline empty div + BreadcrumbBar
- CloudStorageView: EmptyState replaces inline empty div with Settings #cta + BreadcrumbBar
- Two admin skeleton stub files promoted to real tests
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@CLAUDE.md
@.planning/phases/10-ux-interaction/10-CONTEXT.md
@.planning/phases/10-ux-interaction/10-RESEARCH.md
@.planning/phases/10-ux-interaction/10-PATTERNS.md
@frontend/src/views/admin/AdminAuditView.vue
@frontend/src/views/admin/AdminUsersView.vue
@frontend/src/views/admin/AdminQuotasView.vue
@frontend/src/views/admin/AdminAiView.vue
@frontend/src/views/admin/AdminOverviewView.vue
@frontend/src/views/SettingsView.vue
@frontend/src/views/SharedView.vue
@frontend/src/views/CloudStorageView.vue
@frontend/src/components/ui/BreadcrumbBar.vue
@frontend/src/components/ui/EmptyState.vue
<interfaces>
BreadcrumbBar per-view wiring table (from RESEARCH.md):
| View | segments | showRoot |
|------|----------|----------|
| AdminOverviewView | `[]` | false |
| AdminUsersView | `[{ label: 'Users' }]` | false |
| AdminQuotasView | `[{ label: 'Quotas' }]` | false |
| AdminAiView | `[{ label: 'AI Config' }]` | false |
| AdminAuditView | `[{ label: 'Audit Log' }]` | false |
| SettingsView | `[{ label: 'Settings' }, { label: activeTabLabel }]` | false |
| SharedView | `[{ label: 'Shared with me' }]` | false |
| CloudStorageView | `[{ label: 'Cloud Storage' }]` | false |
SettingsView active tab labels: map tab id to display label (existing tabs in SettingsView.vue - read the file to find the mapping; typical tabs: 'Account', 'Cloud Storage', 'Preferences').
Skeleton table row pattern (RESEARCH.md Pitfall 7 admin variant):
AdminAuditView (5 cols: Timestamp | User | Email | Action | IP) - 8 rows.
AdminUsersView (6 cols: Email | Handle | Role | Status | Created | Actions) - 5 rows.
Skeleton rows render inside the existing `<tbody>` when loading. The existing loading spinner block is replaced.
EmptyState wiring (RESEARCH.md Component Inventory #3):
- AdminAuditView empty entries: `<EmptyState icon="clipboardList" headline="No entries found" subtext="Try adjusting your filters or date range.">` with #cta button `Clear filters`
- SharedView empty shared docs: `<EmptyState icon="inbox" headline="Nothing shared with you yet" subtext="When someone shares a document with you, it will appear here." />`
- CloudStorageView empty connections: `<EmptyState icon="cloud" headline="No cloud storage connected" subtext="Connect Google Drive, OneDrive, Nextcloud, or a WebDAV server in Settings.">` with #cta router-link to /settings
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Promote AdminAuditView + AdminUsersView skeleton test stubs</name>
<files>frontend/src/views/admin/__tests__/AdminAuditView.skeleton.test.js, frontend/src/views/admin/__tests__/AdminUsersView.skeleton.test.js</files>
<read_first>
- The two stub files (Wave 0 outputs)
- frontend/src/views/admin/AdminAuditView.vue + AdminUsersView.vue (current state)
</read_first>
<behavior>
Replace the `.todo` entries with real assertions:
AdminAuditView.skeleton.test.js (5 tests):
1. `renders 8 skeleton <tr> rows when loading=true` - mount AdminAuditView with loading mocked to true, find `<tbody>` and assert findAll('tr').length >= 8
2. `skeleton rows have exactly 5 <td> cells matching column count` - assert each skeleton row has 5 `<td>` children
3. `Loading audit log text is absent when loading=true` - assert wrapper.text() does NOT include 'Loading audit log'
4. `renders <EmptyState icon="clipboardList" headline="No entries found"> when entries empty and not loading` - assert EmptyState stub is found with those props
5. `EmptyState includes a Clear filters CTA button` - assert the rendered EmptyState slot contains 'Clear filters'
AdminUsersView.skeleton.test.js (3 tests):
1. `renders 5 skeleton <tr> rows when loading=true` - assert >= 5
2. `skeleton rows have exactly 6 <td> cells` - assert each row has 6 cells
3. `Loading users text is absent when loading=true` - assert wrapper.text() excludes 'Loading users'
Use setActivePinia(createPinia()) and override store state. Stub child components via global.stubs: { BreadcrumbBar: true, EmptyState: true, AppIcon: true }.
</behavior>
<action>
Replace `it.todo(...)` entries in both files with the real tests defined above. Tests fail until Task 2-3 update the views.
</action>
<verify>
<automated>cd frontend && npm run test -- --run AdminAuditView.skeleton; cd frontend && npm run test -- --run AdminUsersView.skeleton</automated>
Expected: 5 + 3 = 8 tests RED.
</verify>
<acceptance_criteria>
- AdminAuditView.skeleton.test.js contains 5 real `it(...)` blocks (no `.todo`)
- AdminUsersView.skeleton.test.js contains 3 real `it(...)` blocks
- All 8 tests are RED before Task 2
</acceptance_criteria>
<done>8 RED skeleton tests in place.</done>
</task>
<task type="auto">
<name>Task 2: Update AdminAuditView.vue and AdminUsersView.vue</name>
<files>frontend/src/views/admin/AdminAuditView.vue, frontend/src/views/admin/AdminUsersView.vue</files>
<read_first>
- Both view files (current state - read in full to learn the loading block + table structure + filter state)
- The two failing test files from Task 1
- frontend/src/components/ui/BreadcrumbBar.vue
- frontend/src/components/ui/EmptyState.vue
- .planning/phases/10-ux-interaction/10-PATTERNS.md sections for AdminAuditView.vue and AdminUsersView.vue
</read_first>
<action>
AdminAuditView.vue changes:
1. Add imports: `import BreadcrumbBar from '../../components/ui/BreadcrumbBar.vue'` and `import EmptyState from '../../components/ui/EmptyState.vue'`. Register both in `components: { ... }` (Options API) or rely on import resolution (script setup).
2. At the very top of the `<template>` root (before any existing content), add:
`<BreadcrumbBar :segments="[{ label: 'Audit Log' }]" :show-root="false" class="mb-4" />`
3. Replace the existing loading block (the `<div v-if="loading">...<span class="animate-spin">...Loading audit log...</div>`) with skeleton table rows. The new structure: when `loading` is true, the `<tbody>` renders 8 skeleton rows. When `entries.length === 0` and not loading, render the `<EmptyState>` block. When entries exist, render the existing real rows. Concretely:
```
<tbody>
<template v-if="loading">
<tr v-for="n in 8" :key="`sk-${n}`" class="border-b border-gray-100">
<td class="px-4 py-3"><div class="h-3 bg-gray-100 rounded animate-pulse w-32"></div></td>
<td class="px-4 py-3"><div class="h-3 bg-gray-100 rounded animate-pulse w-20"></div></td>
<td class="px-4 py-3"><div class="h-3 bg-gray-100 rounded animate-pulse w-36"></div></td>
<td class="px-4 py-3"><div class="h-4 bg-gray-100 rounded-full animate-pulse w-16"></div></td>
<td class="px-4 py-3"><div class="h-3 bg-gray-100 rounded animate-pulse w-24"></div></td>
</tr>
</template>
<tr v-else-if="entries.length === 0">
<td colspan="5" class="px-0 py-0">
<EmptyState icon="clipboardList" headline="No entries found" subtext="Try adjusting your filters or date range.">
<template #cta>
<button @click="clearFilters" class="mt-3 text-sm text-indigo-600 hover:underline">Clear filters</button>
</template>
</EmptyState>
</td>
</tr>
<template v-else>
<!-- existing real rows preserved unchanged -->
</template>
</tbody>
```
Remove the outer loading `<div>` panel that contained Loading audit log spinner.
4. If `clearFilters` does not already exist as a function/method, add one that resets the filter state to defaults (read the filter refs/data and set them back to their initial values).
AdminUsersView.vue changes:
1. Add `import BreadcrumbBar from '../../components/ui/BreadcrumbBar.vue'`. Register if Options API.
2. Add `<BreadcrumbBar :segments="[{ label: 'Users' }]" :show-root="false" class="mb-4" />` at the top of the template root.
3. Replace the existing loading spinner block with skeleton table rows: 5 rows x 6 `<td>` cells per row, classes per <interfaces> section.
Preserve all other functionality (filters, action buttons, sort, pagination, modals).
</action>
<verify>
<automated>cd frontend && npm run test -- --run AdminAuditView.skeleton; cd frontend && npm run test -- --run AdminUsersView.skeleton</automated>
Expected: all 8 tests GREEN.
</verify>
<acceptance_criteria>
- `grep -E "BreadcrumbBar" frontend/src/views/admin/AdminAuditView.vue` returns >= 2 (import + usage)
- `grep -E "BreadcrumbBar" frontend/src/views/admin/AdminUsersView.vue` returns >= 2
- `grep -E "icon=\"clipboardList\"" frontend/src/views/admin/AdminAuditView.vue` returns 1
- `grep -E "animate-pulse" frontend/src/views/admin/AdminAuditView.vue` returns >= 5
- `grep -E "animate-pulse" frontend/src/views/admin/AdminUsersView.vue` returns >= 5
- `grep -v '^#' frontend/src/views/admin/AdminAuditView.vue | grep -c "Loading audit log"` returns 0
- `grep -v '^#' frontend/src/views/admin/AdminUsersView.vue | grep -c "Loading users"` returns 0
- Both skeleton tests pass
</acceptance_criteria>
<done>AuditView + UsersView updated; skeletons green; BreadcrumbBar in place.</done>
</task>
<task type="auto">
<name>Task 3: BreadcrumbBar in remaining admin views + SettingsView; EmptyState in SharedView + CloudStorageView</name>
<files>
frontend/src/views/admin/AdminQuotasView.vue,
frontend/src/views/admin/AdminAiView.vue,
frontend/src/views/admin/AdminOverviewView.vue,
frontend/src/views/SettingsView.vue,
frontend/src/views/SharedView.vue,
frontend/src/views/CloudStorageView.vue
</files>
<read_first>
- All six view files (current state)
- frontend/src/components/ui/BreadcrumbBar.vue + EmptyState.vue
- .planning/phases/10-ux-interaction/10-PATTERNS.md sections for SharedView and CloudStorageView (target EmptyState blocks)
</read_first>
<action>
AdminQuotasView.vue: Add `import BreadcrumbBar` (register if Options API). Add `<BreadcrumbBar :segments="[{ label: 'Quotas' }]" :show-root="false" class="mb-4" />` at top of template. No other changes.
AdminAiView.vue: Same pattern with segments `[{ label: 'AI Config' }]`.
AdminOverviewView.vue: Same pattern with `:segments="[]" :show-root="false"`. The empty segments block renders a `<nav>` with empty `<ol>` - acceptable. Alternatively keep the existing page heading and skip BreadcrumbBar if it would visually duplicate. Read the file: if there is already a clear `<h1>Admin Overview</h1>` heading, skip; otherwise add the BreadcrumbBar.
SettingsView.vue:
Read the file to find the `activeTab` state and its display labels. Add a computed `breadcrumbSegments` that returns `[{ label: 'Settings' }, { label: activeTabLabel }]` where `activeTabLabel` maps the current tab id to its user-facing label. Add `<BreadcrumbBar :segments="breadcrumbSegments" :show-root="false" class="mb-4" />` at the top of the template root.
SharedView.vue:
Add `import EmptyState from '../components/ui/EmptyState.vue'` and `import BreadcrumbBar from '../components/ui/BreadcrumbBar.vue'`. Register if Options API. At top of template add `<BreadcrumbBar :segments="[{ label: 'Shared with me' }]" :show-root="false" class="mb-4" />`. Replace the inline empty `<div v-else-if="sharedDocs.length === 0" class="text-center py-12 text-gray-400">...</div>` with:
```
<EmptyState
v-else-if="sharedDocs.length === 0"
icon="inbox"
headline="Nothing shared with you yet"
subtext="When someone shares a document with you, it will appear here."
/>
```
CloudStorageView.vue:
Add `import EmptyState` + `BreadcrumbBar`. Register if Options API. At top of template add `<BreadcrumbBar :segments="[{ label: 'Cloud Storage' }]" :show-root="false" class="mb-4" />`. Replace the inline empty div with:
```
<EmptyState
v-else-if="connections.length === 0"
icon="cloud"
headline="No cloud storage connected"
subtext="Connect Google Drive, OneDrive, Nextcloud, or a WebDAV server in Settings."
>
<template #cta>
<router-link to="/settings" class="mt-3 inline-block text-sm text-indigo-600 hover:underline">
Go to Settings
</router-link>
</template>
</EmptyState>
```
All six files: preserve existing functionality untouched aside from the additions above.
</action>
<verify>
<automated>cd frontend && npm run test -- --run</automated>
Expected: full suite passes; no regression in any existing test.
</verify>
<acceptance_criteria>
- `grep -E "<BreadcrumbBar" frontend/src/views/admin/AdminQuotasView.vue` returns 1
- `grep -E "<BreadcrumbBar" frontend/src/views/admin/AdminAiView.vue` returns 1
- `grep -E "<BreadcrumbBar" frontend/src/views/SettingsView.vue` returns 1
- `grep -E "breadcrumbSegments" frontend/src/views/SettingsView.vue` returns >= 2 (computed + binding)
- `grep -E "<BreadcrumbBar" frontend/src/views/SharedView.vue` returns 1
- `grep -E "<EmptyState\\s+v-else-if=\"sharedDocs" frontend/src/views/SharedView.vue` returns 1
- `grep -E "icon=\"inbox\"" frontend/src/views/SharedView.vue` returns 1
- `grep -E "<BreadcrumbBar" frontend/src/views/CloudStorageView.vue` returns 1
- `grep -E "<EmptyState\\s+v-else-if=\"connections" frontend/src/views/CloudStorageView.vue` returns 1
- `grep -E "icon=\"cloud\"" frontend/src/views/CloudStorageView.vue` returns 1
- `cd frontend && npm run test -- --run` exits 0 (no regression)
</acceptance_criteria>
<done>All 6 views wired with BreadcrumbBar + EmptyState; full test suite green.</done>
</task>
</tasks>
<verification>
- `cd frontend && npm run test -- --run AdminAuditView.skeleton` exits 0
- `cd frontend && npm run test -- --run AdminUsersView.skeleton` exits 0
- `cd frontend && npm run test -- --run` (full suite) exits 0
- Every admin view + SettingsView + SharedView + CloudStorageView contains exactly one `<BreadcrumbBar` invocation
- AdminAuditView contains both `<BreadcrumbBar` and `<EmptyState icon="clipboardList"`
- SharedView and CloudStorageView contain `<EmptyState`
- No "Loading audit log" / "Loading users" text remains in those views
</verification>
<success_criteria>
Every admin section + SettingsView shows a `Section name` breadcrumb at the top with no "Home" prefix. AdminAuditView + AdminUsersView display animated skeleton tables during load. AdminAuditView's "no entries" state renders the EmptyState with a Clear filters button. SharedView and CloudStorageView render proper EmptyState components with appropriate icons (inbox / cloud) and Settings link where applicable.
</success_criteria>
<output>
Create `.planning/phases/10-ux-interaction/10-08-SUMMARY.md` when done.
</output>
@@ -0,0 +1,342 @@
---
phase: 10-ux-interaction
plan: 09
type: execute
wave: 2
depends_on: [10-04, 10-06, 10-05]
files_modified:
- frontend/src/App.vue
- frontend/src/views/FileManagerView.vue
- frontend/src/components/storage/StorageBrowser.vue
- frontend/src/components/upload/DropZone.vue
- frontend/src/components/documents/SearchBar.vue
- frontend/src/__tests__/keyboard.test.js
autonomous: true
requirements: [UX-05, UX-06, UX-07, UX-08]
must_haves:
truths:
- "Pressing `/` (when no input focused) calls focus() on the SearchBar input via App.vue routeViewRef chain"
- "Pressing Escape (when no input focused) clears the active search query"
- "Pressing U (when no input focused) triggers DropZone.triggerInput via the ref chain"
- "Pressing N (when no input focused) starts the new-folder inline input in StorageBrowser"
- "The global keydown handler guards against active INPUT/TEXTAREA/SELECT/contenteditable elements and returns early"
- "DropZone.triggerInput is exposed via defineExpose"
- "SearchBar.focus is exposed via defineExpose"
- "StorageBrowser exposes triggerUpload, focusSearch, clearSearch alongside its existing startNewFolder"
- "FileManagerView exposes focusSearch, triggerUpload, startNewFolder, clearSearch via defineExpose"
artifacts:
- path: "frontend/src/App.vue"
provides: "Global keydown handler + routeViewRef"
- path: "frontend/src/views/FileManagerView.vue"
provides: "defineExpose of focusSearch/triggerUpload/startNewFolder/clearSearch"
- path: "frontend/src/components/storage/StorageBrowser.vue"
provides: "Updated defineExpose with triggerUpload + focusSearch + clearSearch"
- path: "frontend/src/components/upload/DropZone.vue"
provides: "defineExpose of triggerInput"
- path: "frontend/src/components/documents/SearchBar.vue"
provides: "defineExpose of focus()"
key_links:
- from: "App.vue keydown handler"
to: "routeViewRef.value methods"
via: "optional chaining (?.)"
pattern: "routeViewRef\\.value\\?\\."
- from: "StorageBrowser.vue"
to: "DropZone.vue"
via: "dropZoneRef.value?.triggerInput()"
pattern: "dropZoneRef\\.value"
- from: "StorageBrowser.vue"
to: "SearchBar.vue"
via: "searchBarRef.value?.focus()"
pattern: "searchBarRef\\.value"
---
<objective>
Implement the four global keyboard shortcuts (`/`, `Escape`, `U`, `N`) per D-14, D-15. The handler lives in `App.vue` (already `<script setup>`) and delegates to the active route component through a chain of `ref` + `defineExpose` calls:
App.vue routeViewRef -> FileManagerView (focusSearch/triggerUpload/startNewFolder/clearSearch) -> StorageBrowser (focusSearch/triggerUpload/clearSearch + existing startNewFolder) -> DropZone (triggerInput) / SearchBar (focus).
Use optional chaining at every hop so views that do not expose a method silently no-op (per D-15 and Open Question 2 in RESEARCH.md).
Output:
- DropZone exposes triggerInput
- SearchBar exposes focus()
- StorageBrowser exposes triggerUpload + focusSearch + clearSearch
- FileManagerView exposes all four route-level methods
- App.vue gains routeViewRef + onKeydown listener
- keyboard.test.js stubs promoted to real tests
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@CLAUDE.md
@.planning/phases/10-ux-interaction/10-CONTEXT.md
@.planning/phases/10-ux-interaction/10-RESEARCH.md
@.planning/phases/10-ux-interaction/10-PATTERNS.md
@frontend/src/App.vue
@frontend/src/views/FileManagerView.vue
@frontend/src/components/storage/StorageBrowser.vue
@frontend/src/components/upload/DropZone.vue
@frontend/src/components/documents/SearchBar.vue
@frontend/src/components/documents/DocumentPreviewModal.vue
<interfaces>
App.vue keydown handler signature (Composition API, from RESEARCH.md §Code Examples):
```js
import { ref, onMounted, onUnmounted } from 'vue'
const routeViewRef = ref(null)
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))
```
Template: change `<router-view />` to `<router-view ref="routeViewRef" />`.
Ref chain plumbing (PATTERNS.md):
- SearchBar.vue: add `const inputEl = ref(null)`; add `ref="inputEl"` to the search `<input>`; add `defineExpose({ focus() { inputEl.value?.focus() } })`.
- DropZone.vue: add `defineExpose({ triggerInput })` (the function already exists).
- StorageBrowser.vue: add `const dropZoneRef = ref(null)` and `const searchBarRef = ref(null)`; add `ref="dropZoneRef"` on `<DropZone>` and `ref="searchBarRef"` on `<SearchBar>`; extend `defineExpose` to `{ startNewFolder, triggerUpload, focusSearch, clearSearch }` where:
- `triggerUpload() { dropZoneRef.value?.triggerInput() }`
- `focusSearch() { searchBarRef.value?.focus() }`
- `clearSearch() { emit('search-change', '') }` (emit, since searchQuery is a prop)
- FileManagerView.vue: add `defineExpose({ focusSearch: () => browserRef.value?.focusSearch?.(), triggerUpload: () => browserRef.value?.triggerUpload?.(), startNewFolder: () => browserRef.value?.startNewFolder?.(), clearSearch: () => browserRef.value?.clearSearch?.() })`
DocumentPreviewModal already owns its own Escape handler (PITFALL 4) - leave it alone. The App.vue Escape branch only calls clearSearch which is a no-op when no FileManagerView is mounted.
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Promote keyboard.test.js stubs to real tests</name>
<files>frontend/src/__tests__/keyboard.test.js</files>
<read_first>
- frontend/src/__tests__/keyboard.test.js (Wave 0 stub)
- frontend/src/App.vue (current state)
- frontend/src/views/FileManagerView.vue (current state)
</read_first>
<behavior>
Replace .todo entries with real assertions. Strategy: test the App.vue keydown handler in isolation by mounting App with a stubbed router-view that has a known shape (spy methods for focusSearch / clearSearch / triggerUpload / startNewFolder). Dispatch keyboard events on document and assert the spies were called or not called based on the guard.
UX-05 (3 tests):
1. `keydown "/" calls focusSearch on routeViewRef` - mount App with a stub router-view exposing a focusSearch spy as defineExpose; dispatch keydown "/", assert spy called once.
2. `keydown "/" does NOT call focusSearch when an INPUT is focused` - create an <input>, focus it, dispatch keydown "/", assert spy NOT called.
3. `keydown "/" calls preventDefault` - capture the event; assert defaultPrevented=true after dispatch when no input focused.
UX-06 (2 tests):
4. `keydown Escape calls clearSearch on routeViewRef` - dispatch Escape, assert spy called.
5. `keydown Escape does NOT call clearSearch when an INPUT is focused` - assert NOT called.
UX-07 (2 tests):
6. `keydown "u" calls triggerUpload on routeViewRef` - dispatch "u", assert spy called.
7. `keydown "U" (uppercase) also calls triggerUpload` - assert spy called when Shift+u dispatches "U".
UX-08 (2 tests):
8. `keydown "n" calls startNewFolder on routeViewRef` - assert spy called.
9. `keydown "n" does NOT call startNewFolder when CloudFolderView is the route` - mount App with a stub router-view that does NOT expose startNewFolder; assert no error thrown (optional chaining silently no-ops).
Use `vi.fn()` for spies. For "stub router-view": pass a custom component as the router-view stub via global.stubs or replace `<router-view>` with a test component. Use happy-dom default Vitest env (already in project).
Note: testing App.vue's keydown listener directly may require mounting App.vue with a mock router. A simpler approach: extract the onKeydown logic into a test helper inside App.vue (export it), OR test via component instance access. Use whichever approach works with @vue/test-utils. If mounting App.vue is too complex, write a focused unit test that imports a small reusable handler.
Alternative simpler approach (recommended): write tests that mount FileManagerView with stubbed StorageBrowser, then call `wrapper.vm.focusSearch()` / `wrapper.vm.triggerUpload()` directly to verify the defineExpose surface delegates to browserRef. This still validates the contract App.vue depends on.
Use either approach. The 9 tests above cover the App.vue handler contract.
</behavior>
<action>
Modify `frontend/src/__tests__/keyboard.test.js`. Replace each .todo with a real `it(...)` block per the behavior list above. Choose ONE testing strategy (full App.vue mount OR FileManagerView defineExpose direct invocation OR a hybrid). Aim for 9 real tests covering the four shortcuts plus their guards.
Tests fail initially because no ref chain or App.vue handler exists yet.
</action>
<verify>
<automated>cd frontend && npm run test -- --run keyboard</automated>
Expected: 9 tests RED.
</verify>
<acceptance_criteria>
- File contains 9 real `it(...)` tests across 4 describe blocks (UX-05 to UX-08)
- No `.todo` entries remain
- Tests use `vi.fn()` for spies and dispatch real KeyboardEvent objects
- All 9 tests are RED before Task 2
</acceptance_criteria>
<done>9 RED keyboard shortcut tests in place.</done>
</task>
<task type="auto">
<name>Task 2: Plumb the ref chain - DropZone + SearchBar + StorageBrowser + FileManagerView</name>
<files>
frontend/src/components/upload/DropZone.vue,
frontend/src/components/documents/SearchBar.vue,
frontend/src/components/storage/StorageBrowser.vue,
frontend/src/views/FileManagerView.vue
</files>
<read_first>
- frontend/src/components/upload/DropZone.vue (verify triggerInput already exists; no defineExpose currently)
- frontend/src/components/documents/SearchBar.vue (current state - check for <input> structure and existing exposes)
- frontend/src/components/storage/StorageBrowser.vue (current defineExpose at line 327 - has only startNewFolder)
- frontend/src/views/FileManagerView.vue (browserRef at line 61 - no defineExpose yet)
- .planning/phases/10-ux-interaction/10-PATTERNS.md (DropZone / SearchBar / StorageBrowser / FileManagerView sections)
</read_first>
<action>
Step A - DropZone.vue:
Add `defineExpose({ triggerInput })` after the `triggerInput` function definition (around line 49). The function already exists - this only exposes it.
Step B - SearchBar.vue:
Modify the component so the search `<input>` element has `ref="inputEl"`. In the script (add `import { ref } from 'vue'` if not already imported), declare `const inputEl = ref(null)`. Add `defineExpose({ focus() { inputEl.value?.focus() } })`.
Step C - StorageBrowser.vue:
1. Declare two new refs: `const dropZoneRef = ref(null)` and `const searchBarRef = ref(null)` (near the existing `newFolderInputRef` declaration around line 306).
2. Add `ref="dropZoneRef"` to the `<DropZone>` element (around line 38).
3. Add `ref="searchBarRef"` to the `<SearchBar>` element (around line 12).
4. Replace the existing `defineExpose({ startNewFolder })` at line 327 with:
```js
defineExpose({
startNewFolder,
triggerUpload: () => dropZoneRef.value?.triggerInput(),
focusSearch: () => searchBarRef.value?.focus(),
clearSearch: () => emit('search-change', ''),
})
```
Step D - FileManagerView.vue:
Add a `defineExpose(...)` block after the existing function definitions:
```js
defineExpose({
focusSearch: () => browserRef.value?.focusSearch?.(),
triggerUpload: () => browserRef.value?.triggerUpload?.(),
startNewFolder: () => browserRef.value?.startNewFolder?.(),
clearSearch: () => browserRef.value?.clearSearch?.(),
})
```
Keep all existing logic untouched.
</action>
<verify>
<automated>cd frontend && npm run test -- --run FileManagerView; cd frontend && npm run test -- --run StorageBrowser</automated>
Expected: existing FileManagerView + StorageBrowser tests still pass (no regression). The keyboard.test.js still fails (because App.vue handler not added yet).
</verify>
<acceptance_criteria>
- `grep -E "defineExpose\\(\\{ triggerInput \\}\\)" frontend/src/components/upload/DropZone.vue` returns 1
- `grep -E "defineExpose\\(\\{ focus" frontend/src/components/documents/SearchBar.vue` returns 1
- `grep -E "const inputEl = ref" frontend/src/components/documents/SearchBar.vue` returns 1
- `grep -E "ref=\"inputEl\"" frontend/src/components/documents/SearchBar.vue` returns 1
- `grep -E "const dropZoneRef\\s*=\\s*ref" frontend/src/components/storage/StorageBrowser.vue` returns 1
- `grep -E "const searchBarRef\\s*=\\s*ref" frontend/src/components/storage/StorageBrowser.vue` returns 1
- `grep -E "triggerUpload:\\s*\\(\\)\\s*=>\\s*dropZoneRef\\.value" frontend/src/components/storage/StorageBrowser.vue` returns 1
- `grep -E "focusSearch:\\s*\\(\\)\\s*=>\\s*searchBarRef\\.value" frontend/src/components/storage/StorageBrowser.vue` returns 1
- `grep -E "clearSearch:\\s*\\(\\)\\s*=>\\s*emit\\('search-change',\\s*''\\)" frontend/src/components/storage/StorageBrowser.vue` returns 1
- `grep -E "defineExpose" frontend/src/views/FileManagerView.vue` returns 1
- `grep -E "browserRef\\.value\\?\\.focusSearch" frontend/src/views/FileManagerView.vue` returns 1
- `grep -E "browserRef\\.value\\?\\.triggerUpload" frontend/src/views/FileManagerView.vue` returns 1
- `grep -E "browserRef\\.value\\?\\.startNewFolder" frontend/src/views/FileManagerView.vue` returns 1
- `grep -E "browserRef\\.value\\?\\.clearSearch" frontend/src/views/FileManagerView.vue` returns 1
- Existing FileManagerView + StorageBrowser test suites still pass
</acceptance_criteria>
<done>Ref chain fully plumbed from DropZone/SearchBar up to FileManagerView.</done>
</task>
<task type="auto">
<name>Task 3: Add global keydown handler to App.vue + routeViewRef</name>
<files>frontend/src/App.vue</files>
<read_first>
- frontend/src/App.vue (current state - uses <script setup>)
- frontend/src/__tests__/keyboard.test.js (failing tests)
- frontend/src/components/documents/DocumentPreviewModal.vue (Pitfall 4 reference - has its own Escape handler)
- .planning/phases/10-ux-interaction/10-PATTERNS.md (App.vue keydown handler section)
</read_first>
<action>
Edit `frontend/src/App.vue`:
Template change: replace `<router-view />` with `<router-view ref="routeViewRef" />`.
Script changes (inside the existing `<script setup>`):
1. Extend the import line `import { onMounted } from 'vue'` to also import `ref` and `onUnmounted`: `import { ref, onMounted, onUnmounted } from 'vue'`.
2. Add `const routeViewRef = ref(null)`.
3. Add the keydown handler:
```js
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))
```
Keep the existing `topicsStore.fetchTopics()` call inside `onMounted` intact - either chain it into the same onMounted callback or use two separate onMounted calls.
No comments. Do not touch any other part of App.vue.
</action>
<verify>
<automated>cd frontend && npm run test -- --run keyboard</automated>
Expected: 9 tests GREEN.
</verify>
<acceptance_criteria>
- `grep -E "ref=\"routeViewRef\"" frontend/src/App.vue` returns 1
- `grep -E "const routeViewRef = ref\\(null\\)" frontend/src/App.vue` returns 1
- `grep -E "function onKeydown" frontend/src/App.vue` returns 1
- `grep -E "document\\.activeElement\\?\\.tagName" frontend/src/App.vue` returns 1
- `grep -E "isContentEditable" frontend/src/App.vue` returns 1
- `grep -E "e\\.preventDefault\\(\\)" frontend/src/App.vue` returns 1 (inside the / branch)
- `grep -E "addEventListener\\('keydown'" frontend/src/App.vue` returns 1
- `grep -E "removeEventListener\\('keydown'" frontend/src/App.vue` returns 1
- `cd frontend && npm run test -- --run keyboard` exits 0 with 9 passing tests
- `cd frontend && npm run test -- --run` (full) exits 0 with no regression
</acceptance_criteria>
<done>App.vue global keydown handler live; 9 keyboard tests GREEN; full suite still green.</done>
</task>
</tasks>
<verification>
- `cd frontend && npm run test -- --run keyboard` exits 0 with 9 tests passing
- `cd frontend && npm run test -- --run FileManagerView` exits 0
- `cd frontend && npm run test -- --run StorageBrowser` exits 0 (regression)
- `cd frontend && npm run test -- --run` (full suite) exits 0
- App.vue contains routeViewRef + onKeydown + document.addEventListener('keydown', ...) + cleanup
- All four shortcut branches present (`/`, Escape, U, N)
</verification>
<success_criteria>
With a fresh browser tab on `/`, pressing `/` jumps focus into the search bar. Typing in the search bar and pressing Escape clears it. Pressing `U` opens the OS file picker. Pressing `N` opens the inline new-folder input. None of these fire while typing into a focused input. On admin and settings routes, all four keys silently no-op because those views do not expose the corresponding methods.
</success_criteria>
<output>
Create `.planning/phases/10-ux-interaction/10-09-SUMMARY.md` when done.
</output>
@@ -0,0 +1,255 @@
---
phase: 10-ux-interaction
plan: 10
type: execute
wave: 3
depends_on: [10-04, 10-09, 10-05, 10-06]
files_modified:
- frontend/src/components/layout/OsDragOverlay.vue
- frontend/src/App.vue
- frontend/src/views/FileManagerView.vue
- frontend/src/components/layout/__tests__/OsDragOverlay.test.js
autonomous: true
requirements: [UX-09]
must_haves:
truths:
- "Dragging files from the OS over the browser window shows the full-screen drop overlay"
- "An in-app element drag (no Files type in dataTransfer.types) does NOT show the overlay"
- "Releasing files over the window calls FileManagerView.handleOsDrop(files) which uploads them via the existing flow"
- "The overlay disappears on drop and on the depth counter reaching 0 via dragleave"
- "Overlay z-index (z-[9998]) is below ToastContainer (z-[9999]) so toasts remain visible during drop"
artifacts:
- path: "frontend/src/components/layout/OsDragOverlay.vue"
provides: "Full-screen OS file drag overlay with depth-counter pattern"
- path: "frontend/src/App.vue"
provides: "Updated to mount OsDragOverlay and wire @files-dropped to active route view"
key_links:
- from: "OsDragOverlay.vue"
to: "window dragenter/dragleave/dragover/drop events"
via: "mounted() addEventListener + beforeUnmount() removeEventListener"
pattern: "window\\.addEventListener\\('drag"
- from: "App.vue"
to: "FileManagerView.handleOsDrop"
via: "@files-dropped handler -> routeViewRef.handleOsDrop"
pattern: "routeViewRef\\.value\\?\\.handleOsDrop"
---
<objective>
Implement UX-09 (OS file drag onto browser window -> full-screen overlay -> upload). The overlay uses the depth-counter pattern from D-16 (Pitfall 3) to prevent flicker as the cursor moves between child elements, and only activates when the dragged item has `dataTransfer.types.includes('Files')` (which is true only for OS-origin drags).
Output:
- New component `frontend/src/components/layout/OsDragOverlay.vue` (Options API; Teleport to body; window-level event listeners; emits `files-dropped`)
- App.vue mounts `<OsDragOverlay @files-dropped="..." />` and routes to the active view via `routeViewRef.value?.handleOsDrop?.(files)`
- FileManagerView exposes `handleOsDrop(files)` via `defineExpose` that calls the existing `onFilesSelected({files, autoClassify: true})`
- OsDragOverlay test stubs promoted to real tests
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@CLAUDE.md
@.planning/phases/10-ux-interaction/10-CONTEXT.md
@.planning/phases/10-ux-interaction/10-RESEARCH.md
@.planning/phases/10-ux-interaction/10-PATTERNS.md
@frontend/src/App.vue
@frontend/src/views/FileManagerView.vue
@frontend/src/components/documents/DocumentPreviewModal.vue
@frontend/src/components/ui/AppIcon.vue
@frontend/src/components/upload/DropZone.vue
<interfaces>
OsDragOverlay.vue (Options API) - from PATTERNS.md §"OsDragOverlay.vue":
Data:
- `dragDepth: 0` (counter)
- `showOverlay: false`
Methods:
- `onDragEnter(e)` -> ignore unless `e.dataTransfer?.types.includes('Files')`; increment dragDepth; set showOverlay=true
- `onDragLeave()` -> dragDepth = max(0, dragDepth - 1); if dragDepth === 0 set showOverlay=false
- `onDragOver(e)` -> e.preventDefault() (required to allow drop event)
- `onDrop(e)` -> e.preventDefault(); reset dragDepth=0, showOverlay=false; emit `files-dropped` with `Array.from(e.dataTransfer.files)` (when files.length > 0)
mounted(): addEventListener for dragenter/dragleave/dragover/drop on `window`.
beforeUnmount(): remove all four listeners.
Template (Teleport to body):
```
<Teleport to="body">
<Transition name="fade">
<div
v-if="showOverlay"
class="fixed inset-0 z-[9998] bg-indigo-900/40 flex items-center justify-center pointer-events-none"
data-test="os-drag-overlay"
>
<div class="bg-white rounded-2xl px-10 py-8 text-center shadow-xl pointer-events-none">
<AppIcon name="upload" class="w-10 h-10 text-indigo-400 mx-auto mb-3" />
<p class="text-base font-semibold text-gray-800">Drop files to upload</p>
</div>
</div>
</Transition>
</Teleport>
```
Plus a `<style scoped>` block with `.fade-enter-active, .fade-leave-active { transition: opacity 0.15s ease } .fade-enter-from, .fade-leave-to { opacity: 0 }`.
App.vue wiring:
- Import `OsDragOverlay from './components/layout/OsDragOverlay.vue'`
- Add `<OsDragOverlay @files-dropped="onOsFilesDropped" />` to the template (after the ToastContainer mount from 10-04)
- Add handler:
```js
function onOsFilesDropped(files) {
routeViewRef.value?.handleOsDrop?.(files)
}
```
FileManagerView.vue:
- Extend `defineExpose` to also include `handleOsDrop: (files) => onFilesSelected({ files, autoClassify: true })`. Reuse the existing onFilesSelected function so the upload path, quota handling, and toast wiring (10-06) work identically to a DropZone drop.
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Promote OsDragOverlay.test.js stubs to real tests</name>
<files>frontend/src/components/layout/__tests__/OsDragOverlay.test.js</files>
<read_first>
- The Wave 0 stub file
- .planning/phases/10-ux-interaction/10-PATTERNS.md §"OsDragOverlay.vue"
</read_first>
<behavior>
Replace `.todo` entries with real tests:
1. `overlay hidden by default (dragDepth=0)` - mount, assert wrapper does not contain the overlay element (or assert it's not visible). Use a Teleport stub.
2. `dragenter with Files type shows overlay (dragDepth=1)` - mount, dispatch a window dragenter event with a synthetic dataTransfer carrying `types: ['Files']`; assert showOverlay=true via vm or DOM.
3. `dragenter without Files type is ignored` - dispatch with `types: ['text/plain']`; assert showOverlay still false.
4. `dragleave decrements depth; overlay hides when depth reaches 0` - enter once (depth=1), leave (depth=0), assert hidden.
5. `nested dragenter+dragleave maintains overlay until depth=0` - dispatch enter twice (depth=2), leave once (depth=1, still showing), leave again (depth=0, hidden).
6. `drop emits files-dropped with the file list` - dispatch a window drop with synthetic dataTransfer.files=[new File(['x'], 'a.txt')]; assert emitted('files-dropped')[0][0] is an array containing one File.
7. `drop resets dragDepth to 0 and hides overlay` - enter 3x (depth=3), drop, assert showOverlay=false and subsequent leave doesn't go negative.
8. `overlay element has class z-[9998]` - enter once, find the overlay element in body, assert classList contains 'z-[9998]'.
Use `vi.fn()`, dispatch `new DragEvent(...)` or `new CustomEvent('dragenter', { ... })` and patch a fake `dataTransfer` on the event by `Object.defineProperty(event, 'dataTransfer', { value: { types: ['Files'], files: [...] } })`. happy-dom supports these.
Tests fail until Task 2 creates OsDragOverlay.vue.
</behavior>
<action>
Modify `frontend/src/components/layout/__tests__/OsDragOverlay.test.js`. Replace each `it.todo` with the real tests above. Import `OsDragOverlay` from `../OsDragOverlay.vue` (file doesn't exist yet -> tests fail on import). Use Vitest + @vue/test-utils + happy-dom defaults.
</action>
<verify>
<automated>cd frontend && npm run test -- --run OsDragOverlay</automated>
Expected: 8 tests RED.
</verify>
<acceptance_criteria>
- File has 8 real `it(...)` blocks, no `.todo`
- Tests dispatch DragEvent / CustomEvent with synthetic dataTransfer
- All 8 tests are RED
</acceptance_criteria>
<done>8 RED tests describing OsDragOverlay contract.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Implement OsDragOverlay.vue</name>
<files>frontend/src/components/layout/OsDragOverlay.vue</files>
<read_first>
- frontend/src/components/layout/__tests__/OsDragOverlay.test.js (failing tests)
- frontend/src/components/documents/DocumentPreviewModal.vue (window listener pattern reference)
- .planning/phases/10-ux-interaction/10-PATTERNS.md §"OsDragOverlay.vue"
- frontend/src/components/ui/AppIcon.vue
</read_first>
<behavior>
Component name `OsDragOverlay`, Options API, emits `['files-dropped']`. Registers `AppIcon` as child component. Data + methods per the <interfaces> block. Template uses `<Teleport to="body">` + `<Transition name="fade">` and renders the overlay only when `showOverlay`. Includes `<style scoped>` block defining `.fade-enter-active`/`.fade-leave-active`/`.fade-enter-from`/`.fade-leave-to` transitions.
The overlay element has `class="fixed inset-0 z-[9998] bg-indigo-900/40 flex items-center justify-center pointer-events-none"` and `data-test="os-drag-overlay"`.
</behavior>
<action>
Create `frontend/src/components/layout/OsDragOverlay.vue` using the exact Options API structure from PATTERNS.md §"OsDragOverlay.vue". Use the template + style above. No comments inside the file. Add `data-test="os-drag-overlay"` for testability.
</action>
<verify>
<automated>cd frontend && npm run test -- --run OsDragOverlay</automated>
Expected: 8 tests GREEN.
</verify>
<acceptance_criteria>
- File `frontend/src/components/layout/OsDragOverlay.vue` exists
- File contains `name: 'OsDragOverlay'`
- File contains `emits: ['files-dropped']`
- File contains `dragDepth: 0` in data
- File contains `dataTransfer?.types.includes('Files')` guard
- File contains all four window listeners (dragenter, dragleave, dragover, drop)
- File contains `<Teleport to="body">`
- File contains class string with `z-[9998]`
- File uses Options API (no `<script setup>`)
- 8 tests pass
</acceptance_criteria>
<done>OsDragOverlay implemented; 8 tests green.</done>
</task>
<task type="auto">
<name>Task 3: Mount OsDragOverlay in App.vue; expose handleOsDrop in FileManagerView</name>
<files>frontend/src/App.vue, frontend/src/views/FileManagerView.vue</files>
<read_first>
- frontend/src/App.vue (current state after 10-04 + 10-09)
- frontend/src/views/FileManagerView.vue (current state after 10-06 + 10-09)
- frontend/src/components/layout/OsDragOverlay.vue (newly created)
</read_first>
<action>
Step A - App.vue:
1. Add `import OsDragOverlay from './components/layout/OsDragOverlay.vue'` to script imports.
2. Add `<OsDragOverlay @files-dropped="onOsFilesDropped" />` to the template (place it after `<ToastContainer />` from 10-04 so the overlay z-[9998] is below the toast z-[9999]).
3. Add handler in script:
```js
function onOsFilesDropped(files) {
routeViewRef.value?.handleOsDrop?.(files)
}
```
Step B - FileManagerView.vue:
Extend the existing `defineExpose` block (added in 10-09) to also include `handleOsDrop`:
```js
defineExpose({
focusSearch: () => browserRef.value?.focusSearch?.(),
triggerUpload: () => browserRef.value?.triggerUpload?.(),
startNewFolder: () => browserRef.value?.startNewFolder?.(),
clearSearch: () => browserRef.value?.clearSearch?.(),
handleOsDrop: (files) => onFilesSelected({ files, autoClassify: true }),
})
```
Do not modify any other logic. The existing onFilesSelected handles the upload + per-file UploadProgress + toast wiring (from 10-06) end to end.
</action>
<verify>
<automated>cd frontend && npm run test -- --run OsDragOverlay; cd frontend && npm run test -- --run FileManagerView; cd frontend && npm run test -- --run keyboard; cd frontend && npm run test -- --run toast</automated>
Expected: all suites pass; no regression.
</verify>
<acceptance_criteria>
- `grep -E "import OsDragOverlay" frontend/src/App.vue` returns 1
- `grep -E "<OsDragOverlay" frontend/src/App.vue` returns 1
- `grep -E "onOsFilesDropped" frontend/src/App.vue` returns 2 (handler + binding)
- `grep -E "routeViewRef\\.value\\?\\.handleOsDrop" frontend/src/App.vue` returns 1
- `grep -E "handleOsDrop:\\s*\\(files\\)" frontend/src/views/FileManagerView.vue` returns 1
- `grep -E "onFilesSelected\\(\\{\\s*files,\\s*autoClassify:\\s*true\\s*\\}\\)" frontend/src/views/FileManagerView.vue` returns 1
- OsDragOverlay placed AFTER ToastContainer in App.vue template (toast z-[9999] dominates) - inspect via `grep -n` ordering
- All test suites pass
</acceptance_criteria>
<done>OsDragOverlay mounted in App.vue; FileManagerView exposes handleOsDrop; UX-09 fully wired.</done>
</task>
</tasks>
<verification>
- `cd frontend && npm run test -- --run OsDragOverlay` exits 0 with 8 tests passing
- `cd frontend && npm run test -- --run` (full) exits 0
- `grep -n "ToastContainer\\|OsDragOverlay" frontend/src/App.vue` shows OsDragOverlay AFTER ToastContainer (or below it in source order)
- OsDragOverlay.vue uses z-[9998] (below ToastContainer z-[9999])
- FileManagerView.handleOsDrop reuses onFilesSelected (no duplicate upload logic)
</verification>
<success_criteria>
Dragging one or more files from the OS file explorer over the browser window shows an indigo overlay with an upload icon and "Drop files to upload" prompt. Releasing the files uploads them via the existing FileManagerView upload flow (with per-file progress in UploadProgress and a summary toast). The overlay does not appear when dragging an in-app element (file row, folder row). The overlay does not appear when on non-file-manager routes (admin, settings) because routeViewRef does not expose handleOsDrop there.
</success_criteria>
<output>
Create `.planning/phases/10-ux-interaction/10-10-SUMMARY.md` when done.
</output>
@@ -0,0 +1,380 @@
---
phase: 10-ux-interaction
plan: 11
type: execute
wave: 4
depends_on: [10-06, 10-09, 10-10, 10-05]
files_modified:
- frontend/src/components/storage/StorageBrowser.vue
- frontend/src/components/documents/DocumentCard.vue
- frontend/src/components/folders/FolderRow.vue
- frontend/src/components/storage/__tests__/StorageBrowser.dragmove.test.js
- frontend/src/components/ui/__tests__/dropdown.test.js
autonomous: true
requirements: [UX-11, UX-13]
must_haves:
truths:
- "Drag-to-move document onto a folder applies the ring-2 ring-inset ring-amber-300 highlight while dragging (already wired in StorageBrowser - this plan verifies + adds click guard)"
- "Dropping a document on a folder emits file-move which fires a success toast via the FileManagerView.doMove chain (toast already added in 10-06)"
- "File-row click is suppressed when draggingFile is non-null (Pitfall 2 - prevents click-after-drag navigation)"
- "StorageBrowser folder picker dropdown is teleported to body with getBoundingClientRect positioning"
- "DocumentCard folder picker dropdown is teleported to body with getBoundingClientRect positioning"
- "FolderRow three-dot menu is teleported to body with getBoundingClientRect positioning"
- "All three teleported dropdowns reposition on window scroll"
artifacts:
- path: "frontend/src/components/storage/StorageBrowser.vue"
provides: "Updated with click-guard for drag + Teleport-based folder picker"
- path: "frontend/src/components/documents/DocumentCard.vue"
provides: "Folder picker teleported"
- path: "frontend/src/components/folders/FolderRow.vue"
provides: "Three-dot menu teleported"
key_links:
- from: "StorageBrowser.vue file row @click"
to: "draggingFile guard"
via: "v-if/v-else or inline check"
pattern: "draggingFile.*null"
---
<objective>
Complete the drag-to-move flow (UX-11) and fix the three dropdown clipping risks (UX-13). The drag-to-move highlight + drop handlers are already in StorageBrowser.vue; the missing pieces are the click-after-drag guard (Pitfall 2) and verification of the toast emit (toast was wired in 10-06).
The dropdown fix uses the SearchableModelSelect.vue pattern: `<Teleport to="body">` + `getBoundingClientRect()` to compute fixed position, with a `window.addEventListener('scroll', updatePosition, true)` listener to reposition on scroll. Apply to:
1. StorageBrowser folder picker (per-file move-to-folder dropdown)
2. DocumentCard folder picker (move-to-folder dropdown on the card)
3. FolderRow three-dot menu (rename/delete actions)
Output:
- StorageBrowser file-row click guard + folder picker teleport
- DocumentCard folder picker teleport
- FolderRow three-dot menu teleport
**D-18 scope note (locked decision from CONTEXT.md):** D-18 specifies "dedicated drag handle element in DocumentCard.vue to prevent dragend-then-click navigation." DocumentCard.vue does NOT have `draggable="true"` set in Phase 10 — it is not a drag source in this phase. The dragend-then-click guard (D-18's protection goal) is implemented on StorageBrowser file rows, which ARE the drag sources. D-18 is fully satisfied for the elements that are actually draggable. DocumentCard drag capability, if added in a future phase, would need its own drag handle at that time. Document this conclusion in the SUMMARY.md.
- Two stub test files (StorageBrowser.dragmove + dropdown) promoted to real tests
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@CLAUDE.md
@.planning/phases/10-ux-interaction/10-CONTEXT.md
@.planning/phases/10-ux-interaction/10-RESEARCH.md
@.planning/phases/10-ux-interaction/10-PATTERNS.md
@frontend/src/components/storage/StorageBrowser.vue
@frontend/src/components/documents/DocumentCard.vue
@frontend/src/components/folders/FolderRow.vue
@frontend/src/components/ui/SearchableModelSelect.vue
@frontend/src/views/FileManagerView.vue
<interfaces>
**Drag-to-move click guard (Pitfall 2):**
File rows in StorageBrowser.vue have `@click="$emit('file-open', file)"` (current line ~107). After a drag (even one shorter than 4px) the browser fires `dragend` followed by `click` - causing the document to open accidentally after drag.
Fix:
1. The existing onDropDocOnFolder already sets `draggingFile.value = null` inside its body, but the `dragend` event handler still needs to clear it for the case where the user releases on a non-folder area.
2. Add an `@dragend="draggingFile = null"` handler to file rows.
3. Guard the click: change `@click="$emit('file-open', file)"` to `@click="draggingFile ? null : $emit('file-open', file)"`.
Per RESEARCH.md, the existing onDropDocOnFolder already resets draggingFile -> null synchronously after emitting file-move; that means click WILL still fire with draggingFile=null. To reliably block click-after-drag, defer the reset using nextTick:
```js
function onDropDocOnFolder(folderId) {
if (!draggingFile.value) return
const fileId = draggingFile.value.id
const f = draggingFile.value
dragOverFolderId.value = null
emit('file-move', { fileId, folderId })
nextTick(() => { draggingFile.value = null })
}
```
And on file row dragend:
```html
@dragend="onFileDragEnd"
```
with
```js
function onFileDragEnd() {
nextTick(() => { draggingFile.value = null })
}
```
**Dropdown teleport pattern (per SearchableModelSelect.vue):**
```js
import { ref, onMounted, onUnmounted } from 'vue'
const triggerEl = ref(null)
const dropdownStyle = ref({})
const isOpen = ref(false)
function updatePosition() {
if (!triggerEl.value) return
const rect = triggerEl.value.getBoundingClientRect()
const spaceBelow = window.innerHeight - rect.bottom
const dropH = 240
if (spaceBelow >= dropH || spaceBelow > 120) {
dropdownStyle.value = {
top: `${rect.bottom + 4}px`,
left: `${rect.left}px`,
width: '192px',
}
} else {
dropdownStyle.value = {
bottom: `${window.innerHeight - rect.top + 4}px`,
left: `${rect.left}px`,
width: '192px',
}
}
}
function openDropdown() {
updatePosition()
isOpen.value = true
}
function onScroll() { if (isOpen.value) updatePosition() }
onMounted(() => {
window.addEventListener('scroll', onScroll, true)
window.addEventListener('resize', onScroll)
})
onUnmounted(() => {
window.removeEventListener('scroll', onScroll, true)
window.removeEventListener('resize', onScroll)
})
```
Template:
```html
<button ref="triggerEl" @click="openDropdown">...</button>
<Teleport to="body">
<div v-if="isOpen" :style="dropdownStyle" class="fixed z-[9999] bg-white border border-gray-200 rounded-xl shadow-lg ...">
...
</div>
</Teleport>
```
Three targets:
- StorageBrowser folder picker (lines ~196-213 of current file): the per-file `<button @click.stop="folderPickerFileId = ...">` + `<div v-if="folderPickerFileId === file.id">` dropdown.
- DocumentCard folder picker (lines ~63-91 per RESEARCH.md): `<button @click.stop="toggleFolderPicker">` + `<div v-if="showFolderPicker">` dropdown.
- FolderRow three-dot menu (lines ~37-66): `<button @click="toggleMenu">` + `<div v-if="menuOpen">` dropdown.
For all three: keep the existing close-on-outside-click logic (already implemented via `onOutsideClick` in StorageBrowser; verify each component has equivalent).
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Promote StorageBrowser.dragmove + dropdown stubs to real tests</name>
<files>frontend/src/components/storage/__tests__/StorageBrowser.dragmove.test.js, frontend/src/components/ui/__tests__/dropdown.test.js</files>
<read_first>
- Both stub files (Wave 0 outputs)
- frontend/src/components/storage/StorageBrowser.vue (current state with drag handlers)
- frontend/src/components/documents/DocumentCard.vue
- frontend/src/components/folders/FolderRow.vue
</read_first>
<behavior>
StorageBrowser.dragmove.test.js (6 tests):
UX-11 group (4 tests):
1. `dragOverFolderId applied to a folder row results in ring-2 ring-inset ring-amber-300 class` - mount StorageBrowser with folders=[{id:'f1', name:'A'}] and stub draggingFile via vm.$forceUpdate after setting wrapper.vm.draggingFile, find the folder row, assert classList includes 'ring-2', 'ring-inset', 'ring-amber-300', 'bg-amber-50'.
2. `drop on folder emits file-move with { fileId, folderId }` - simulate dragstart on a file row (sets draggingFile), dispatch drop on a folder row, assert emitted('file-move')[0] === [{ fileId: '...', folderId: 'f1' }].
3. `file-row click is suppressed while draggingFile is non-null` - set draggingFile manually, click a file row, assert NO file-open emitted.
4. `dragend resets draggingFile after nextTick` - dispatch dragend, await nextTick, assert wrapper.vm.draggingFile === null.
UX-11 toast group (2 tests, integration with FileManagerView):
5. `successful moveToFolder triggers useToastStore.show("Document moved", "success")` - already wired in 10-06; this test verifies the wiring still exists. Mount FileManagerView with a mocked docsStore.moveToFolder that resolves, call doMove via vm, assert toastStore.show called with 'Document moved' and 'success'.
6. `failed moveToFolder triggers useToastStore.show("Move failed: ...", "error")` - same setup but moveToFolder rejects.
dropdown.test.js (4 tests):
1. `DocumentCard folder picker uses Teleport to body` - mount DocumentCard, open the picker, assert `document.body.querySelector('[data-test="folder-picker"]')` is non-null (or use Teleport stub to verify).
2. `DocumentCard folder picker position matches trigger getBoundingClientRect` - mock getBoundingClientRect to return a known rect, open picker, assert the picker style contains top/left matching the rect.
3. `FolderRow three-dot menu uses Teleport to body` - same approach with FolderRow.
4. `Window scroll while open recalculates position` - open the picker, mock getBoundingClientRect to return a different rect, dispatch window scroll event, assert the picker style updated.
Use happy-dom default. Stub child components where needed.
</behavior>
<action>
Modify both test files. Replace .todo entries with the 10 tests above (6 dragmove + 4 dropdown). Tests fail until Tasks 2-4 implement.
</action>
<verify>
<automated>cd frontend && npm run test -- --run dragmove; cd frontend && npm run test -- --run dropdown</automated>
Expected: 10 tests RED (some may currently pass if drag-to-move was wired in 10-06 - the dropdown tests must all fail).
</verify>
<acceptance_criteria>
- StorageBrowser.dragmove.test.js contains 6 real `it(...)` blocks
- dropdown.test.js contains 4 real `it(...)` blocks
- No `.todo` entries remain in either file
- Dropdown tests assert Teleport behavior (either via Teleport stub or by querying document.body)
</acceptance_criteria>
<done>10 tests in place describing UX-11 + UX-13 contracts.</done>
</task>
<task type="auto">
<name>Task 2: Add click-after-drag guard to StorageBrowser + teleport folder picker</name>
<files>frontend/src/components/storage/StorageBrowser.vue</files>
<read_first>
- frontend/src/components/storage/StorageBrowser.vue (current state - drag-to-move already wired)
- frontend/src/components/ui/SearchableModelSelect.vue (Teleport pattern reference)
- frontend/src/components/storage/__tests__/StorageBrowser.dragmove.test.js (failing tests)
- .planning/phases/10-ux-interaction/10-PATTERNS.md (StorageBrowser drag + dropdown sections)
</read_first>
<action>
Step A - Click-after-drag guard:
1. Update the file-row `@click` (around line 107 of current file): change `@click="$emit('file-open', file)"` to `@click="draggingFile ? null : $emit('file-open', file)"`.
2. Add `@dragend` handler on file rows that defers the reset:
Add a new function `function onFileDragEnd() { nextTick(() => { draggingFile.value = null }) }` and bind `@dragend="onFileDragEnd"` on the file row.
3. Modify `onDropDocOnFolder` so it also uses nextTick for the draggingFile reset:
```js
async function onDropDocOnFolder(folderId) {
if (!draggingFile.value) return
const fileId = draggingFile.value.id
dragOverFolderId.value = null
emit('file-move', { fileId, folderId })
await nextTick()
draggingFile.value = null
}
```
`nextTick` is already imported at the top of the file.
Step B - Teleport the folder picker dropdown:
The current picker (around lines 196-213) uses `<div class="relative"><button>...</button><div v-if="folderPickerFileId === file.id" class="absolute right-0 top-full mt-1 ...">`. Replace with:
1. Add per-file refs is impractical (folders are iterated). Use a single shared trigger ref + a single Teleport.
2. Add new refs: `const pickerTriggerEl = ref(null)`, `const pickerStyle = ref({})`.
3. Add a function `function openFolderPicker(fileId, ev) { folderPickerFileId.value = fileId; const trigger = ev.currentTarget; const rect = trigger.getBoundingClientRect(); updatePickerPosition(rect) }`.
4. Add `function updatePickerPosition(rect) { const spaceBelow = window.innerHeight - rect.bottom; const dropH = 240; if (spaceBelow >= dropH || spaceBelow > 120) { pickerStyle.value = { top: `${rect.bottom + 4}px`, left: `${rect.left}px`, width: '192px' } } else { pickerStyle.value = { bottom: `${window.innerHeight - rect.top + 4}px`, left: `${rect.left}px`, width: '192px' } } }`.
5. Update the move-button to use `@click.stop="openFolderPicker(file.id, $event)"` (replacing the inline state setter).
6. Store the trigger button in a Map keyed by fileId (`const pickerTriggerMap = new Map()`) so reposition-on-scroll knows which trigger to recompute. On `openFolderPicker`, set `pickerTriggerMap.set(fileId, ev.currentTarget)`.
7. Move the dropdown `<div>` OUT of the inline `<div class="relative">` and into a single `<Teleport to="body">` block at the end of the template:
```html
<Teleport to="body">
<div
v-if="folderPickerFileId"
:style="pickerStyle"
data-test="folder-picker"
class="fixed z-[9999] bg-white border border-gray-200 rounded-xl shadow-lg overflow-y-auto py-1"
style="max-height: 240px;"
>
<!-- existing dropdown content from the original block -->
</div>
</Teleport>
```
Reuse the existing list-of-folders markup verbatim - only the wrapping changes.
8. Add a window scroll listener to keep the picker positioned:
```js
function onWindowScroll() {
if (!folderPickerFileId.value) return
const trig = pickerTriggerMap.get(folderPickerFileId.value)
if (trig) updatePickerPosition(trig.getBoundingClientRect())
}
onMounted(() => { window.addEventListener('scroll', onWindowScroll, true); window.addEventListener('resize', onWindowScroll) })
onUnmounted(() => { window.removeEventListener('scroll', onWindowScroll, true); window.removeEventListener('resize', onWindowScroll) })
```
9. Keep the existing outside-click handler (`onOutsideClick`) but update it to also close the picker when clicking outside both the trigger AND the teleported dropdown. Simplest: in onOutsideClick, check `if (!e.target.closest('[data-test="folder-picker"]') && !e.target.closest('.relative')) folderPickerFileId.value = null`. Or rely on the existing `.relative` check since the trigger button is still wrapped in `.relative` even if the dropdown is teleported.
Preserve all other StorageBrowser functionality untouched.
</action>
<verify>
<automated>cd frontend && npm run test -- --run dragmove; cd frontend && npm run test -- --run StorageBrowser</automated>
Expected: 4 UX-11 dragmove tests pass; existing StorageBrowser suite unaffected.
</verify>
<acceptance_criteria>
- `grep -E "draggingFile\\s*\\?\\s*null\\s*:\\s*\\$emit\\('file-open'" frontend/src/components/storage/StorageBrowser.vue` returns 1 (click guard in place)
- `grep -E "function onFileDragEnd" frontend/src/components/storage/StorageBrowser.vue` returns 1
- `grep -E "@dragend=\"onFileDragEnd\"" frontend/src/components/storage/StorageBrowser.vue` returns 1
- `grep -E "await nextTick\\(\\)" frontend/src/components/storage/StorageBrowser.vue` returns >= 1 (in onDropDocOnFolder)
- `grep -E "<Teleport to=\"body\">" frontend/src/components/storage/StorageBrowser.vue` returns >= 1
- `grep -E "data-test=\"folder-picker\"" frontend/src/components/storage/StorageBrowser.vue` returns 1
- `grep -E "getBoundingClientRect" frontend/src/components/storage/StorageBrowser.vue` returns >= 1
- `grep -E "addEventListener\\('scroll'" frontend/src/components/storage/StorageBrowser.vue` returns 1
- 4 UX-11 dragmove tests pass
- Existing StorageBrowser test suite passes
</acceptance_criteria>
<done>Click guard + teleport in place; UX-11 dragmove tests green.</done>
</task>
<task type="auto">
<name>Task 3: Teleport DocumentCard folder picker</name>
<files>frontend/src/components/documents/DocumentCard.vue</files>
<read_first>
- frontend/src/components/documents/DocumentCard.vue (current state - has folder picker absolute-positioned)
- frontend/src/components/ui/SearchableModelSelect.vue (Teleport reference)
- .planning/phases/10-ux-interaction/10-PATTERNS.md §"DocumentCard.vue + FolderRow.vue"
</read_first>
<action>
Apply the same Teleport pattern to the DocumentCard folder picker:
1. Add refs: `pickerTriggerEl`, `pickerStyle`. Add `updatePosition()` helper using getBoundingClientRect.
2. Change `toggleFolderPicker()` to capture the trigger element and recompute position before setting `showFolderPicker = true`.
3. Wrap the picker dropdown in `<Teleport to="body">` with `class="fixed z-[9999]"` and `:style="pickerStyle"`. Add `data-test="folder-picker"`.
4. Add window scroll + resize listeners that call updatePosition when open.
5. Add window scroll + resize listener cleanup in onUnmounted.
6. Ensure outside-click closes the picker (existing logic should still work if it checks for the trigger button's wrapper class).
Reuse the exact dropdown markup; only the wrapping changes.
</action>
<verify>
<automated>cd frontend && npm run test -- --run dropdown</automated>
Expected: 2 DocumentCard-related tests pass (Teleport + position).
</verify>
<acceptance_criteria>
- `grep -E "<Teleport to=\"body\">" frontend/src/components/documents/DocumentCard.vue` returns 1
- `grep -E "getBoundingClientRect" frontend/src/components/documents/DocumentCard.vue` returns >= 1
- `grep -E "data-test=\"folder-picker\"" frontend/src/components/documents/DocumentCard.vue` returns 1
- `grep -E "addEventListener\\('scroll'" frontend/src/components/documents/DocumentCard.vue` returns 1
- 2 DocumentCard-related dropdown tests pass
</acceptance_criteria>
<done>DocumentCard folder picker teleported.</done>
</task>
<task type="auto">
<name>Task 4: Teleport FolderRow three-dot menu</name>
<files>frontend/src/components/folders/FolderRow.vue</files>
<read_first>
- frontend/src/components/folders/FolderRow.vue (current state - has three-dot menu absolute-positioned, lines ~37-66)
- frontend/src/components/ui/SearchableModelSelect.vue (Teleport reference)
- .planning/phases/10-ux-interaction/10-PATTERNS.md §"DocumentCard.vue + FolderRow.vue"
</read_first>
<action>
Apply the same Teleport pattern to FolderRow's three-dot menu:
1. Add refs: `menuTriggerEl`, `menuStyle`. Add `updatePosition()` helper using getBoundingClientRect.
2. Change `toggleMenu()` to capture the trigger element and recompute position before flipping `menuOpen`.
3. Wrap the menu `<div>` in `<Teleport to="body">` with `class="fixed z-[9999]"` and `:style="menuStyle"`. Add `data-test="folder-row-menu"`.
4. Add window scroll + resize listeners.
5. Outside-click logic preserved or updated.
</action>
<verify>
<automated>cd frontend && npm run test -- --run dropdown; cd frontend && npm run test -- --run</automated>
Expected: all 4 dropdown tests pass + full suite green.
</verify>
<acceptance_criteria>
- `grep -E "<Teleport to=\"body\">" frontend/src/components/folders/FolderRow.vue` returns 1
- `grep -E "getBoundingClientRect" frontend/src/components/folders/FolderRow.vue` returns >= 1
- `grep -E "data-test=\"folder-row-menu\"" frontend/src/components/folders/FolderRow.vue` returns 1
- `grep -E "addEventListener\\('scroll'" frontend/src/components/folders/FolderRow.vue` returns 1
- All 4 dropdown tests pass
- Full test suite passes
</acceptance_criteria>
<done>FolderRow three-dot menu teleported; UX-13 fully closed.</done>
</task>
</tasks>
<verification>
- `cd frontend && npm run test -- --run dragmove` exits 0
- `cd frontend && npm run test -- --run dropdown` exits 0
- `cd frontend && npm run test -- --run` (full suite) exits 0
- StorageBrowser, DocumentCard, FolderRow each contain `<Teleport to="body">`
- StorageBrowser file-row click guard present (grep on draggingFile + file-open)
</verification>
<success_criteria>
Drag a document onto a folder row -> folder row gets the amber ring -> release -> document moves and a "Document moved" toast appears -> clicking the same file row does NOT navigate (the drag suppressed the click). Opening the move-to-folder dropdown on any file (in StorageBrowser or DocumentCard) shows a properly positioned dropdown that does not clip at the viewport edge and follows the trigger when the page scrolls. Same for FolderRow three-dot menu.
</success_criteria>
<output>
Create `.planning/phases/10-ux-interaction/10-11-SUMMARY.md` when done.
</output>
@@ -0,0 +1,361 @@
---
phase: 10-ux-interaction
plan: 12
type: execute
wave: 5
depends_on: [10-01, 10-06, 10-07, 10-08, 10-09, 10-10, 10-11]
files_modified:
- frontend/src/components/storage/StorageBrowser.vue
- frontend/src/components/layout/AppSidebar.vue
- frontend/src/components/admin/AdminSidebar.vue
- frontend/src/components/folders/FolderRow.vue
- frontend/src/components/folders/FolderTreeItem.vue
- frontend/src/components/folders/FolderDeleteModal.vue
- frontend/src/components/documents/DocumentCard.vue
- frontend/src/components/documents/DocumentPreviewModal.vue
- frontend/src/components/documents/SearchBar.vue
- frontend/src/components/sharing/ShareModal.vue
- frontend/src/components/upload/DropZone.vue
- frontend/src/components/upload/UploadProgress.vue
- frontend/src/components/ui/TreeItem.vue
- frontend/src/components/ui/SearchableModelSelect.vue
- frontend/src/components/settings/SettingsCloudTab.vue
- frontend/src/components/cloud/CloudFolderTreeItem.vue
- frontend/src/components/cloud/CloudProviderTreeItem.vue
- frontend/src/views/AccountView.vue
- frontend/src/views/CloudStorageView.vue
- frontend/src/views/SettingsView.vue
- frontend/src/views/admin/AdminUsersView.vue
- frontend/src/views/admin/AdminAuditView.vue
- frontend/src/views/admin/AdminQuotasView.vue
- frontend/src/views/admin/AdminAiView.vue
- frontend/src/views/admin/AdminOverviewView.vue
autonomous: true
requirements: [CODE-05]
must_haves:
truths:
- "All inline <svg> blocks (~66 instances across ~29 files) are replaced with <AppIcon name='...' class='...' /> calls"
- "Each affected file imports AppIcon from the correct relative path"
- "Visual rendering is unchanged - same classes carry through to the <svg> via $attrs.class"
- "The full test suite continues to pass post-migration (no regression)"
- "No inline `<svg fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">` blocks remain (except in AppIcon.vue itself, AppSpinner.vue spinner, and UploadProgress.vue fill-based icons if they cannot be swapped)"
artifacts:
- path: "frontend/src/**/*.vue"
provides: "Updated to use AppIcon"
key_links:
- from: "Each modified .vue file"
to: "AppIcon.vue"
via: "import AppIcon from <relative-path>/components/ui/AppIcon.vue"
pattern: "import AppIcon"
---
<objective>
Complete CODE-05 by replacing every inline `<svg>` block in the frontend with an `<AppIcon name="..." class="..." />` call. This is the final wave of Phase 10 because every previous wave can freely modify its files without worrying about icon migration churn, and any new SVGs introduced earlier in the phase are caught here.
Per RESEARCH.md §Component Inventory §1, the audit found ~66 `<svg>` blocks across ~29 files mapping to ~30 named icons. The full Name -> d-attribute map lives in `AppIcon.vue` (from 10-01).
Output:
- Every affected file imports `AppIcon` from the correct relative path
- Every inline `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="..." /></svg>` block (or equivalent) is replaced with `<AppIcon name="<assigned-name>" class="<original-classes>" />`
- AppSpinner.vue retains its inline spinner SVG (it is not an icon)
- UploadProgress.vue fill-based icons are swapped for stroke equivalents (checkCircle / exclamationCircle from AppIcon)
- FolderRow.vue's previously fill-based three-dot icon is replaced with `<AppIcon name="dots" />` (stroke variant added in 10-01)
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@CLAUDE.md
@.planning/phases/10-ux-interaction/10-CONTEXT.md
@.planning/phases/10-ux-interaction/10-RESEARCH.md
@.planning/phases/10-ux-interaction/10-PATTERNS.md
@frontend/src/components/ui/AppIcon.vue
<interfaces>
**Migration mapping (from RESEARCH.md §Component Inventory §1):**
For each file, identify each inline `<svg>` block by its `d` attribute, look up the icon name in the AppIcon ICON_PATHS map, then replace.
Example transformation:
```html
<!-- BEFORE -->
<svg class="w-4 h-4 mr-2 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7..." />
</svg>
<!-- AFTER -->
<AppIcon name="tag" class="w-4 h-4 mr-2 shrink-0" />
```
**Special cases:**
- Settings dual-path (cog + cogDot) in AppSidebar -> single `<AppIcon name="cog" class="..." />` (AppIcon handles the array)
- FolderRow dots (fill-based) -> `<AppIcon name="dots" class="w-4 h-4" />` (stroke replacement; ICON_PATHS already includes the stroke version per 10-01)
- UploadProgress fill-based check/error -> `<AppIcon name="checkCircle" class="..." />` / `<AppIcon name="exclamationCircle" class="..." />`
- BreadcrumbBar internal chevronRight (added in 10-03) is already AppIcon - no change needed
- AppSpinner internal spinner SVG -> KEEP (it is a special animation, not an icon)
- DocumentPreviewModal internal spinner SVG (if it uses `<circle>`) -> KEEP
**Per-file inline SVG count (from RESEARCH.md):**
| File | Approx SVG count | Notes |
|------|-----------------|-------|
| frontend/src/components/storage/StorageBrowser.vue | ~6 | plus, folder (x2), folderMove, pencil, trash, share, document |
| frontend/src/components/layout/AppSidebar.vue | ~10 | tag, inbox, cloud, shield, cog (dual-path), logout, chevronRight (x2) |
| frontend/src/components/admin/AdminSidebar.vue | ~6 | home, users, chartBar, lightBulb, clipboardList, logout |
| frontend/src/components/folders/FolderRow.vue | ~3 | folder, dots, pencil, trash |
| frontend/src/components/folders/FolderTreeItem.vue | ~2 | folder, chevronRight |
| frontend/src/components/folders/FolderDeleteModal.vue | 1 | warning |
| frontend/src/components/documents/DocumentCard.vue | ~3 | document, folderMove, trash |
| frontend/src/components/documents/DocumentPreviewModal.vue | 1 | x (plus spinner kept) |
| frontend/src/components/sharing/ShareModal.vue | 1 | x |
| frontend/src/components/upload/DropZone.vue | 1 | upload |
| frontend/src/components/upload/UploadProgress.vue | ~2 | checkCircle, exclamationCircle (fill-swap) |
| frontend/src/components/ui/TreeItem.vue | 1 | chevronRight |
| frontend/src/components/ui/SearchableModelSelect.vue | ~2 | chevronDown, pencilEdit |
| frontend/src/components/settings/SettingsCloudTab.vue | 1 | warning |
| frontend/src/components/cloud/CloudFolderTreeItem.vue | ~2 | folder, fileDoc |
| frontend/src/components/cloud/CloudProviderTreeItem.vue | 1 | cloud |
| frontend/src/views/AccountView.vue | 1 | checkMark |
| frontend/src/views/CloudStorageView.vue | 1 | cloud (now handled in 10-08 via EmptyState; verify no other inline svg) |
| frontend/src/views/SettingsView.vue | ~3 | checkCircle, exclamationCircle, x (x2) |
| frontend/src/views/admin/AdminUsersView.vue | ~3 | copy, check, refresh |
| frontend/src/views/admin/AdminAuditView.vue | possibly 0-1 after 10-08 changes |
| frontend/src/views/admin/AdminQuotasView.vue | 0-1 |
| frontend/src/views/admin/AdminAiView.vue | 0-1 |
| frontend/src/views/admin/AdminOverviewView.vue | 0-1 |
The numbers are approximate; the actual count comes from `grep -c "<svg" <file>` at execution time.
**Identification process:**
For each file:
1. Read the file
2. For each inline `<svg>` block, read the `d` attribute
3. Look up the matching name in ICON_PATHS (from 10-01)
4. Replace the whole `<svg>...</svg>` block with `<AppIcon name="<name>" class="<original class attribute value>" />`
5. Ensure the file imports AppIcon
If an inline SVG's `d` attribute does NOT match any known icon, halt and report it - DO NOT invent a name. Either:
(a) Add the missing name+d to ICON_PATHS in AppIcon.vue (and update the AppIcon test if necessary), OR
(b) Leave that one inline SVG untouched and note it as an exception in the SUMMARY.
**Import path patterns:**
- Files under `frontend/src/components/storage/` -> `import AppIcon from '../ui/AppIcon.vue'`
- Files under `frontend/src/components/layout/` -> `import AppIcon from '../ui/AppIcon.vue'`
- Files under `frontend/src/components/folders/` -> `import AppIcon from '../ui/AppIcon.vue'`
- Files under `frontend/src/components/documents/` -> `import AppIcon from '../ui/AppIcon.vue'`
- Files under `frontend/src/components/cloud/` -> `import AppIcon from '../ui/AppIcon.vue'`
- Files under `frontend/src/components/sharing/` -> `import AppIcon from '../ui/AppIcon.vue'`
- Files under `frontend/src/components/upload/` -> `import AppIcon from '../ui/AppIcon.vue'`
- Files under `frontend/src/components/settings/` -> `import AppIcon from '../ui/AppIcon.vue'`
- Files under `frontend/src/components/admin/` -> `import AppIcon from '../ui/AppIcon.vue'`
- Files under `frontend/src/components/ui/` -> `import AppIcon from './AppIcon.vue'`
- Files under `frontend/src/views/` -> `import AppIcon from '../components/ui/AppIcon.vue'`
- Files under `frontend/src/views/admin/` -> `import AppIcon from '../../components/ui/AppIcon.vue'`
For Options API files, also register: `components: { AppIcon, ... }`.
For `<script setup>` files, the import is enough.
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Migrate sidebar + top-level layout files (StorageBrowser, AppSidebar, AdminSidebar, TreeItem)</name>
<files>
frontend/src/components/storage/StorageBrowser.vue,
frontend/src/components/layout/AppSidebar.vue,
frontend/src/components/admin/AdminSidebar.vue,
frontend/src/components/ui/TreeItem.vue
</files>
<read_first>
- All four files (current state)
- frontend/src/components/ui/AppIcon.vue (to know the available names and their d-values)
- .planning/phases/10-ux-interaction/10-RESEARCH.md §"Component Inventory §1: SVG Audit" (file-to-icon map)
</read_first>
<action>
For each file: add the AppIcon import (correct relative path). For Options API files (AppSidebar uses Options API, AdminSidebar likely the same, TreeItem may be either), register `AppIcon` in the `components: { ... }` map. For `<script setup>` files (StorageBrowser), only the import is needed.
Then, for each inline `<svg>` block, replace as described in <interfaces>:
**StorageBrowser.vue** (approx 6-7 SVGs):
- "New folder" button SVG (d=`M12 4v16m8-8H4`) -> `<AppIcon name="plus" class="w-4 h-4" />`
- Folder row icon (d starting `M3 7a2 2 0 012-2h4l2 2h8...`) -> `<AppIcon name="folder" class="w-4 h-4 text-amber-500" />`
- Inline new-folder row folder icon -> same as above
- File row pencil (rename) -> `<AppIcon name="pencil" class="..." />`
- File row trash (delete) -> `<AppIcon name="trash" class="..." />`
- File row share -> `<AppIcon name="share" class="..." />`
- File row document icon -> `<AppIcon name="document" class="..." />`
- File row folder-move (folderMove) -> `<AppIcon name="folderMove" class="..." />`
**AppSidebar.vue** (approx 10 SVGs):
- chevronRight x2 -> `<AppIcon name="chevronRight" class="..." />` each
- tag (All Topics) -> `<AppIcon name="tag" class="..." />`
- inbox (Shared with me) -> `<AppIcon name="inbox" class="..." />`
- cloud (Cloud section) -> `<AppIcon name="cloud" class="..." />`
- shield (Admin) -> `<AppIcon name="shield" class="..." />`
- cog dual-path (Settings) -> `<AppIcon name="cog" class="..." />` (single tag - AppIcon handles the array)
- logout (Sign out) -> `<AppIcon name="logout" class="..." />`
**AdminSidebar.vue** (approx 6 SVGs): home, users, chartBar, lightBulb, clipboardList, logout. Replace each with the matching AppIcon name preserving the original class attribute.
**TreeItem.vue** (1 SVG): chevronRight -> `<AppIcon name="chevronRight" class="..." />`.
Preserve all existing classes verbatim. Do not change SVG container element types (e.g., if the SVG was inside a `<button>`, the AppIcon stays inside the `<button>`).
</action>
<verify>
<automated>cd frontend && npm run test -- --run; cd frontend && npm run build</automated>
Expected: full test suite passes; build succeeds.
</verify>
<acceptance_criteria>
- StorageBrowser.vue: `grep -c "<svg" frontend/src/components/storage/StorageBrowser.vue` returns 0 (all SVGs migrated)
- AppSidebar.vue: `grep -c "<svg" frontend/src/components/layout/AppSidebar.vue` returns 0
- AdminSidebar.vue: `grep -c "<svg" frontend/src/components/admin/AdminSidebar.vue` returns 0
- TreeItem.vue: `grep -c "<svg" frontend/src/components/ui/TreeItem.vue` returns 0
- Each file contains `import AppIcon` from the correct relative path
- Each file contains `<AppIcon name="..." class="..." />` calls
- Full test suite passes
- npm run build exits 0
</acceptance_criteria>
<done>4 sidebar/storage files fully migrated; no inline SVGs remain in any of them.</done>
</task>
<task type="auto">
<name>Task 2: Migrate folder + document + upload + sharing components</name>
<files>
frontend/src/components/folders/FolderRow.vue,
frontend/src/components/folders/FolderTreeItem.vue,
frontend/src/components/folders/FolderDeleteModal.vue,
frontend/src/components/documents/DocumentCard.vue,
frontend/src/components/documents/DocumentPreviewModal.vue,
frontend/src/components/documents/SearchBar.vue,
frontend/src/components/sharing/ShareModal.vue,
frontend/src/components/upload/DropZone.vue,
frontend/src/components/upload/UploadProgress.vue
</files>
<read_first>
- All nine files (current state)
- frontend/src/components/ui/AppIcon.vue
- .planning/phases/10-ux-interaction/10-RESEARCH.md §"Component Inventory §1" (file/icon map)
</read_first>
<action>
For each file, repeat the migration pattern: add `import AppIcon`, register in components if Options API, replace each inline `<svg>` with `<AppIcon name="..." class="..." />`.
Specific notes:
- **FolderRow.vue**: the three-dot menu currently uses `fill="currentColor"` - replace with `<AppIcon name="dots" class="w-4 h-4" />` (stroke variant added in 10-01).
- **FolderDeleteModal.vue**: warning icon -> `<AppIcon name="warning" class="..." />`.
- **DocumentCard.vue**: document + folderMove + trash -> each with matching AppIcon name.
- **DocumentPreviewModal.vue**: close `x` button SVG -> `<AppIcon name="x" class="..." />`. KEEP the loading spinner SVG (it uses `<circle>` and is not in the icon map).
- **SearchBar.vue**: if SearchBar has a search icon inline, replace with `<AppIcon name="search" />`. If it does not have any inline SVG, this file is a no-op for migration (still add the import only if needed).
- **ShareModal.vue**: `x` close button -> `<AppIcon name="x" class="..." />`.
- **DropZone.vue**: upload icon -> `<AppIcon name="upload" class="..." />`.
- **UploadProgress.vue**: fill-based check + error icons -> `<AppIcon name="checkCircle" />` / `<AppIcon name="exclamationCircle" />`. Drop the `fill="currentColor"` styling - AppIcon uses stroke; visual difference at w-5 h-5 is minor and acceptable per RESEARCH.md Open Question 1.
</action>
<verify>
<automated>cd frontend && npm run test -- --run; cd frontend && npm run build</automated>
Expected: tests pass; build succeeds.
</verify>
<acceptance_criteria>
- For each file: `grep -c "<svg" <file>` returns 0 OR 1 (1 is acceptable only for DocumentPreviewModal.vue with the loading spinner)
- Specifically: `grep -c "<svg" frontend/src/components/documents/DocumentPreviewModal.vue` may be 1 (spinner preserved)
- All other listed files: `grep -c "<svg" <file>` returns 0
- Every file contains `import AppIcon` (except SearchBar if no SVGs existed there - confirm by reading)
- Full test suite passes
- npm run build exits 0
</acceptance_criteria>
<done>9 folder/document/upload/sharing files migrated.</done>
</task>
<task type="auto">
<name>Task 3: Migrate cloud + settings + UI utility files</name>
<files>
frontend/src/components/cloud/CloudFolderTreeItem.vue,
frontend/src/components/cloud/CloudProviderTreeItem.vue,
frontend/src/components/settings/SettingsCloudTab.vue,
frontend/src/components/ui/SearchableModelSelect.vue
</files>
<read_first>
- All four files (current state)
- frontend/src/components/ui/AppIcon.vue
- .planning/phases/10-ux-interaction/10-RESEARCH.md §"Component Inventory §1"
</read_first>
<action>
Apply the migration:
- **CloudFolderTreeItem.vue**: folder + fileDoc -> `<AppIcon name="folder" />` / `<AppIcon name="fileDoc" />`.
- **CloudProviderTreeItem.vue**: cloud -> `<AppIcon name="cloud" class="..." />`.
- **SettingsCloudTab.vue**: warning -> `<AppIcon name="warning" class="..." />`.
- **SearchableModelSelect.vue**: chevronDown + pencilEdit -> `<AppIcon name="chevronDown" />` / `<AppIcon name="pencilEdit" />`.
</action>
<verify>
<automated>cd frontend && npm run test -- --run; cd frontend && npm run build</automated>
Expected: tests pass; build succeeds.
</verify>
<acceptance_criteria>
- Each file: `grep -c "<svg" <file>` returns 0
- Each file imports AppIcon
- Test suite + build pass
</acceptance_criteria>
<done>4 cloud/settings/UI files migrated.</done>
</task>
<task type="auto">
<name>Task 4: Migrate view files (AccountView, CloudStorageView, SettingsView, all admin views)</name>
<files>
frontend/src/views/AccountView.vue,
frontend/src/views/CloudStorageView.vue,
frontend/src/views/SettingsView.vue,
frontend/src/views/admin/AdminUsersView.vue,
frontend/src/views/admin/AdminAuditView.vue,
frontend/src/views/admin/AdminQuotasView.vue,
frontend/src/views/admin/AdminAiView.vue,
frontend/src/views/admin/AdminOverviewView.vue
</files>
<read_first>
- All eight files (current state, after 10-08 modifications)
- frontend/src/components/ui/AppIcon.vue
- .planning/phases/10-ux-interaction/10-RESEARCH.md §"Component Inventory §1"
</read_first>
<action>
Apply the migration:
- **AccountView.vue**: checkMark -> `<AppIcon name="checkMark" class="..." />`.
- **CloudStorageView.vue**: any remaining inline SVG (after 10-08 EmptyState wiring) -> matching AppIcon.
- **SettingsView.vue**: checkCircle + exclamationCircle + x (x2) -> matching AppIcons.
- **AdminUsersView.vue**: copy + check + refresh -> matching AppIcons.
- **AdminAuditView.vue, AdminQuotasView.vue, AdminAiView.vue, AdminOverviewView.vue**: any remaining inline SVGs -> matching AppIcons. If none, this is a no-op (no import needed unless adding it).
For all views: register `AppIcon` in `components: { ... }` if Options API; or only add the import if `<script setup>`.
</action>
<verify>
<automated>cd frontend && npm run test -- --run; cd frontend && npm run build; grep -rE "<svg fill=\"none\" stroke=\"currentColor\"" frontend/src --include="*.vue" | grep -v AppIcon.vue | grep -v AppSpinner.vue | grep -v DocumentPreviewModal.vue | wc -l</automated>
Expected: tests pass; build succeeds; grep count returns 0 (all stroke-based outline SVGs migrated).
</verify>
<acceptance_criteria>
- Each view file: `grep -c "<svg" <file>` returns 0
- `grep -rE "<svg fill=\"none\" stroke=\"currentColor\"" frontend/src --include="*.vue" | grep -v AppIcon.vue | wc -l` returns 0 (only AppIcon.vue should contain stroke-based outline SVGs)
- The exceptions are: AppSpinner.vue (spinner animation, not an icon) and DocumentPreviewModal.vue (loading spinner with `<circle>`). Both have already been validated NOT to use the stroke + viewBox 0 0 24 24 pattern; if either matches the grep above, halt and confirm with the user before proceeding.
- npm run build exits 0
- Full test suite passes
</acceptance_criteria>
<done>All view-level files migrated; CODE-05 verifiable by the single grep across the codebase returning 0.</done>
</task>
</tasks>
<verification>
- Codebase-wide check: `grep -rE "<svg fill=\"none\" stroke=\"currentColor\"" frontend/src --include="*.vue" | grep -v "AppIcon.vue"` returns 0 lines (every inline outline SVG migrated)
- `cd frontend && npm run test -- --run` exits 0 (full suite passes)
- `cd frontend && npm run build` exits 0
- Every modified file imports AppIcon from the correct relative path
- ICON_PATHS in AppIcon.vue is the ONLY file containing the canonical d-attribute values for the migrated icons
</verification>
<success_criteria>
The frontend bundle has a single source of truth for icon paths. Visually, the app renders identically to before migration. Adding a new icon requires editing ONE map in AppIcon.vue and using `<AppIcon name="..." />` at consumer sites - no more copy-paste of `<svg ...>` blocks. The grep gate guarantees no future drift back to inline SVGs without violating CODE-05.
</success_criteria>
<output>
Create `.planning/phases/10-ux-interaction/10-12-SUMMARY.md` when done. Include the final `grep -rc "<svg" frontend/src --include="*.vue"` count (broken down by file if not zero) and confirm the grep gate above returns 0.
</output>
File diff suppressed because it is too large Load Diff
@@ -839,7 +839,7 @@ Phase 10 is purely frontend UX. No new API endpoints, no authentication changes,
---
## Open Questions
## 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.
@@ -0,0 +1,93 @@
---
phase: 10
slug: ux-interaction
status: draft
nyquist_compliant: false
wave_0_complete: false
created: 2026-06-14
---
# Phase 10 — Validation Strategy
> Per-phase validation contract for feedback sampling during execution.
---
## Test Infrastructure
| Property | Value |
|----------|-------|
| **Framework** | Vitest ^4.1.7 |
| **Config file** | `frontend/vite.config.js` |
| **Quick run command** | `cd frontend && npm run test -- --run` |
| **Full suite command** | `cd frontend && npm run test` |
| **Estimated runtime** | ~30 seconds |
---
## Sampling Rate
- **After every task commit:** Run `cd frontend && npm run test -- --run [component-name]`
- **After every plan wave:** Run `cd frontend && npm run test -- --run`
- **Before `/gsd:verify-work`:** Full suite must be green
- **Max feedback latency:** 30 seconds
---
## Per-Task Verification Map
| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
| AppIcon foundation | W0 | 0 | CODE-05 | — | Unknown names warn; render nothing | unit | `npm run test -- --run AppIcon` | ❌ | pending |
| EmptyState foundation | W0 | 0 | UX-01 | — | Props render; CTA slot optional | unit | `npm run test -- --run EmptyState` | ❌ | pending |
| BreadcrumbBar foundation | W0 | 0 | UX-12 | — | Last segment non-clickable; navigate emitted | unit | `npm run test -- --run BreadcrumbBar` | ❌ | pending |
| Toast store + container | W0 | 0 | UX-10 | — | auto-dismiss 4s; dismiss on click | unit | `npm run test -- --run toast` | ❌ | pending |
| StorageBrowser skeleton | W1 | 1 | UX-02 | — | skeleton renders when loading=true | unit | `npm run test -- --run StorageBrowser` | ❌ | pending |
| AppSidebar skeleton + empty | W1 | 1 | UX-03 | — | skeleton rows replace Loading text | unit | `npm run test -- --run AppSidebar` | ❌ | pending |
| Admin table skeletons | W1 | 1 | UX-04 | — | skeleton rows in users + audit | unit | `npm run test -- --run Admin` | ❌ | pending |
| EmptyState wiring | W1 | 1 | UX-01 | — | EmptyState shown in all 7+ contexts | unit | `npm run test -- --run EmptyState` | ❌ | pending |
| Toast call sites | W1 | 1 | UX-10 | — | show() called on upload/delete/move | unit | `npm run test -- --run toast` | ❌ | pending |
| BreadcrumbBar wiring | W1 | 1 | UX-12 | — | admin/settings views pass static segments | unit | `npm run test -- --run BreadcrumbBar` | ❌ | pending |
| UX-14 sidebar removal | W1 | 1 | UX-14 | — | "New" button absent from AppSidebar | unit | `npm run test -- --run AppSidebar` | ❌ | pending |
| Keyboard shortcuts | W2 | 2 | UX-05..08 | — | guard fires; no input bleed | unit | `npm run test -- --run keyboard` | ❌ | pending |
| OS drag overlay | W2 | 2 | UX-09 | — | overlay on Files dragenter; hide on leave | unit | `npm run test -- --run OsDragOverlay` | ❌ | pending |
| Drag-to-move toast | W3 | 3 | UX-11 | — | ring-2 ring-inset ring-amber-300 on hover | unit | `npm run test -- --run StorageBrowser` | ❌ | pending |
| Dropdown clipping fixes | W3 | 3 | UX-13 | — | picker renders via Teleport at correct pos | unit | `npm run test -- --run dropdown` | ❌ | pending |
| SVG migration | W3 | 3 | CODE-05 | — | all 29 files use AppIcon; no inline svg | unit | `npm run test -- --run AppIcon` | ❌ | pending |
---
## Key Behavioral Contracts
| Contract | Value | Test Type |
|---|---|---|
| Toast auto-dismiss timing | 4000ms (locked by Phase 8 stub) | 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) | 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 | unit |
| AppIcon unknown name | `console.warn` in dev; renders nothing | unit |
---
## Wave 0 Test Stubs (REQUIRED — create before implementation)
- [ ] `frontend/src/components/ui/AppIcon.test.js` — covers CODE-05 (name→path, 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
---
## Manual Validation Checkpoints
These require human observation:
- UX-03: Sidebar skeleton visual appearance (match TreeItem indent level)
- UX-04: Admin table skeleton row column alignment
- Toast stacking with multiple simultaneous toasts
- OS drag-and-drop actual file upload end-to-end
- Keyboard `N` in cloud folder view (must no-op silently)
- Human checkpoint UAT: all 15 requirements exercised by a real user