diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 7870468..567b4bd 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -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 `` blocks in ~29 files with `` — CODE-05 **UI hint**: yes --- diff --git a/.planning/phases/10-ux-interaction/10-01-PLAN.md b/.planning/phases/10-ux-interaction/10-01-PLAN.md new file mode 100644 index 0000000..2c08541 --- /dev/null +++ b/.planning/phases/10-ux-interaction/10-01-PLAN.md @@ -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 path for any name in the icon map" + - "AppIcon forwards the consumer's class attribute to the outer 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\\]" +--- + + +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 `` 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. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@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 + + +Per D-08, D-09, D-10 (CONTEXT.md): +- Props: `name` (String, required) +- inheritAttrs: false +- $attrs.class forwarded to outer +- 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` + + + + + + + Task 1: Create AppIcon.test.js with failing tests for the contract + frontend/src/components/ui/__tests__/AppIcon.test.js + + - 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) + + + - Test 1: `renders 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 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 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 + + + 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. + + + cd frontend && npm run test -- --run AppIcon + 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. + + + - 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 + + Test file exists with 6 failing tests describing the AppIcon contract. + + + + Task 2: Implement AppIcon.vue with the full ICON_PATHS map + frontend/src/components/ui/AppIcon.vue + + - 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) + + + - 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: ` + + 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. + + + cd frontend && npm run test -- --run AppIcon + Expected: All 6 tests from Task 1 PASS (GREEN). + + + - 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 ` + AppIcon.vue exists with full icon map; all 6 tests pass. + + + + + +- `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`) + + + +AppIcon.vue is the single source of truth for icon paths. Wave 5's SVG migration (10-12) can replace every inline `` block with `` and the visual result matches the existing rendering pixel-for-pixel (same fill/stroke/viewBox/path conventions). + + + +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. + diff --git a/.planning/phases/10-ux-interaction/10-02-PLAN.md b/.planning/phases/10-ux-interaction/10-02-PLAN.md new file mode 100644 index 0000000..c28dcc6 --- /dev/null +++ b/.planning/phases/10-ux-interaction/10-02-PLAN.md @@ -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 + " + pattern: "import AppIcon" +--- + + +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. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@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 + + +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 `` 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) | + + + + + + + Task 1: Create EmptyState.test.js with failing tests + frontend/src/components/ui/__tests__/EmptyState.test.js + + - frontend/src/components/folders/__tests__/FolderBreadcrumb.test.js (test style) + - .planning/phases/10-ux-interaction/10-PATTERNS.md §"EmptyState.vue" (target structure + class table) + + + - 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

+ - 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 ' }, 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' + + + 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. + + + cd frontend && npm run test -- --run EmptyState + Expected: tests collected; all 7 FAIL with module-not-found (RED phase). + + + - 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) + + Test file exists with 7 failing tests describing the EmptyState contract. + + + + Task 2: Implement EmptyState.vue + frontend/src/components/ui/EmptyState.vue + + - 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) + + + - 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 `

`, AppIcon (when icon truthy), headline `

`, subtext `

` (when subtext truthy AND size !== 'sm' — sidebar micro hides subtext via the `hidden` class), `` + + + 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: + ``` + + ``` + + Do NOT add explanatory comments. Use Options API (CLAUDE.md). + + + cd frontend && npm run test -- --run EmptyState + Expected: all 7 tests PASS (GREEN). + + + - 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 `` + - All 7 tests pass: `cd frontend && npm run test -- --run EmptyState` exits 0 + - File uses Options API, not ` +``` + +**Key conventions to copy:** +- `inheritAttrs: false` so `$attrs.class` passes through to the `` element +- All existing inline SVGs use `fill="none" stroke="currentColor" viewBox="0 0 24 24"` — match exactly +- All existing path attrs: `stroke-linecap="round" stroke-linejoin="round" stroke-width="2"` — match exactly +- `aria-hidden="true"` on the `` (decorative icons; label is on parent element) +- Dev-mode `console.warn` for unknown names; renders nothing (`v-if="paths[name]"`) + +--- + +### `frontend/src/components/ui/EmptyState.vue` (NEW — component, request-response) + +**Analog:** Inline empty-state blocks in `SharedView.vue` (lines 9-13), `StorageBrowser.vue` (lines 226-241), `AppSidebar.vue` (lines 102-103, 148-149, 163-164). The pattern is consistent: centered container, small text, optional action. + +**Current inline pattern to replace** (SharedView.vue lines 9-13): +```html +

+

No documents shared with you yet.

+

When someone shares a document with you, it will appear here.

+
+``` + +**Current inline pattern to replace** (StorageBrowser.vue lines 226-233): +```html +
+

{{ emptyMessage }}

+

{{ emptyHint }}

