Files
kite/.planning/milestones/v0.2-phases/10-ux-interaction/10-PATTERNS.md
T
curo1305andClaude Sonnet 4.6 123ae5b29b chore: archive v0.2 phase directories to milestones/v0.2-phases/
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>
2026-06-17 14:34:52 +02:00

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

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

// 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:

  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:

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

<!-- 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.vueonMounted/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:

  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:

    <!-- 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:

    <!-- 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):

    defineExpose({ startNewFolder })
    // ADD: triggerUpload, focusSearch
    
  5. 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).

  6. 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:

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

<!-- 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