composables/ # Shared reactive logic
βββ design/ # Token registry & theme configuration
βββ views/ # Route-level page shells
### Step 2: Configure Tailwind CSS 4 with `@theme` Tokens
Tailwind CSS 4 shifts configuration from `tailwind.config.js` to CSS-native `@theme` blocks. This creates a single source of truth for design tokens and eliminates build-time configuration drift.
```css
/* src/design/theme.css */
@import "tailwindcss";
@theme {
--color-brand-primary: #0f172a;
--color-brand-accent: #3b82f6;
--color-brand-success: #10b981;
--color-brand-warning: #f59e0b;
--color-brand-danger: #ef4444;
--spacing-chat-bubble: 0.75rem;
--radius-message: 0.5rem;
--font-mono-stream: "JetBrains Mono", monospace;
}
Register the token catalog in a TypeScript registry to enforce consistency across components:
// src/design/token-registry.ts
export const UI_TOKENS = {
colors: {
primary: 'bg-brand-primary text-white',
accent: 'bg-brand-accent text-white',
success: 'bg-brand-success text-white',
warning: 'bg-brand-warning text-black',
danger: 'bg-brand-danger text-white',
},
spacing: {
bubble: 'p-chat-bubble',
container: 'max-w-4xl mx-auto',
},
typography: {
stream: 'font-mono-stream text-sm',
heading: 'font-semibold tracking-tight',
},
} as const;
Step 3: Implement Reactive State with Pinia
Separate UI state from business logic. Use Pinia stores for conversation history and streaming buffers, and composables for side effects.
// src/stores/useDialogueStore.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
interface MessageEntry {
id: string
role: 'user' | 'assistant' | 'system'
content: string
citations?: string[]
timestamp: number
}
export const useDialogueStore = defineStore('dialogue', () => {
const history = ref<MessageEntry[]>([])
const isStreaming = ref(false)
const currentBuffer = ref('')
const activeSession = computed(() => history.value.filter(m => m.role !== 'system'))
function appendMessage(entry: MessageEntry) {
history.value.push(entry)
}
function updateStreamChunk(chunk: string) {
currentBuffer.value += chunk
}
function commitStream() {
if (currentBuffer.value.length > 0) {
appendMessage({
id: crypto.randomUUID(),
role: 'assistant',
content: currentBuffer.value,
timestamp: Date.now(),
})
currentBuffer.value = ''
isStreaming.value = false
}
}
return { history, isStreaming, currentBuffer, activeSession, appendMessage, updateStreamChunk, commitStream }
})
Step 4: Build a Stream Handling Composable
Isolate network and parsing logic from the view layer. This composable manages Server-Sent Events (SSE) or WebSocket streams and pushes chunks to the store.
// src/composables/useStreamProcessor.ts
import { ref } from 'vue'
import { useDialogueStore } from '@/stores/useDialogueStore'
export function useStreamProcessor() {
const store = useDialogueStore()
const abortController = ref<AbortController | null>(null)
async function initiateDialogue(prompt: string, endpoint: string) {
store.isStreaming = true
abortController.value = new AbortController()
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: prompt, stream: true }),
signal: abortController.value.signal,
})
if (!response.body) throw new Error('ReadableStream not supported')
const reader = response.body.getReader()
const decoder = new TextDecoder()
while (true) {
const { done, value } = await reader.read()
if (done) break
const chunk = decoder.decode(value, { stream: true })
store.updateStreamChunk(chunk)
}
store.commitStream()
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
store.commitStream()
} else {
console.error('Stream processing failed:', error)
}
}
}
function cancelStream() {
abortController.value?.abort()
}
return { initiateDialogue, cancelStream }
}
Step 5: Wire the View Layer
The component tree remains lean. Views consume stores and composables, delegating rendering to focused sub-components.
<!-- src/components/dialogue/DialogueShell.vue -->
<template>
<div class="flex h-screen bg-slate-50 dark:bg-slate-900">
<aside class="w-64 border-r border-slate-200 dark:border-slate-700">
<SessionList :sessions="store.activeSession" />
</aside>
<main class="flex-1 flex flex-col">
<MessageFeed :messages="store.activeSession" :buffer="store.currentBuffer" />
<InputBar @submit="handleSend" :disabled="store.isStreaming" />
</main>
</div>
</template>
<script setup lang="ts">
import { useDialogueStore } from '@/stores/useDialogueStore'
import { useStreamProcessor } from '@/composables/useStreamProcessor'
import SessionList from './SessionList.vue'
import MessageFeed from './MessageFeed.vue'
import InputBar from './InputBar.vue'
const store = useDialogueStore()
const { initiateDialogue } = useStreamProcessor()
const handleSend = async (prompt: string) => {
store.appendMessage({
id: crypto.randomUUID(),
role: 'user',
content: prompt,
timestamp: Date.now(),
})
await initiateDialogue(prompt, '/api/v1/ai/stream')
}
</script>
Architecture Rationale:
- Feature-scoped directories prevent cross-module dependencies. Adding a knowledge graph or agent configuration panel never touches the chat rendering pipeline.
- Tailwind CSS 4
@theme eliminates configuration files. Design tokens are CSS-native, enabling instant hot-reload and strict visual consistency.
- Composables for side effects keep components declarative. Stream parsing, error handling, and abort logic live outside the view layer.
- Pinia for state provides reactive, serializable conversation history. The buffer pattern prevents unnecessary DOM updates during token streaming.
Pitfall Guide
1. Streaming State Bleed
Explanation: Developers often push raw token chunks directly into the DOM or mix stream parsing logic inside Vue components. This causes excessive re-renders and memory leaks.
Fix: Decouple stream consumption from rendering. Use a dedicated buffer in Pinia, accumulate chunks in the composable, and commit to the store only when a logical boundary (newline, sentence, or chunk batch) is reached.
2. Token Drift & Hardcoded Styles
Explanation: As features multiply, developers bypass the design system and hardcode colors or spacing. This breaks dark mode, creates visual inconsistency, and increases maintenance overhead.
Fix: Enforce @theme token usage via ESLint rules. Maintain a TypeScript registry that maps semantic names to utility classes. Run visual regression tests in CI to catch drift early.
3. Unbounded History Growth
Explanation: Storing every AI response and user prompt in memory without limits causes performance degradation, especially on lower-end devices or long sessions.
Fix: Implement windowed history or pagination. Keep only the last N messages in active memory, serialize older sessions to IndexedDB, and load them on demand.
4. Type Debt Accumulation
Explanation: Ignoring legacy TypeScript errors leads to a broken type-checking pipeline. New features inherit implicit any types, making refactoring dangerous.
Fix: Establish a CI baseline of existing type errors. Configure the pipeline to fail if new errors are introduced. Gradually refactor legacy modules while maintaining the regression gate.
5. Mock Data Dependency in E2E Tests
Explanation: Relying on static mocks for AI workflows produces false positives. Real LLM responses vary in latency, chunk size, and error states.
Fix: Use Playwright or Cypress with network interception to simulate realistic streaming behavior. Test timeout handling, partial responses, and abort scenarios explicitly.
6. Component Scope Creep
Explanation: Placing knowledge graph logic, citation parsing, or vector search filtering inside chat components creates tightly coupled monoliths.
Fix: Enforce strict feature boundaries. Extract cross-cutting concerns into shared composables. Use event buses or Pinia actions for inter-module communication instead of prop drilling.
7. Ignoring Accessibility in Streaming UIs
Explanation: Real-time token updates often bypass screen readers. Live regions are misconfigured, causing assistive technology to read fragmented chunks or ignore updates entirely.
Fix: Use aria-live="polite" for message containers. Debounce live region updates to announce complete sentences rather than individual tokens. Test with NVDA and VoiceOver during development.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Short-lived UI state (modals, dropdowns) | Component-level ref/reactive | Avoids unnecessary store serialization overhead | Low |
| Cross-feature state (conversation history, active session) | Pinia with defineStore | Provides reactive synchronization, devtools, and SSR compatibility | Medium |
| Real-time AI token streaming | Composable + Pinia buffer | Decouples network I/O from rendering, prevents DOM thrashing | Low |
| Design system management | Tailwind CSS 4 @theme + TS registry | Eliminates config files, enables instant hot-reload, enforces consistency | Low |
| Component isolation & documentation | Storybook with src/stories/ | Accelerates UI iteration without backend dependencies | Medium |
| Type safety enforcement | vue-tsc baseline + CI regression gate | Prevents legacy debt from blocking new feature development | Low |
Configuration Template
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
},
})
// tsconfig.json (critical strict settings)
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"skipLibCheck": true,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"exclude": ["node_modules", "dist"]
}
Quick Start Guide
- Scaffold the project: Run
npm create vite@latest ai-frontend -- --template vue-ts and install dependencies (pinia, tailwindcss, @tailwindcss/vite).
- Initialize the design system: Create
src/design/theme.css with the @theme block, import it in main.ts, and register src/design/token-registry.ts.
- Wire the state layer: Define
useDialogueStore with history, buffer, and streaming flags. Create useStreamProcessor composable for fetch/SSE handling.
- Build the view shell: Implement
DialogueShell.vue with feature-scoped sub-components. Connect the input handler to the composable and bind the message feed to the store.
- Validate the pipeline: Run
npm run dev, verify streaming updates render correctly, execute npm run test:unit, and confirm npx vue-tsc --noEmit passes against the baseline.