# 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 ``` **Key conventions to copy:** - `inheritAttrs: false` so `$attrs.class` passes through to the `` element - All existing inline SVGs use `fill="none" stroke="currentColor" viewBox="0 0 24 24"` — match exactly - All existing path attrs: `stroke-linecap="round" stroke-linejoin="round" stroke-width="2"` — match exactly - `aria-hidden="true"` on the `` (decorative icons; label is on parent element) - Dev-mode `console.warn` for unknown names; renders nothing (`v-if="paths[name]"`) --- ### `frontend/src/components/ui/EmptyState.vue` (NEW — component, request-response) **Analog:** Inline empty-state blocks in `SharedView.vue` (lines 9-13), `StorageBrowser.vue` (lines 226-241), `AppSidebar.vue` (lines 102-103, 148-149, 163-164). The pattern is consistent: centered container, small text, optional action. **Current inline pattern to replace** (SharedView.vue lines 9-13): ```html No documents shared with you yet. When someone shares a document with you, it will appear here. ``` **Current inline pattern to replace** (StorageBrowser.vue lines 226-233): ```html {{ emptyMessage }} {{ emptyHint }} ``` **Current inline pattern for sidebar** (AppSidebar.vue lines 102-103): ```html No folders yet ``` **Target component structure for EmptyState.vue** (Options API, consistent with component convention): ```html {{ headline }} {{ subtext }} ``` **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 Home … {{ segment.name }} {{ segment.name }} ``` **What BreadcrumbBar.vue adds over FolderBreadcrumb.vue:** 1. Renamed prop shape: `segments: [{ id?, label }]` — `label` replaces `name` 2. New props: `rootLabel` (String, default `'Home'`) and `showRoot` (Boolean, default `true`) 3. Segments without `id` are non-clickable (admin static breadcrumbs) 4. Use `` instead of inline SVG **BreadcrumbBar prop interface:** ```javascript props: { segments: { type: Array, default: () => [] }, rootLabel: { type: String, default: 'Home' }, showRoot: { type: Boolean, default: true }, } emits: ['navigate'] // emits segment.id (or null for root) ``` **How StorageBrowser currently uses FolderBreadcrumb** (StorageBrowser.vue lines 7-10): ```html ``` After swap: `` Existing breadcrumb prop shape from FileManagerView (passes `foldersStore.breadcrumb` which returns `[{ id, name }]`) — segments must be remapped to `{ id, label: name }` or BreadcrumbBar must accept both `name` and `label` for backward compat. --- ### `frontend/src/components/ui/ToastContainer.vue` (NEW — component, event-driven) **Analog:** `frontend/src/components/ui/SearchableModelSelect.vue` — the `` + `z-[9999]` pattern (lines 37-91) **Teleport pattern to copy** (SearchableModelSelect.vue lines 37-43): ```html ``` **Target ToastContainer structure** (Options API, matches D-01 through D-04): ```html {{ toast.message }} ``` **Toast type → classes mapping:** - `success`: accent `bg-green-500`, icon `checkCircle` `text-green-500` - `error`: accent `bg-red-500`, icon `exclamationCircle` `text-red-500` - `warning`: accent `bg-amber-400`, icon `warning` `text-amber-500` - `info`: accent `bg-sky-400`, icon `exclamationCircle` `text-sky-500` **App.vue mount point** (App.vue current lines 1-21): ```html ``` --- ### `frontend/src/components/layout/OsDragOverlay.vue` (NEW — component, event-driven) **Analog:** `frontend/src/components/documents/DocumentPreviewModal.vue` — `onMounted`/`onUnmounted` window-level event listener pattern (lines 118-135) **Keydown listener pattern to copy** (DocumentPreviewModal.vue lines 112-135): ```javascript function handleKeydown(e) { if (e.key === 'Escape') { emit('close') } } onMounted(() => { document.addEventListener('keydown', handleKeydown) loadContent(props.doc.id) }) onUnmounted(() => { document.removeEventListener('keydown', handleKeydown) // cleanup... }) ``` **Adapt for window drag events** (Options API, D-16 depth counter from Pitfall 3): ```javascript export default { name: 'OsDragOverlay', emits: ['files-dropped'], data() { return { dragDepth: 0, showOverlay: false } }, methods: { onDragEnter(e) { if (!e.dataTransfer?.types.includes('Files')) return this.dragDepth++ this.showOverlay = true }, onDragLeave() { this.dragDepth = Math.max(0, this.dragDepth - 1) if (this.dragDepth === 0) this.showOverlay = false }, onDragOver(e) { e.preventDefault() }, onDrop(e) { e.preventDefault() this.dragDepth = 0 this.showOverlay = false const files = Array.from(e.dataTransfer.files) if (files.length) this.$emit('files-dropped', files) }, }, mounted() { window.addEventListener('dragenter', this.onDragEnter) window.addEventListener('dragleave', this.onDragLeave) window.addEventListener('dragover', this.onDragOver) window.addEventListener('drop', this.onDrop) }, beforeUnmount() { window.removeEventListener('dragenter', this.onDragEnter) window.removeEventListener('dragleave', this.onDragLeave) window.removeEventListener('dragover', this.onDragOver) window.removeEventListener('drop', this.onDrop) }, } ``` **Overlay template** (z-index below toasts per Pitfall 6 — `z-[9998]`): ```html Drop files to upload ``` --- ### `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 `
No documents shared with you yet.
When someone shares a document with you, it will appear here.
{{ emptyMessage }}
{{ emptyHint }}
{{ headline }}
{{ subtext }}
{{ toast.message }}
Drop files to upload