Files
kite/.planning/phases/10-ux-interaction/10-PATTERNS.md
T

1088 lines
41 KiB
Markdown

# 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