# Phase 10: UX & Interaction — Pattern Map

**Mapped:** 2026-06-14
**Files analyzed:** 18 new/modified files
**Analogs found:** 17 / 18

---

## File Classification

| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|---|---|---|---|---|
| `frontend/src/components/ui/AppIcon.vue` | utility | transform | `frontend/src/components/ui/AppSpinner.vue` | role-match |
| `frontend/src/components/ui/EmptyState.vue` | component | request-response | `frontend/src/views/SharedView.vue` (inline empty) | partial |
| `frontend/src/components/ui/BreadcrumbBar.vue` | component | request-response | `frontend/src/components/folders/FolderBreadcrumb.vue` | exact |
| `frontend/src/components/ui/ToastContainer.vue` | component | event-driven | `frontend/src/components/ui/SearchableModelSelect.vue` (Teleport) | role-match |
| `frontend/src/components/layout/OsDragOverlay.vue` | component | event-driven | `frontend/src/components/documents/DocumentPreviewModal.vue` (window listener) | partial |
| `frontend/src/stores/toast.js` | store | event-driven | existing stub — fill in | exact |
| `frontend/src/App.vue` | provider | event-driven | `frontend/src/components/documents/DocumentPreviewModal.vue` | partial |
| `frontend/src/stores/toast.js` (impl) | store | event-driven | Pinia setup-store pattern in `frontend/src/stores/topics.js` | role-match |
| `frontend/src/components/storage/StorageBrowser.vue` | component | CRUD | self (modify) | exact |
| `frontend/src/components/layout/AppSidebar.vue` | component | CRUD | self (modify) | exact |
| `frontend/src/views/FileManagerView.vue` | view | CRUD | self (modify) | exact |
| `frontend/src/components/upload/DropZone.vue` | component | file-I/O | self (modify) | exact |
| `frontend/src/views/SharedView.vue` | view | request-response | self (modify) | exact |
| `frontend/src/views/CloudStorageView.vue` | view | request-response | self (modify) | exact |
| `frontend/src/views/admin/AdminUsersView.vue` | view | CRUD | self (modify) | exact |
| `frontend/src/views/admin/AdminAuditView.vue` | view | CRUD | self (modify) | exact |
| `frontend/src/components/documents/DocumentCard.vue` | component | CRUD | `frontend/src/components/ui/SearchableModelSelect.vue` (Teleport/position) | role-match |
| `frontend/src/components/folders/FolderRow.vue` | component | CRUD | `frontend/src/components/ui/SearchableModelSelect.vue` (Teleport/position) | role-match |
| `frontend/src/components/documents/SearchBar.vue` | component | request-response | self (modify: add defineExpose) | exact |

---

## Pattern Assignments

### `frontend/src/components/ui/AppIcon.vue` (NEW — utility, transform)

**Analog:** `frontend/src/components/ui/AppSpinner.vue` (single-purpose SVG UI component)

**Imports / component declaration pattern** (AppSpinner.vue, lines 1-23 — entire file):
The file has no script block at all — pure template. AppIcon needs a script block for the `name` prop and the path map. Use Options API per project convention.

**Options API component structure** (copy from AppSidebar.vue lines 235-313 — script setup means new components use Options API per CLAUDE.md pitfall note):