+
+``` + +**Current inline pattern for sidebar** (AppSidebar.vue lines 102-103): +```html +
No folders yet
+``` + +**Target component structure for EmptyState.vue** (Options API, consistent with component convention): +```html + + + +``` + +**Usage patterns** (copy from call sites): +- `size="sm"` for AppSidebar micro states (same indent as `pl-7 py-1 text-xs text-gray-400`) +- Default `size="md"` for StorageBrowser, SharedView, CloudStorageView, AdminAuditView +- `#cta` slot renders buttons using existing button styles (e.g., `class="text-sm text-indigo-600 hover:underline"`) + +--- + +### `frontend/src/components/ui/BreadcrumbBar.vue` (NEW — component, request-response) + +**Analog:** `frontend/src/components/folders/FolderBreadcrumb.vue` (lines 1-68 — **read completely above**) + +**Existing interface to extend** (FolderBreadcrumb.vue, lines 46-68): +```javascript +// script setup +const props = defineProps({ + segments: { type: Array, default: () => [] }, // [{ id, name }] +}) +const emit = defineEmits(['navigate']) + +const visibleSegments = computed(() => { + if (props.segments.length > 4) { + return [ + props.segments[0], + { id: 'ellipsis', name: '…' }, + ...props.segments.slice(-2), + ] + } + return props.segments +}) +``` + +**Existing template pattern** (FolderBreadcrumb.vue, lines 1-44): +```html + +``` + +**What BreadcrumbBar.vue adds over FolderBreadcrumb.vue:** +1. Renamed prop shape: `segments: [{ id?, label }]` — `label` replaces `name` +2. New props: `rootLabel` (String, default `'Home'`) and `showRoot` (Boolean, default `true`) +3. Segments without `id` are non-clickable (admin static breadcrumbs) +4. Use `` instead of inline SVG + +**BreadcrumbBar prop interface:** +```javascript +props: { + segments: { type: Array, default: () => [] }, + rootLabel: { type: String, default: 'Home' }, + showRoot: { type: Boolean, default: true }, +} +emits: ['navigate'] // emits segment.id (or null for root) +``` + +**How StorageBrowser currently uses FolderBreadcrumb** (StorageBrowser.vue lines 7-10): +```html + +``` +After swap: `` + +Existing breadcrumb prop shape from FileManagerView (passes `foldersStore.breadcrumb` which returns `[{ id, name }]`) — segments must be remapped to `{ id, label: name }` or BreadcrumbBar must accept both `name` and `label` for backward compat. + +--- + +### `frontend/src/components/ui/ToastContainer.vue` (NEW — component, event-driven) + +**Analog:** `frontend/src/components/ui/SearchableModelSelect.vue` — the `` + `z-[9999]` pattern (lines 37-91) + +**Teleport pattern to copy** (SearchableModelSelect.vue lines 37-43): +```html + +
    +``` + +**Target ToastContainer structure** (Options API, matches D-01 through D-04): +```html + +``` + +**Toast type → classes mapping:** +- `success`: accent `bg-green-500`, icon `checkCircle` `text-green-500` +- `error`: accent `bg-red-500`, icon `exclamationCircle` `text-red-500` +- `warning`: accent `bg-amber-400`, icon `warning` `text-amber-500` +- `info`: accent `bg-sky-400`, icon `exclamationCircle` `text-sky-500` + +**App.vue mount point** (App.vue current lines 1-21): +```html + + +``` + +--- + +### `frontend/src/components/layout/OsDragOverlay.vue` (NEW — component, event-driven) + +**Analog:** `frontend/src/components/documents/DocumentPreviewModal.vue` — `onMounted`/`onUnmounted` window-level event listener pattern (lines 118-135) + +**Keydown listener pattern to copy** (DocumentPreviewModal.vue lines 112-135): +```javascript +function handleKeydown(e) { + if (e.key === 'Escape') { + emit('close') + } +} + +onMounted(() => { + document.addEventListener('keydown', handleKeydown) + loadContent(props.doc.id) +}) + +onUnmounted(() => { + document.removeEventListener('keydown', handleKeydown) + // cleanup... +}) +``` + +**Adapt for window drag events** (Options API, D-16 depth counter from Pitfall 3): +```javascript +export default { + name: 'OsDragOverlay', + emits: ['files-dropped'], + data() { + return { dragDepth: 0, showOverlay: false } + }, + methods: { + onDragEnter(e) { + if (!e.dataTransfer?.types.includes('Files')) return + this.dragDepth++ + this.showOverlay = true + }, + onDragLeave() { + this.dragDepth = Math.max(0, this.dragDepth - 1) + if (this.dragDepth === 0) this.showOverlay = false + }, + onDragOver(e) { e.preventDefault() }, + onDrop(e) { + e.preventDefault() + this.dragDepth = 0 + this.showOverlay = false + const files = Array.from(e.dataTransfer.files) + if (files.length) this.$emit('files-dropped', files) + }, + }, + mounted() { + window.addEventListener('dragenter', this.onDragEnter) + window.addEventListener('dragleave', this.onDragLeave) + window.addEventListener('dragover', this.onDragOver) + window.addEventListener('drop', this.onDrop) + }, + beforeUnmount() { + window.removeEventListener('dragenter', this.onDragEnter) + window.removeEventListener('dragleave', this.onDragLeave) + window.removeEventListener('dragover', this.onDragOver) + window.removeEventListener('drop', this.onDrop) + }, +} +``` + +**Overlay template** (z-index below toasts per Pitfall 6 — `z-[9998]`): +```html + +``` + +--- + +### `frontend/src/stores/toast.js` (MODIFY — store, event-driven) + +**Current stub** (toast.js lines 1-20 — entire file): +```javascript +import { defineStore } from 'pinia' + +export const useToastStore = defineStore('toast', () => { + // eslint-disable-next-line no-unused-vars + function show(message, type = 'success', duration = 4000) { + // No-op stub — Phase 10 implements rendering. + } + return { show } +}) +``` + +**Implementation target** (preserve exact `show(message, type, duration)` signature — D-04): +```javascript +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 } +}) +``` + +**Existing call sites that must remain unchanged** (from CONTEXT.md D-04): +- `SettingsAccountTab.vue`: `useToastStore().show('Sessions revoked', 'success')` +- `TotpEnrollment.vue`: `useToastStore().show('TOTP enabled', 'success')` + +--- + +### `frontend/src/App.vue` (MODIFY — provider, event-driven) + +**Current state** (App.vue lines 1-21 — entire file): Uses `