Appearance
Chat Search — Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Add message search to the chat system — search within a single conversation and global search across all conversations.
Architecture: New FSD feature slice chat-search with two composables and two UI components. MessageBubble gains text highlighting. useMessages gains scrollToMessage. Both pages integrate search via composables.
Tech Stack: Nuxt 4 / TypeScript / Nuxt UI v3 / Vue 3.5 / VueUse / Zod
Design doc: docs/plans/2026-02-26-chat-search-design.md
Task 1: Add i18n keys for chat search [S]
Files:
- Modify:
i18n/locales/ru.json(lines 443-468, inside"chat"object)
Step 1: Add search keys to the chat section
In i18n/locales/ru.json, inside the "chat" object, add these keys after "imageCounter":
json
"imageCounter": "{current} из {total}",
"searchPlaceholder": "Поиск по сообщениям...",
"searchAllPlaceholder": "Поиск по всем чатам...",
"noSearchResults": "Ничего не найдено",
"searchPrev": "Предыдущее совпадение",
"searchNext": "Следующее совпадение",
"searchClose": "Закрыть поиск",
"searchLoading": "Загрузка сообщений...",
"loadMore": "Загрузить ещё"Step 2: Run lint
Run: npm run lint -- --no-error-on-unmatched-pattern i18n/locales/ru.json Expected: No errors
Step 3: Commit
bash
git add i18n/locales/ru.json
git commit -m "feat(chat): add i18n keys for message search"Task 2: Add highlight and data-message-id to MessageBubble [S]
Files:
- Modify:
app/entities/message/ui/MessageBubble.vue
Step 1: Add highlightQuery prop
In the defineProps, add highlightQuery after companionId:
ts
const props = defineProps<{
message: GhostMessage
isMine: boolean
showAvatar?: boolean
avatarUrl?: string | null
displayName?: string
companionId?: number
highlightQuery?: string
}>()Step 2: Add highlight helper function
After the existing computed properties (after line 27), add:
ts
function highlightText(text: string, query: string): string {
if (!query || query.length < 2) return text
const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
return text.replace(
new RegExp(`(${escaped})`, 'gi'),
'<mark class="bg-yellow-200 dark:bg-yellow-800 rounded px-0.5">$1</mark>',
)
}
const highlightedText = computed(() => {
if (!props.message.text) return ''
if (!props.highlightQuery) return ''
return highlightText(props.message.text, props.highlightQuery)
})Step 3: Add data-message-id to the root elements
On the system message <div> (line 32), add the attribute:
html
<div v-if="isSystem" class="my-2 flex justify-center" :data-message-id="message.id">On the regular message <div> (line 41), add the attribute:
html
<div v-else class="flex items-end gap-2" :class="isMine ? 'justify-end' : 'justify-start'" :data-message-id="message.id">Step 4: Replace text rendering with highlight-aware version
Replace line 87:
html
<p v-if="message.text" class="whitespace-pre-wrap break-words text-sm">
{{ message.text }}
</p>With:
html
<p v-if="message.text" class="whitespace-pre-wrap break-words text-sm">
<span v-if="highlightedText" v-html="highlightedText" />
<template v-else>{{ message.text }}</template>
</p>Step 5: Run lint
Run: npm run lint:fix -- app/entities/message/ui/MessageBubble.vue Expected: Clean or auto-fixed
Step 6: Commit
bash
git add app/entities/message/ui/MessageBubble.vue
git commit -m "feat(chat): add text highlighting and data-message-id to MessageBubble"Task 3: Add scrollToMessage to useMessages [M]
Files:
- Modify:
app/features/chat-messaging/composables/useMessages.ts
Step 1: Add isScrolling ref and scrollToMessage function
After line 11 (const cursor = ref<string | null>(null)), add:
ts
const isScrollingToMessage = ref(false)Before the return block (before line 217), add the scrollToMessage function:
ts
async function scrollToMessage(messageId: number): Promise<boolean> {
isScrollingToMessage.value = true
try {
await nextTick()
let el = document.querySelector(`[data-message-id="${messageId}"]`)
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
return true
}
while (hasMore.value) {
await loadOlder()
await nextTick()
el = document.querySelector(`[data-message-id="${messageId}"]`)
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
return true
}
}
return false
}
finally {
isScrollingToMessage.value = false
}
}Step 2: Expose in return
Update the return block to include the new exports:
ts
return {
messages: readonly(messages),
hasMore: readonly(hasMore),
isLoading: readonly(isLoading),
isScrollingToMessage: readonly(isScrollingToMessage),
loadOlder,
send,
sendImage,
retryImage,
markRead,
scrollToMessage,
}Step 3: Run lint
Run: npm run lint:fix -- app/features/chat-messaging/composables/useMessages.ts Expected: Clean
Step 4: Commit
bash
git add app/features/chat-messaging/composables/useMessages.ts
git commit -m "feat(chat): add scrollToMessage to useMessages composable"Task 4: Create useConversationSearch composable [M]
Files:
- Create:
app/features/chat-search/composables/useConversationSearch.ts
Step 1: Create the composable
ts
// app/features/chat-search/composables/useConversationSearch.ts
import { useDebounceFn } from '@vueuse/core'
import type { Message } from '~/entities/message/model/message.schema'
import type { ApiListResponse } from '~/shared/api/types'
const MIN_QUERY_LENGTH = 2
export function useConversationSearch(conversationId: Ref<number>) {
const api = useApi()
const query = ref('')
const results = ref<Message[]>([])
const currentIndex = ref(0)
const isSearching = ref(false)
const isOpen = ref(false)
const totalMatches = computed(() => results.value.length)
const currentMatch = computed(() => (totalMatches.value > 0 ? currentIndex.value + 1 : 0))
const currentMessageId = computed(() => results.value[currentIndex.value]?.id ?? null)
const highlightQuery = computed(() => (results.value.length > 0 ? query.value : ''))
const matchedIds = computed(() => new Set(results.value.map(m => m.id)))
async function search() {
const q = query.value.trim()
if (q.length < MIN_QUERY_LENGTH) {
results.value = []
currentIndex.value = 0
return
}
isSearching.value = true
try {
const res = await api.get<ApiListResponse<Message>>(
`/vendor/conversations/${conversationId.value}/messages/search`,
{ q, limit: 50 },
)
results.value = res.data
currentIndex.value = 0
}
catch {
results.value = []
}
finally {
isSearching.value = false
}
}
const debouncedSearch = useDebounceFn(search, 300)
watch(query, () => debouncedSearch())
function goNext() {
if (totalMatches.value === 0) return
currentIndex.value = (currentIndex.value + 1) % totalMatches.value
}
function goPrev() {
if (totalMatches.value === 0) return
currentIndex.value = (currentIndex.value - 1 + totalMatches.value) % totalMatches.value
}
function open() {
isOpen.value = true
}
function close() {
isOpen.value = false
query.value = ''
results.value = []
currentIndex.value = 0
}
return {
query,
results,
currentIndex,
isSearching,
isOpen,
totalMatches,
currentMatch,
currentMessageId,
highlightQuery,
matchedIds,
goNext,
goPrev,
open,
close,
}
}Step 2: Run lint
Run: npm run lint:fix -- app/features/chat-search/composables/useConversationSearch.ts Expected: Clean
Step 3: Commit
bash
git add app/features/chat-search/
git commit -m "feat(chat): add useConversationSearch composable"Task 5: Create ChatSearchBar component [M]
Files:
- Create:
app/features/chat-search/ui/ChatSearchBar.vue
Step 1: Create the component
html
<!-- app/features/chat-search/ui/ChatSearchBar.vue -->
<script setup lang="ts">
defineProps<{
query: string
totalMatches: number
currentMatch: number
isSearching: boolean
}>()
const emit = defineEmits<{
'update:query': [value: string]
next: []
prev: []
close: []
}>()
const inputRef = ref<HTMLInputElement>()
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
emit('close')
}
else if (e.key === 'Enter') {
e.preventDefault()
if (e.shiftKey) {
emit('prev')
}
else {
emit('next')
}
}
}
onMounted(() => {
inputRef.value?.focus()
})
</script>
<template>
<div class="flex items-center gap-2 border-b border-gray-200 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-800/50">
<UIcon name="i-lucide-search" class="shrink-0 text-gray-400" />
<input
ref="inputRef"
:value="query"
type="text"
class="flex-1 bg-transparent text-sm outline-none placeholder-gray-400 dark:text-white"
:placeholder="$t('chat.searchPlaceholder')"
@input="emit('update:query', ($event.target as HTMLInputElement).value)"
@keydown="onKeydown"
/>
<span v-if="isSearching" class="shrink-0">
<UIcon name="i-lucide-loader-2" class="h-4 w-4 animate-spin text-gray-400" />
</span>
<span v-else-if="totalMatches > 0" class="shrink-0 text-xs text-gray-500 whitespace-nowrap">
{{ currentMatch }}/{{ totalMatches }}
</span>
<span v-else-if="query.length >= 2" class="shrink-0 text-xs text-gray-400 whitespace-nowrap">
{{ $t('chat.noSearchResults') }}
</span>
<UButton
v-if="totalMatches > 1"
icon="i-lucide-chevron-up"
variant="ghost"
size="xs"
:aria-label="$t('chat.searchPrev')"
@click="emit('prev')"
/>
<UButton
v-if="totalMatches > 1"
icon="i-lucide-chevron-down"
variant="ghost"
size="xs"
:aria-label="$t('chat.searchNext')"
@click="emit('next')"
/>
<UButton
icon="i-lucide-x"
variant="ghost"
size="xs"
:aria-label="$t('chat.searchClose')"
@click="emit('close')"
/>
</div>
</template>Step 2: Run lint
Run: npm run lint:fix -- app/features/chat-search/ui/ChatSearchBar.vue Expected: Clean
Step 3: Commit
bash
git add app/features/chat-search/ui/ChatSearchBar.vue
git commit -m "feat(chat): add ChatSearchBar component"Task 6: Integrate in-conversation search into [id].vue [M]
Files:
- Modify:
app/pages/cabinet/messages/[id].vue
Step 1: Import search composable and set it up
After line 20 (the useMessages destructuring), add:
ts
const search = useConversationSearch(conversationId)Step 2: Add Ctrl+F / Cmd+F keyboard shortcut
After onMounted(() => { markRead() }) (line 153), add:
ts
function onSearchKeydown(e: KeyboardEvent) {
if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
e.preventDefault()
search.open()
}
}
onMounted(() => document.addEventListener('keydown', onSearchKeydown))
onUnmounted(() => document.removeEventListener('keydown', onSearchKeydown))Step 3: Watch currentMessageId for scroll navigation
After the keyboard shortcut code, add:
ts
watch(
() => search.currentMessageId.value,
(messageId) => {
if (messageId === null) return
scrollToMessage(messageId)
},
)Step 4: Handle ?highlight= query param from global search
After the watcher above, add:
ts
const highlightMessageId = computed(() => {
const id = route.query.highlight
return id ? Number(id) : null
})
watch(highlightMessageId, async (id) => {
if (!id) return
// Wait for initial messages to load
await nextTick()
const found = await scrollToMessage(id)
if (found) {
await nextTick()
const el = document.querySelector(`[data-message-id="${id}"]`)
if (el) {
el.classList.add('ring-2', 'ring-yellow-400')
setTimeout(() => el.classList.remove('ring-2', 'ring-yellow-400'), 2000)
}
}
// Clean up URL
navigateTo(route.path, { replace: true })
}, { immediate: true })Step 5: Add search button to header
In the header section, after the closing </NuxtLink> (line 209), add a search button:
html
<UButton
variant="ghost"
icon="i-lucide-search"
size="sm"
@click="search.open()"
/>Step 6: Add ChatSearchBar below the product strip
After the product context strip </NuxtLink> (line 234), add:
html
<!-- Search bar -->
<FeaturesChatSearchUiChatSearchBar
v-if="search.isOpen.value"
:query="search.query.value"
:total-matches="search.totalMatches.value"
:current-match="search.currentMatch.value"
:is-searching="search.isSearching.value"
@update:query="search.query.value = $event"
@next="search.goNext()"
@prev="search.goPrev()"
@close="search.close()"
/>Note: Using auto-imported component name FeaturesChatSearchUiChatSearchBar per FSD conventions (layer prefix + path). If auto-import doesn't resolve this name, use explicit import:
ts
import ChatSearchBar from '~/features/chat-search/ui/ChatSearchBar.vue'and <ChatSearchBar ... /> in the template.
Step 7: Pass highlightQuery to MessageBubble
Update the <MessageBubble> component (around line 243) to include the highlight prop:
html
<MessageBubble
v-for="msg in messagesWithMeta"
:key="msg.id"
:message="msg"
:is-mine="msg.isMine"
:show-avatar="msg.showAvatar"
:avatar-url="msg.avatarUrl ?? null"
:display-name="msg.displayName ?? ''"
:companion-id="conversation?.companion.id"
:highlight-query="search.highlightQuery.value"
@image-click="handleImageClick"
@retry-upload="retryImage"
/>Step 8: Add isScrollingToMessage loading indicator
In the messages container, after the existing loading spinner (line 240-242), add a search-loading indicator:
html
<div v-if="isScrollingToMessage" class="flex justify-center py-4">
<div class="flex items-center gap-2 text-sm text-gray-400">
<UIcon name="i-lucide-loader-2" class="h-4 w-4 animate-spin" />
{{ t('chat.searchLoading') }}
</div>
</div>And update the destructuring of useMessages (line 20) to include isScrollingToMessage and scrollToMessage:
ts
const { messages, hasMore, isLoading, isScrollingToMessage, loadOlder, send, sendImage, retryImage, markRead, scrollToMessage } = useMessages(conversationId)Step 9: Run lint
Run: npm run lint:fix -- app/pages/cabinet/messages/\\[id\\].vue Expected: Clean
Step 10: Commit
bash
git add app/pages/cabinet/messages/\\[id\\].vue
git commit -m "feat(chat): integrate in-conversation search into message page"Task 7: Create useGlobalSearch composable [M]
Files:
- Create:
app/features/chat-search/composables/useGlobalSearch.ts
Step 1: Create the composable
ts
// app/features/chat-search/composables/useGlobalSearch.ts
import { useDebounceFn } from '@vueuse/core'
import type { Message } from '~/entities/message/model/message.schema'
import type { ApiListResponse } from '~/shared/api/types'
export interface GlobalSearchMessage extends Message {
conversation: {
id: number
companion: { id: number; name: string }
product: { id: number; title: string }
}
}
export interface GlobalSearchGroup {
conversation: GlobalSearchMessage['conversation']
messages: GlobalSearchMessage[]
}
const MIN_QUERY_LENGTH = 2
export function useGlobalSearch() {
const api = useApi()
const query = ref('')
const results = ref<GlobalSearchMessage[]>([])
const isSearching = ref(false)
const hasMore = ref(false)
const cursor = ref<string | undefined>()
const grouped = computed<GlobalSearchGroup[]>(() => {
const map = new Map<number, GlobalSearchGroup>()
for (const msg of results.value) {
const convId = msg.conversation.id
const existing = map.get(convId)
if (existing) {
existing.messages.push(msg)
}
else {
map.set(convId, { conversation: msg.conversation, messages: [msg] })
}
}
return Array.from(map.values())
})
async function search() {
const q = query.value.trim()
if (q.length < MIN_QUERY_LENGTH) {
results.value = []
hasMore.value = false
cursor.value = undefined
return
}
isSearching.value = true
try {
const res = await api.get<ApiListResponse<GlobalSearchMessage>>(
'/vendor/messages/search',
{ q, limit: 20 },
)
results.value = res.data
hasMore.value = res.meta.has_more
cursor.value = res.meta.next_cursor ?? undefined
}
catch {
results.value = []
}
finally {
isSearching.value = false
}
}
async function loadMore() {
if (!hasMore.value || !cursor.value) return
isSearching.value = true
try {
const res = await api.get<ApiListResponse<GlobalSearchMessage>>(
'/vendor/messages/search',
{ q: query.value.trim(), limit: 20, cursor: cursor.value },
)
results.value.push(...res.data)
hasMore.value = res.meta.has_more
cursor.value = res.meta.next_cursor ?? undefined
}
catch {
// keep existing results
}
finally {
isSearching.value = false
}
}
const debouncedSearch = useDebounceFn(search, 300)
watch(query, () => {
cursor.value = undefined
debouncedSearch()
})
function clear() {
query.value = ''
results.value = []
hasMore.value = false
cursor.value = undefined
}
return {
query,
results,
grouped,
isSearching,
hasMore,
loadMore,
clear,
}
}Step 2: Run lint
Run: npm run lint:fix -- app/features/chat-search/composables/useGlobalSearch.ts Expected: Clean
Step 3: Commit
bash
git add app/features/chat-search/composables/useGlobalSearch.ts
git commit -m "feat(chat): add useGlobalSearch composable"Task 8: Create GlobalSearchResults component [M]
Files:
- Create:
app/features/chat-search/ui/GlobalSearchResults.vue
Step 1: Create the component
html
<!-- app/features/chat-search/ui/GlobalSearchResults.vue -->
<script setup lang="ts">
import type { GlobalSearchGroup } from '../composables/useGlobalSearch'
defineProps<{
grouped: GlobalSearchGroup[]
query: string
isSearching: boolean
hasMore: boolean
}>()
const emit = defineEmits<{
loadMore: []
select: [conversationId: number, messageId: number]
}>()
function highlightText(text: string, query: string): string {
if (!query || query.length < 2) return escapeHtml(text)
const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const safeText = escapeHtml(text)
const safeQuery = escapeHtml(query)
// Re-apply highlight on escaped HTML
return safeText.replace(
new RegExp(`(${escaped.replace(/[<>&"']/g, (c) => `&#${c.charCodeAt(0)};`)})`, 'gi'),
'<mark class="bg-yellow-200 dark:bg-yellow-800 rounded px-0.5">$1</mark>',
)
}
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleString('ru-RU', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
</script>
<template>
<div class="flex flex-col gap-4 py-3">
<div v-if="isSearching && grouped.length === 0" class="flex justify-center py-8">
<UIcon name="i-lucide-loader-2" class="h-6 w-6 animate-spin text-gray-400" />
</div>
<p v-else-if="!isSearching && grouped.length === 0 && query.length >= 2" class="py-8 text-center text-sm text-gray-400">
{{ $t('chat.noSearchResults') }}
</p>
<div v-for="group in grouped" :key="group.conversation.id" class="space-y-1">
<div class="px-1 text-sm font-medium text-gray-700 dark:text-gray-300">
{{ group.conversation.companion.name }}
<span class="text-gray-400">·</span>
{{ group.conversation.product.title }}
</div>
<button
v-for="msg in group.messages"
:key="msg.id"
class="w-full rounded-lg px-3 py-2 text-left transition-colors hover:bg-gray-100 dark:hover:bg-gray-800"
@click="emit('select', group.conversation.id, msg.id)"
>
<p class="line-clamp-2 text-sm text-gray-600 dark:text-gray-400" v-html="highlightText(msg.text ?? '', query)" />
<span class="text-xs text-gray-400">{{ formatDate(msg.created_at) }}</span>
</button>
</div>
<UButton
v-if="hasMore"
variant="ghost"
:label="$t('chat.loadMore')"
:loading="isSearching"
class="self-center"
@click="emit('loadMore')"
/>
</div>
</template>Step 2: Run lint
Run: npm run lint:fix -- app/features/chat-search/ui/GlobalSearchResults.vue Expected: Clean
Step 3: Commit
bash
git add app/features/chat-search/ui/GlobalSearchResults.vue
git commit -m "feat(chat): add GlobalSearchResults component"Task 9: Integrate global search into conversation list page [M]
Files:
- Modify:
app/pages/cabinet/messages/index.vue
Step 1: Add global search composable
After line 7 (const { conversations, filter, isLoading, refresh, loadMore } = useConversations()), add:
ts
const globalSearch = useGlobalSearch()
const isSearchActive = computed(() => globalSearch.query.value.trim().length >= 2)
function handleSearchSelect(conversationId: number, messageId: number) {
navigateTo(`/cabinet/messages/${conversationId}?highlight=${messageId}`)
}Step 2: Add search input above the filter buttons
After the <h1> heading row (after line 22), add:
html
<!-- Global search -->
<div class="mb-4">
<div class="relative">
<UIcon name="i-lucide-search" class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
v-model="globalSearch.query.value"
type="text"
class="w-full rounded-lg border border-gray-200 bg-white py-2 pl-9 pr-8 text-sm outline-none focus:ring-2 focus:ring-primary-500 dark:border-gray-700 dark:bg-gray-800 dark:text-white"
:placeholder="$t('chat.searchAllPlaceholder')"
/>
<UButton
v-if="globalSearch.query.value"
icon="i-lucide-x"
variant="ghost"
size="xs"
class="absolute right-1 top-1/2 -translate-y-1/2"
@click="globalSearch.clear()"
/>
</div>
</div>Step 3: Conditionally show search results or normal content
Wrap the existing filter buttons + conversation list + empty state + sentinel in a v-if="!isSearchActive" block, and add the global search results:
Replace lines 24-51 (everything from the filter buttons to the sentinel) with:
html
<!-- Search results mode -->
<FeaturesChatSearchUiGlobalSearchResults
v-if="isSearchActive"
:grouped="globalSearch.grouped.value"
:query="globalSearch.query.value"
:is-searching="globalSearch.isSearching.value"
:has-more="globalSearch.hasMore.value"
@load-more="globalSearch.loadMore()"
@select="handleSearchSelect"
/>
<!-- Normal conversation list -->
<template v-else>
<div class="flex gap-2 mb-4">
<UButton
:variant="filter === 'all' ? 'solid' : 'ghost'"
size="sm"
:label="t('chat.filterAll')"
@click="filter = 'all'"
/>
<UButton
:variant="filter === 'unread' ? 'solid' : 'ghost'"
size="sm"
:label="t('chat.filterUnread')"
@click="filter = 'unread'"
/>
</div>
<div v-if="conversations.length" class="flex flex-col divide-y divide-gray-200 dark:divide-gray-800">
<ConversationCard v-for="conv in conversations" :key="conv.id" :conversation="conv" />
</div>
<div v-else-if="!isLoading" class="text-center py-16">
<UIcon name="i-lucide-message-circle" class="w-16 h-16 text-gray-300 dark:text-gray-600 mx-auto mb-4" />
<p class="text-gray-500 dark:text-gray-400">{{ t('chat.noConversations') }}</p>
</div>
<div ref="sentinel" class="py-4 text-center">
<UButton v-if="isLoading" loading variant="ghost" />
</div>
</template>Note: Same auto-import consideration as Task 6. Use FeaturesChatSearchUiGlobalSearchResults or explicit import.
Step 4: Run lint
Run: npm run lint:fix -- app/pages/cabinet/messages/index.vue Expected: Clean
Step 5: Commit
bash
git add app/pages/cabinet/messages/index.vue
git commit -m "feat(chat): integrate global message search into conversation list"Task 10: Verify — typecheck and lint [S]
Step 1: Run full lint
Run: npm run lint Expected: Clean (no errors)
Step 2: Run typecheck
Run: npm run typecheck Expected: Clean (no type errors)
Step 3: Fix any issues found
If lint or typecheck reports errors, fix them in the relevant files.
Step 4: Commit fixes if any
bash
git add -A && git commit -m "fix(chat): resolve lint/typecheck issues in chat search"Task Dependency Graph
Task 1: i18n keys (independent)
Task 2: MessageBubble highlight (independent)
Task 3: scrollToMessage in useMessages (independent)
Task 4: useConversationSearch composable (independent)
Task 5: ChatSearchBar component (after Task 1)
Task 6: Integrate into [id].vue (after Tasks 2, 3, 4, 5)
Task 7: useGlobalSearch composable (independent)
Task 8: GlobalSearchResults component (after Tasks 1, 7)
Task 9: Integrate into index.vue (after Tasks 7, 8)
Task 10: Verify (after all)Tasks 1, 2, 3, 4, 7 can run in parallel. Critical path: 1 → 5 → 6 → 10.
Complexity Key
| Size | Meaning |
|---|---|
| S | One file, straightforward, ~5 min |
| M | 1-2 files, some logic, ~15 min |