```html
<template>
  <svg
    v-if="paths[name]"
    :class="$attrs.class"
    fill="none"
    stroke="currentColor"
    viewBox="0 0 24 24"
    aria-hidden="true"
  >
    <template v-if="Array.isArray(resolvedPaths)">
      <path
        v-for="(d, i) in resolvedPaths"
        :key="i"
        stroke-linecap="round"
        stroke-linejoin="round"
        stroke-width="2"
        :d="d"
      />
    </template>
    <path
      v-else
      stroke-linecap="round"
      stroke-linejoin="round"
      stroke-width="2"
      :d="resolvedPaths"
    />
  </svg>
</template>

<script>
const ICON_PATHS = {
  // single-path icons
  plus:           'M12 4v16m8-8H4',
  folder:         'M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V7z',
  folderMove:     'M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2V7z',
  pencil:         'M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z',
  trash:          'M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16',
  share:          'M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z',
  document:       'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z',
  fileDoc:        'M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z',
  chevronRight:   'M9 5l7 7-7 7',
  chevronDown:    'M19 9l-7 7-7-7',
  tag:            'M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z',
  inbox:          'M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4',
  cloud:          'M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z',
  shield:         'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z',
  logout:         'M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1',
  home:           'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6',
  users:          'M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z',
  chartBar:       'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z',
  clipboardList:  'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01',
  upload:         'M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12',
  x:              'M6 18L18 6M6 6l12 12',
  checkCircle:    'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z',
  exclamationCircle: 'M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z',
  warning:        'M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z',
  copy:           'M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z',
  check:          'M5 13l4 4L19 7',
  checkMark:      'M4.5 12.75l6 6 9-13.5',
  refresh:        'M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15',
  pencilEdit:     'M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z',
  lightBulb:      'M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z',
  // search: verify Heroicons path before committing (A3 — assumed)
  search:         'M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0',
  // dots: stroke replacement for fill-based three-dot icon
  dots:           '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',
  // dual-path icon — stored as array
  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',
  ],
}

export default {
  name: 'AppIcon',
  inheritAttrs: false,
  props: {
    name: { type: String, required: true },
  },
  computed: {
    resolvedPaths() {
      const p = ICON_PATHS[this.name]
      if (!p && import.meta.env.DEV) {
        console.warn(`[AppIcon] Unknown icon name: "${this.name}"`)
      }
      return p ?? null
    },
  },
}
</script>
```

**Key conventions to copy:**
- `inheritAttrs: false` so `$attrs.class` passes through to the `<svg>` 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 `<svg>` (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
<div v-else-if="sharedDocs.length === 0" class="text-center py-12 text-gray-400">
  <p class="text-sm font-medium text-gray-500">No documents shared with you yet.</p>
  <p class="text-xs mt-1">When someone shares a document with you, it will appear here.</p>
</div>
```

**Current inline pattern to replace** (StorageBrowser.vue lines 226-233):
```html
<div
  v-if="!loading && folders.length === 0 && files.length === 0 && !showNewFolderInput"
  class="px-4 py-10 text-center text-gray-300"
>
  <p class="text-gray-400 text-sm">{{ emptyMessage }}</p>
  <p class="text-xs mt-1">{{ emptyHint }}</p>
</div>
```

**Current inline pattern for sidebar** (AppSidebar.vue lines 102-103):
```html
<div v-else-if="foldersStore.rootFolders.length === 0 && !showNewFolderInput"
  class="pl-7 py-1 text-xs text-gray-400">No folders yet</div>
```

**Target component structure for EmptyState.vue** (Options API, consistent with component convention):
```html
<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>

<script>
import AppIcon from './AppIcon.vue'
export default {
  name: 'EmptyState',
  components: { AppIcon },
  props: {
    icon:     { type: String, default: null },
    headline: { type: String, required: true },
    subtext:  { type: String, default: '' },
    size:     { type: String, default: 'md' },  // 'sm' for sidebar micro states
  },
  computed: {
    containerClass() {
      return this.size === 'sm'
        ? 'flex items-center gap-2 py-1 text-xs text-gray-400'
        : 'text-center py-10 px-4'
    },
    iconClass() {
      return this.size === 'sm' ? 'w-3.5 h-3.5 shrink-0' : 'w-8 h-8 mx-auto mb-3 text-gray-300'
    },
    headlineClass() {
      return this.size === 'sm' ? '' : 'text-sm font-medium text-gray-500'
    },
    subtextClass() {
      return this.size === 'sm' ? 'hidden' : 'text-xs text-gray-400 mt-1'
    },
  },
}
</script>
```

**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
<nav aria-label="Folder navigation">
  <ol class="flex items-center gap-1 text-sm flex-wrap">
    <!-- Root "Home" segment -->
    <li class="flex items-center gap-1">
      <button @click="emit('navigate', null)" class="text-indigo-600 hover:underline font-medium">
        Home
      </button>
    </li>
    <template v-for="(segment, idx) in visibleSegments" :key="segment.id ?? 'ellipsis-' + idx">
      <!-- Separator -->
      <li class="shrink-0" aria-hidden="true">
        <svg class="w-3 h-3 text-gray-400" ...>
          <path ... d="M9 5l7 7-7 7" />  <!-- chevronRight icon — replace with <AppIcon name="chevronRight"> -->
        </svg>
      </li>
      <!-- Ellipsis -->
      <li v-if="segment.id === 'ellipsis'" ...>
        <span class="px-2 py-1 text-gray-400">…</span>
      </li>
      <!-- Last segment (non-clickable) -->
      <li v-else-if="idx === visibleSegments.length - 1" ...>
        <span class="text-gray-900 font-medium">{{ segment.name }}</span>
      </li>
      <!-- Clickable segment -->
      <li v-else ...>
        <button @click="emit('navigate', segment.id)" class="text-indigo-600 hover:underline font-medium">
          {{ segment.name }}
        </button>
      </li>
    </template>
  </ol>
</nav>
```

**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 `<AppIcon name="chevronRight" class="w-3 h-3 text-gray-400" />` 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
<FolderBreadcrumb
  :segments="breadcrumb"
  @navigate="$emit('breadcrumb-navigate', $event)"
/>
```
After swap: `<BreadcrumbBar :segments="breadcrumb" @navigate="$emit('breadcrumb-navigate', $event)" />`

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 `<Teleport to="body">` + `z-[9999]` pattern (lines 37-91)

**Teleport pattern to copy** (SearchableModelSelect.vue lines 37-43):
```html
<Teleport to="body">
  <ul
    v-if="isOpen"
    :style="dropdownStyle"
    class="fixed z-[9999] bg-white border border-gray-200 rounded-lg shadow-xl overflow-y-auto"
    ...
  >
```

**Target ToastContainer structure** (Options API, matches D-01 through D-04):
```html
<template>
  <Teleport to="body">
    <div class="fixed bottom-4 right-4 z-[9999] flex flex-col-reverse gap-2 pointer-events-none">
      <TransitionGroup name="toast">
        <div
          v-for="toast in toastStore.toasts"
          :key="toast.id"
          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]"
          @click="toastStore.dismiss(toast.id)"
        >
          <!-- Left-border accent (D-02) -->
          <div class="w-1 self-stretch shrink-0" :class="accentClass(toast.type)"></div>
          <!-- Icon -->
          <AppIcon :name="iconName(toast.type)" class="w-5 h-5 shrink-0" :class="iconColorClass(toast.type)" />
          <!-- Message -->
          <p class="text-sm text-gray-800 flex-1 py-3 pr-4">{{ toast.message }}</p>
        </div>
      </TransitionGroup>
    </div>
  </Teleport>
