Moves phases 08–11 execution artifacts from .planning/phases/ to .planning/milestones/v0.2-phases/ to keep .planning/phases/ clean for the next milestone. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
41 KiB
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):
<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: falseso$attrs.classpasses 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.warnfor 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):
<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):
<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):
<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):
<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 aspl-7 py-1 text-xs text-gray-400)- Default
size="md"for StorageBrowser, SharedView, CloudStorageView, AdminAuditView #ctaslot 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):
// 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):
<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:
- Renamed prop shape:
segments: [{ id?, label }]—labelreplacesname - New props:
rootLabel(String, default'Home') andshowRoot(Boolean, defaulttrue) - Segments without
idare non-clickable (admin static breadcrumbs) - Use
<AppIcon name="chevronRight" class="w-3 h-3 text-gray-400" />instead of inline SVG
BreadcrumbBar prop interface:
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):
<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):
<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):
<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: accentbg-green-500, iconcheckCircletext-green-500error: accentbg-red-500, iconexclamationCircletext-red-500warning: accentbg-amber-400, iconwarningtext-amber-500info: accentbg-sky-400, iconexclamationCircletext-sky-500
App.vue mount point (App.vue current lines 1-21):
<!-- 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):
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):
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]):
<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):
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):
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):
// 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:
function handleOsDrop(files) {
routeViewRef.value?.handleOsDrop?.(files) // optional chain — only FileManagerView exposes this
}
Template modification pattern (existing lines 1-9):
<!-- 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:
-
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
BreadcrumbBarimport + update usage to passshowRoot: true
- Import line 252:
-
Current empty state block (lines 226-241) — replace with EmptyState.vue:
<!-- 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> -
Current loading state (line 244) — replace with skeleton rows:
<!-- 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 -
Current defineExpose (line 327):
defineExpose({ startNewFolder }) // ADD: triggerUpload, focusSearch -
Folder picker dropdown (lines 196-213) — current pattern (clips in overflow):
<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).
-
Existing drag-to-move state (lines 341-362) — already implemented, do NOT rebuild:
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):
<!-- 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):
<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):
<!-- 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:
// 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):
// 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):
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:
// 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):
<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):
<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):
<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):
<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):
<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):
<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):
<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):
<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):
<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):
<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):
<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):
// 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):
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):
<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):
<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:
<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):
<!-- 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):
<!-- 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:
- Add
refto the trigger<button>element - Store
dropdownStyle = ref({})reactive object - Call
updatePosition()(usinggetBoundingClientRect()) in the open handler - Wrap the dropdown
<div>in<Teleport to="body">withclass="fixed z-[9999]"and:style="dropdownStyle" - Register
window.addEventListener('scroll', onScroll, true)inonMountedand clean up inonUnmounted
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):
<!-- 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):
<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:
// 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