</template>
```

**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
<!-- App.vue — add ToastContainer alongside router-view -->
<template>
  <AuthLayout v-if="route.meta.layout === 'auth'" />
  <div v-else class="flex h-screen overflow-hidden">
    <AppSidebar />
    <main class="flex-1 overflow-y-auto">
      <router-view ref="routeViewRef" />  <!-- add ref for keyboard shortcuts -->
    </main>
  </div>
  <ToastContainer />  <!-- mounts via Teleport, no positioning needed here -->
  <OsDragOverlay @files-dropped="handleOsDrop" />  <!-- mounts via Teleport -->
</template>
```

---

### `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
<template>
  <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"
      >
        <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>
</template>
```

---

### `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 `<script setup>` (Composition API). `onMounted()` already used for `topicsStore.fetchTopics()`.

**Keydown listener pattern to add** (mirrors DocumentPreviewModal.vue lines 118-135):
```javascript
// App.vue additions to <script setup>
import { ref, onMounted, onUnmounted } from 'vue'
// (useRoute already imported)

const routeViewRef = ref(null)  // ref on <router-view>

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))
```

**OsDrop handler:**
```javascript
function handleOsDrop(files) {
  routeViewRef.value?.handleOsDrop?.(files)  // optional chain — only FileManagerView exposes this
}
```

**Template modification pattern** (existing lines 1-9):
```html
<!-- BEFORE -->
<router-view />

<!-- AFTER -->
<router-view ref="routeViewRef" />
```

---

### `frontend/src/components/storage/StorageBrowser.vue` (MODIFY — component, CRUD)

**Current state baseline** (StorageBrowser.vue lines 1-374 — read completely above).

**Key existing patterns the planner will modify:**

1. **FolderBreadcrumb import/usage** (lines 7-10, 252):
   - Import line 252: `import FolderBreadcrumb from '../folders/FolderBreadcrumb.vue'`
   - Usage lines 7-10: `<FolderBreadcrumb :segments="breadcrumb" @navigate="$emit('breadcrumb-navigate', $event)" />`
   - Replace with `BreadcrumbBar` import + update usage to pass `showRoot: true`

2. **Current empty state block** (lines 226-241) — **replace with EmptyState.vue**:
   ```html
   <!-- lines 226-233: root/folder empty -->
   <div v-if="!loading && folders.length === 0 && files.length === 0 && !showNewFolderInput"
        class="px-4 py-10 text-center text-gray-300">
     <p class="text-gray-400 text-sm">{{ emptyMessage }}</p>
     <p class="text-xs mt-1">{{ emptyHint }}</p>
   </div>
   <!-- lines 235-241: search no-results -->
   <div v-else-if="searchQuery && files.length === 0 && folders.length === 0"
        class="px-4 py-10 text-center text-sm text-gray-400">
     No items match "{{ searchQuery }}".
   </div>
   ```

3. **Current loading state** (line 244) — **replace with skeleton rows**:
   ```html
   <!-- line 244 — replace this: -->
   <div v-if="loading" class="py-6 text-center text-sm text-gray-400">Loading…</div>
   ```
   Skeleton rows must match grid: `grid grid-cols-[2rem_1fr_6rem_8rem_6rem] gap-3 px-4 py-2.5`

4. **Current defineExpose** (line 327):
   ```javascript
   defineExpose({ startNewFolder })
   // ADD: triggerUpload, focusSearch
   ```

5. **Folder picker dropdown** (lines 196-213) — **current pattern (clips in overflow)**:
   ```html
   <div class="relative">
     <button @click.stop="folderPickerFileId = ... " ...>...</button>
     <div v-if="folderPickerFileId === file.id"
          class="absolute right-0 top-full mt-1 w-48 bg-white ...z-20">
       ...
     </div>
   </div>
   ```
   Replace with Teleport + getBoundingClientRect pattern (see Shared Patterns section).

6. **Existing drag-to-move state** (lines 341-362) — already implemented, do NOT rebuild:
   ```javascript
   const draggingFile = ref(null)       // line 341
   const dragOverFolderId = ref(null)   // line 342
   // onFileDragStart: lines 344-348
   // onFolderDragOver: lines 350-354
   // onDropDocOnFolder: lines 356-362 — emits 'file-move'
   ```
   Ring class is already wired (line 84): `'bg-amber-50 ring-2 ring-inset ring-amber-300': dragOverFolderId === folder.id`

---

### `frontend/src/components/layout/AppSidebar.vue` (MODIFY — component, CRUD)

**Current loading/empty text patterns to replace** (lines 101-103, 147-149, 163-164):
```html
<!-- line 101 — folder tree loading -->
<div v-if="loadingRoots" class="pl-7 py-1 text-xs text-gray-400">Loading…</div>
<!-- line 102-103 — folder tree empty -->
<div v-else-if="foldersStore.rootFolders.length === 0 && !showNewFolderInput"
     class="pl-7 py-1 text-xs text-gray-400">No folders yet</div>
<!-- line 147 — cloud loading -->
<div v-if="loadingCloudConnections" class="pl-7 py-1 text-xs text-gray-400">Loading…</div>
<!-- line 148-149 — cloud empty -->
<div v-else-if="activeCloudConnections.length === 0" class="pl-7 py-1 text-xs text-gray-400">
  No cloud storage connected
</div>
<!-- line 163 — topics loading -->
<div v-if="topicsStore.loading" class="px-3 py-1 text-xs text-gray-400">Loading…</div>
<!-- line 164 — topics empty -->
<div v-else-if="topicsStore.topics.length === 0" class="px-3 py-1 text-xs text-gray-400">No topics yet</div>
```

**UX-14 sidebar "New" button to remove** (lines 75-82):
```html
<button
  @click="startNewFolder"
  class="text-xs text-indigo-600 hover:underline shrink-0 mr-1"
  title="New root folder"
>
  New
</button>
```
Also remove: `startNewFolder()` (line 289-293), `cancelNewFolder()` (295-298), `submitNewFolder()` (300-312), and state: `showNewFolderInput`, `newFolderName`, `newFolderError` from `data`/`ref`. The inline new-folder `<div>` at lines 87-98 also gets deleted.

**Skeleton pattern** (replace `Loading…` text — must match `TreeItem.vue` indent):
```html
<!-- TreeItem renders children at paddingLeft: depth * 12px -->
<!-- Sidebar tree items are at depth=1, so pl-7 (28px = ~1 * 12 + 16) -->
<div class="pl-7 py-1 flex flex-col gap-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 flex-1"></div>
  </div>
</div>
```

---

### `frontend/src/views/FileManagerView.vue` (MODIFY — view, CRUD)

**Current state baseline** (FileManagerView.vue lines 1-158 — read completely above).

**Current `doMove` function** (lines 145-147) — **add toast**:
```javascript
// BEFORE
async function doMove(docId, folderId) {
  try { await docsStore.moveToFolder(docId, folderId) } catch (e) { console.error(e.message) }
}

// AFTER
async function doMove(docId, folderId) {
  try {
    await docsStore.moveToFolder(docId, folderId)
    useToastStore().show('Document moved', 'success')
  } catch (e) {
    useToastStore().show('Move failed: ' + (e.message || 'unknown error'), 'error')
  }
}
```

**Methods to expose via defineExpose** (for keyboard shortcuts from App.vue):
```javascript
// Add to FileManagerView.vue — these don't exist yet
defineExpose({
  focusSearch:    () => browserRef.value?.focusSearch?.(),
  triggerUpload:  () => browserRef.value?.triggerUpload?.(),
  startNewFolder: () => browserRef.value?.startNewFolder(),
  clearSearch:    () => { docsStore.searchQuery = '' },
  handleOsDrop:   (files) => onFilesSelected({ files, autoClassify: true }),
})
```

**browserRef already exists** (line 61): `const browserRef = ref(null)` with `ref="browserRef"` on `<StorageBrowser>` (line 16).

---

### `frontend/src/components/upload/DropZone.vue` (MODIFY — component, file-I/O)

**Current state** (DropZone.vue lines 1-62 — read completely above).

**`triggerInput` exists but not exposed** (lines 47-49):
```javascript
function triggerInput() {
  inputRef.value?.click()
}
// ADD:
defineExpose({ triggerInput })
```

---

### `frontend/src/components/documents/SearchBar.vue` (MODIFY — component, request-response)

**Current state** (SearchBar.vue lines 1-28 — read completely above). No `defineExpose` exists.

**Input ref and expose pattern to add:**
```javascript
// SearchBar.vue additions
import { ref } from 'vue'  // add to existing import (currently no script imports)

const inputEl = ref(null)  // add ref="inputEl" to the <input> element

defineExpose({
  focus() { inputEl.value?.focus() }
})
```

And on the `<input>` element (line 4), add `ref="inputEl"`.

---

### `frontend/src/views/SharedView.vue` (MODIFY — view, request-response)

**Current inline empty state** (lines 9-13):
```html
<div v-else-if="sharedDocs.length === 0" class="text-center py-12 text-gray-400">
  <p class="text-sm font-medium text-gray-500">No documents shared with you yet.</p>
  <p class="text-xs mt-1">When someone shares a document with you, it will appear here.</p>
</div>
```

**Replace with** (per Section 3 EmptyState inventory):
```html
<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."
/>
```

---

### `frontend/src/views/CloudStorageView.vue` (MODIFY — view, request-response)

**Current inline empty state** (lines 23-28):
```html
<div v-else-if="connections.length === 0" class="text-center py-12 text-gray-400">
  <p class="text-sm">No cloud storage connected.</p>
  <router-link to="/settings" class="text-sm text-indigo-600 hover:underline mt-1 inline-block">
    Add a connection in Settings
  </router-link>
</div>
```

**Replace with** (CTA slot contains the settings link):
```html
<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>
```

---

### `frontend/src/views/admin/AdminAuditView.vue` (MODIFY — view, CRUD)

**Current loading state** (lines 78-83):
```html
<div v-if="loading" class="bg-white rounded-xl border border-gray-200 p-8 text-center">
  <div class="flex items-center justify-center gap-2 text-gray-400 text-sm">
    <span class="animate-spin rounded-full border-2 border-current border-t-transparent w-4 h-4"></span>
    Loading audit log…
  </div>
</div>
```

**Table row pattern** (lines 102-126) — skeleton must match these columns: Timestamp | User | Email | Action Type | IP Address.

**Skeleton row pattern** (5 columns, mirrors real `<tr>` structure):
```html
<tr v-for="n in 8" :key="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>
```

**Current empty state** (line 87-89):
```html
<div v-else-if="entries.length === 0" class="text-center py-12 text-gray-400 text-sm">
  No audit log entries match the selected filters.
</div>
```
Replace with `<EmptyState icon="clipboardList" headline="No entries found" subtext="Try adjusting your filters or date range." />`.

---

### `frontend/src/views/admin/AdminUsersView.vue` (MODIFY — view, CRUD)

**Current loading state** (lines 93-98):
```html
<div v-if="loading" class="bg-white rounded-xl border border-gray-200 p-8 text-center">
  <div class="flex items-center justify-center gap-2 text-gray-400 text-sm">
    <span class="animate-spin rounded-full border-2 border-current border-t-transparent w-4 h-4"></span>
    Loading users…
  </div>
</div>
```

**Table header** (lines 108-115) — skeleton must match: Email | Handle | Role | Status | Created | Actions.

**Skeleton row pattern** (6 columns):
```html
<tr v-for="n in 5" :key="n" class="border-b border-gray-200">
  <td class="px-4 py-3"><div class="h-3 bg-gray-100 rounded animate-pulse w-40"></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-5 bg-gray-100 rounded animate-pulse w-12"></div></td>
  <td class="px-4 py-3"><div class="h-5 bg-gray-100 rounded animate-pulse w-16"></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-16"></div></td>
</tr>
```

---

### `frontend/src/components/documents/DocumentCard.vue` + `FolderRow.vue` (MODIFY — dropdown Teleport fix)

**Analog for fix:** `frontend/src/components/ui/SearchableModelSelect.vue` — the complete Teleport + getBoundingClientRect pattern (lines 37-91, 143-163, 269-276).

**Current broken pattern in DocumentCard.vue** (lines 63-91):
```html
<div class="relative">
  <button @click.stop="toggleFolderPicker" ...>  <!-- trigger -->
  </button>
  <!-- THIS CLIPS: absolute positioning inside overflow parent -->
  <div v-if="showFolderPicker" class="absolute right-0 top-full mt-1 w-48 ... z-20">
    ...
  </div>
</div>
```

**Current broken pattern in FolderRow.vue** (lines 37-66):
```html
<div class="relative shrink-0" @click.stop>
  <button @click="toggleMenu" ...>  <!-- trigger -->
  </button>
  <!-- clips at viewport edge -->
  <div v-if="menuOpen" class="absolute right-0 top-full mt-1 ... z-10">
    ...
  </div>
</div>
```

**SearchableModelSelect position calculation pattern to copy** (lines 143-163):
```javascript
// Store ref on trigger button
const inputEl = ref(null)  // → ref on trigger button
const dropdownStyle = ref({})

function updatePosition() {
  if (!inputEl.value) return
  const rect = inputEl.value.getBoundingClientRect()
  const spaceBelow = window.innerHeight - rect.bottom
  const dropH = 200  // approximate picker height

  if (spaceBelow >= dropH || spaceBelow > 120) {
    dropdownStyle.value = {
      top: `${rect.bottom + 4}px`,
      left: `${rect.left}px`,
      width: '192px',  // w-48 = 12rem = 192px
    }
  } else {
    dropdownStyle.value = {
      bottom: `${window.innerHeight - rect.top + 4}px`,
      left: `${rect.left}px`,
      width: '192px',
    }
  }
}
```

**SearchableModelSelect scroll handling** (lines 269-276):
```javascript
function onScroll() { if (isOpen.value) updatePosition() }
window.addEventListener('scroll', onScroll, true)
window.addEventListener('resize', onScroll)
onUnmounted(() => {
  window.removeEventListener('scroll', onScroll, true)
  window.removeEventListener('resize', onScroll)
})
```

**Teleported dropdown** (SearchableModelSelect.vue lines 37-43):
```html
<Teleport to="body">
  <ul v-if="isOpen" :style="dropdownStyle"
      class="fixed z-[9999] bg-white border border-gray-200 rounded-xl shadow-lg overflow-y-auto py-1"
      style="max-height: 240px;">
    ...
  </ul>
</Teleport>
```

---

## Shared Patterns

### 1. Inline SVG → AppIcon Migration

**Source:** All 29 files with inline `<svg>` blocks (see RESEARCH.md Section 1 for full audit)
**Apply to:** All 29 files in Wave 3

**Before pattern** (e.g., AppSidebar.vue line 16-19):
```html
<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 pattern:**
```html
<AppIcon name="tag" class="w-4 h-4 mr-2 shrink-0" />
```

**Class forwarding:** `AppIcon` uses `inheritAttrs: false` + `:class="$attrs.class"` so all Tailwind sizing/color/margin classes pass through identically.

**Special case — Settings dual-path icon** (AppSidebar.vue lines 206-210):
```html
<!-- BEFORE: two <path> elements in one <svg> -->
<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="M10.325 4.317..." />
  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0..." />
</svg>
<!-- AFTER: single AppIcon, Array.isArray handling inside AppIcon -->
<AppIcon name="cog" class="w-4 h-4 mr-2 shrink-0" />
```

**Special case — FolderRow fill-based dots icon** (FolderRow.vue line 43-45):
```html
<!-- BEFORE: fill="currentColor" -->
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
  <path d="M10 6a2 2 0 110-4 2 2 0 010 4zm0 6a2 2 0 110-4..." />
</svg>
<!-- AFTER: stroke equivalent in AppIcon map -->
<AppIcon name="dots" class="w-4 h-4" />
```

---

### 2. Teleport + getBoundingClientRect Dropdown Fix

**Source:** `frontend/src/components/ui/SearchableModelSelect.vue` (lines 37-91, 143-163, 269-276)
**Apply to:** `StorageBrowser.vue` (folder picker), `DocumentCard.vue` (folder picker), `FolderRow.vue` (three-dot menu)

Pattern steps:
1. Add `ref` to the trigger `<button>` element
2. Store `dropdownStyle = ref({})` reactive object
3. Call `updatePosition()` (using `getBoundingClientRect()`) in the open handler
4. Wrap the dropdown `<div>` in `<Teleport to="body">` with `class="fixed z-[9999]"` and `:style="dropdownStyle"`
5. Register `window.addEventListener('scroll', onScroll, true)` in `onMounted` and clean up in `onUnmounted`

---

### 3. `animate-pulse` Skeleton Loading

**Source:** Tailwind built-in (already in project bundle)
**Apply to:** StorageBrowser.vue, AppSidebar.vue, AdminUsersView.vue, AdminAuditView.vue

**5-column StorageBrowser skeleton** (matches existing grid — StorageBrowser.vue line 43):
```html
<!-- Grid class from existing column headers: grid-cols-[2rem_1fr_6rem_8rem_6rem] -->
<div v-if="loading">
  <div
    v-for="n in 8"
    :key="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>
</div>
```

**Sidebar skeleton** (matches `pl-7 py-1` tree indent):
```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>
```

---

### 4. Options API Component Structure

**Apply to:** All NEW components (AppIcon, EmptyState, BreadcrumbBar, ToastContainer, OsDragOverlay)

**Reference:** AppSidebar.vue (lines 235-313) — the project's canonical Options API component style:
```javascript
// Standard Options API structure for new components
export default {
  name: 'ComponentName',
  components: { /* imports */ },
  props: {
    propName: { type: String, required: true },
    optionalProp: { type: Boolean, default: false },
  },
  emits: ['event-name'],
  data() {
    return { localState: null }
  },
  computed: {
    derivedValue() { return this.propName + '...' }
  },
  methods: {
    handleAction() { this.$emit('event-name', payload) }
  },
  mounted() { /* setup */ },
  beforeUnmount() { /* cleanup */ },
}
```

**Exception:** `BreadcrumbBar.vue` can use `<script setup>` since it directly replaces `FolderBreadcrumb.vue` which already uses `<script setup>`.

---

### 5. Pinia Store Pattern (setup store)

**Source:** `frontend/src/stores/toast.js` (stub) — already uses setup-store form `defineStore('id', () => { ... })`
**Apply to:** toast.js implementation only

The setup-store form is the correct pattern for toast.js because the stub already uses it. Do not convert to options-store.

---

## No Analog Found

| File | Role | Data Flow | Reason |
|---|---|---|---|
| `<TransitionGroup name="toast">` CSS | utility | — | No existing CSS transitions in the project for stacked notifications; planner must define `toast-enter-active`, `toast-leave-active` CSS in ToastContainer.vue `<style>` |

---

## Metadata

**Analog search scope:** `frontend/src/components/`, `frontend/src/views/`, `frontend/src/stores/`
**Files read:** 18 source files read in full
**Pattern extraction date:** 2026-06-14
