Compare commits

..

4 Commits

Author SHA1 Message Date
31cd84186f Refactor: UI redesign with new glasmorphism layout and auto-rounds
- Remove all emojis from UI elements
- Redesign with two-column layout: timeline sidebar + content
- Implement automatic rounds without manual button
- Add persistent timeline panel with progress tracking
- Implement true convergence (all agents must agree)
- Add TimelinePanel component with progress bar
- Update styling with improved glasmorphism effects
- Clean up mentions of external tools
2025-10-18 22:38:46 +02:00
fca2b58689 Clean up: Remove old debate mode reference from WebSocket composable
Remove unused debateId parameter from useWebSocket composable since
debate mode has been completely removed. Update CollaborativeSession
component to use simplified signature.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-17 17:39:00 +02:00
85319b5ca6 Fix session lifecycle: change initial status from 'ongoing' to 'created'
Update database schema and backend services to properly track session lifecycle:
- Sessions now start with 'created' status
- Frontend auto-start logic works when status is 'created'
- Status transitions to 'ongoing' when session actually starts
- Prevents issues with premature round execution

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-17 17:38:41 +02:00
d52c0aef92 Add animated gradient background with floating particles
Implement dynamic background that fills entire viewport:
- Animated gradient that smoothly shifts colors every 15s
- Floating radial gradients (particles) that animate independently
- Multiple animation layers with different timings (20s, 25s)
- Content properly layered above background (z-index handling)
- Smooth, continuous animations that don't loop jarringly

Changes:
- App.vue: Full-screen animated background with pseudo-elements
- CollaborativeInput.vue: Same animated effect
- CollaborativeSession.vue: Full viewport background
- All components now use 100% available space
- Subtle visual interest without distracting from content

Result: Modern, premium feel with movement depth

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-17 17:36:47 +02:00
11 changed files with 966 additions and 437 deletions

View File

@ -21,15 +21,15 @@ Aucune génération de code direct n'est effectuée : seules des **explications,
Reproduire une **table ronde d'IA spécialistes**, chaque agent IA représentant un rôle spécifique : Reproduire une **table ronde d'IA spécialistes**, chaque agent IA représentant un rôle spécifique :
- Architecte logiciel - Lead Architect (Architecte logiciel)
- Développeur backend - Backend Engineer (Développeur backend)
- Développeur frontend - Frontend Engineer (Développeur frontend)
- Designer UI/UX - UI Designer (Designer UI/UX)
- Data engineer - DevOps Engineer (Ingénieur DevOps)
- Chef de projet - Product Manager (Chef de projet)
- Éventuellement d'autres rôles selon la complexité du prompt. - Security Specialist (Spécialiste sécurité)
Cependant, l'aspect le plus crucial de ces IA réside dans leur capacité à **échanger, négocier, voire s'opposer** pour déterminer collectivement la meilleure approche à adopter. L'aspect le plus crucial de ces IA réside dans leur capacité à **collaborer itérativement** pour améliorer collectivement la spécification architecturale jusqu'à convergence naturelle (quand plus aucune amélioration n'est proposée).
--- ---
@ -96,16 +96,17 @@ graph TD
### Interface et visualisation ### Interface et visualisation
- **Saisie de prompt** décrivant le projet souhaité - **Saisie de prompt** décrivant le projet souhaité
- **Visualisation en temps réel** des échanges entre agents via WebSocket - **Upload optionnel** de fichiers contexte (MD/TXT)
- **Affichage structuré** des propositions avec justifications et niveaux de confiance - **Visualisation en temps réel** des modifications via WebSocket
- **Rendu automatique** de diagrammes Mermaid intégrés dans les réponses - **Timeline d'évolution** du document avec historique complet
- **Indicateurs de progression** et statuts du débat - **Affichage interactif** du document Markdown final
- **Indicateurs de progression** et statuts du session
### Architecture et stockage ### Architecture et stockage
- **API REST** pour gestion des débats et récupération des résultats - **API REST** pour gestion des sessions collaboratives
- **WebSocket** pour streaming temps réel des réponses des agents - **WebSocket** pour streaming temps réel des modifications d'agents
- **Base SQLite** pour persistance des débats et historique - **Base SQLite** pour persistance des sessions, versions et historique complet
- **Gestion d'erreurs** avec fallbacks et réponses de secours - **Versioning intelligent** : Chaque modification crée une version avec métadonnées (agent, raison, round)
--- ---

View File

@ -23,7 +23,7 @@ db.exec(`
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
initial_prompt TEXT NOT NULL, initial_prompt TEXT NOT NULL,
document_format TEXT DEFAULT 'md' CHECK(document_format IN ('md', 'txt')), document_format TEXT DEFAULT 'md' CHECK(document_format IN ('md', 'txt')),
status TEXT CHECK(status IN ('ongoing', 'completed', 'failed')) DEFAULT 'ongoing', status TEXT CHECK(status IN ('created', 'ongoing', 'completed', 'failed')) DEFAULT 'created',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
completed_at TIMESTAMP, completed_at TIMESTAMP,
final_document TEXT final_document TEXT

View File

@ -55,8 +55,8 @@ router.post('/:id/start', async (req, res) => {
return res.status(404).json({ error: 'Session not found' }); return res.status(404).json({ error: 'Session not found' });
} }
if (session.status !== 'ongoing') { if (session.status !== 'created') {
return res.status(400).json({ error: 'Session is not in ongoing status' }); return res.status(400).json({ error: 'Session has already been started or is no longer available' });
} }
// Start the session asynchronously // Start the session asynchronously

View File

@ -58,7 +58,7 @@ class CollaborativeOrchestrator {
const stmt = db.prepare( const stmt = db.prepare(
'INSERT INTO collaborative_sessions (initial_prompt, document_format, status) VALUES (?, ?, ?)' 'INSERT INTO collaborative_sessions (initial_prompt, document_format, status) VALUES (?, ?, ?)'
); );
const result = stmt.run(initialPrompt, documentFormat, 'ongoing'); const result = stmt.run(initialPrompt, documentFormat, 'created');
const sessionId = result.lastInsertRowid; const sessionId = result.lastInsertRowid;
// Select the agents to use // Select the agents to use
@ -145,6 +145,10 @@ class CollaborativeOrchestrator {
throw new Error('Active session not found'); throw new Error('Active session not found');
} }
// Update session status from 'created' to 'ongoing'
const updateStmt = db.prepare('UPDATE collaborative_sessions SET status = ? WHERE id = ?');
updateStmt.run('ongoing', sessionId);
activeSession.started = true; activeSession.started = true;
// Broadcast session start // Broadcast session start

View File

@ -3,6 +3,7 @@ import { ref } from 'vue'
import { useCollaborationStore } from './stores/collaboration' import { useCollaborationStore } from './stores/collaboration'
import CollaborativeInput from './components/CollaborativeInput.vue' import CollaborativeInput from './components/CollaborativeInput.vue'
import CollaborativeSession from './components/CollaborativeSession.vue' import CollaborativeSession from './components/CollaborativeSession.vue'
import TimelinePanel from './components/TimelinePanel.vue'
import NetworkStatus from './components/NetworkStatus.vue' import NetworkStatus from './components/NetworkStatus.vue'
const collaborationStore = useCollaborationStore() const collaborationStore = useCollaborationStore()
@ -25,17 +26,28 @@ function startNewCollaboration() {
<div class="app"> <div class="app">
<NetworkStatus /> <NetworkStatus />
<!-- Input Mode --> <!-- Input Mode - Centered Full Screen -->
<div v-if="!showSession"> <div v-if="!showSession" class="input-container">
<CollaborativeInput @session-created="handleCollaborationCreated" /> <CollaborativeInput @session-created="handleCollaborationCreated" />
</div> </div>
<!-- Session Mode --> <!-- Session Mode - Two Column Layout -->
<div v-else> <div v-else class="session-layout">
<button @click="startNewCollaboration" class="new-session-btn"> <!-- Left Sidebar: Timeline -->
New Session <div class="timeline-sidebar">
<div class="sidebar-header">
<h2>Session Progress</h2>
<button @click="startNewCollaboration" class="new-btn-small" title="Start new session">
R
</button> </button>
</div>
<!-- Timeline Panel -->
<TimelinePanel />
</div>
<!-- Right Content: Session -->
<div class="main-content">
<CollaborativeSession <CollaborativeSession
v-if="currentSessionId" v-if="currentSessionId"
:session-id="currentSessionId" :session-id="currentSessionId"
@ -43,54 +55,205 @@ function startNewCollaboration() {
/> />
</div> </div>
</div> </div>
</div>
</template> </template>
<style> <style>
* { @keyframes gradient-shift {
margin: 0; 0% { background-position: 0% 50%; }
padding: 0; 50% { background-position: 100% 50%; }
box-sizing: border-box; 100% { background-position: 0% 50%; }
} }
body { @keyframes float1 {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; 0%, 100% { transform: translate(0, 0) rotate(0deg); opacity: 0.03; }
background-color: #f5f7fa; 25% { transform: translate(100px, -100px) rotate(90deg); opacity: 0.05; }
color: #2c3e50; 50% { transform: translate(-50px, 150px) rotate(180deg); opacity: 0.03; }
75% { transform: translate(-100px, -50px) rotate(270deg); opacity: 0.04; }
} }
#app { @keyframes float2 {
min-height: 100vh; 0%, 100% { transform: translate(0, 0) rotate(0deg); opacity: 0.04; }
25% { transform: translate(-120px, 80px) rotate(-90deg); opacity: 0.06; }
50% { transform: translate(80px, -120px) rotate(-180deg); opacity: 0.04; }
75% { transform: translate(120px, 100px) rotate(-270deg); opacity: 0.05; }
}
@keyframes float3 {
0%, 100% { transform: translate(0, 0) rotate(0deg); opacity: 0.03; }
25% { transform: translate(-80px, -80px) rotate(45deg); opacity: 0.05; }
50% { transform: translate(100px, 100px) rotate(135deg); opacity: 0.03; }
75% { transform: translate(-100px, 80px) rotate(225deg); opacity: 0.04; }
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
} }
</style> </style>
<style scoped> <style scoped>
.app { .app {
min-height: 100vh; min-height: 100vh;
padding: 2rem; background: linear-gradient(-45deg, #0f0c29 0%, #302b63 25%, #24243e 50%, #302b63 75%, #0f0c29 100%);
background: linear-gradient(135deg, #0f0c29 0%, #302b63 50%, #24243e 100%); background-size: 400% 400%;
animation: gradient-shift 15s ease infinite;
position: relative;
overflow: hidden;
} }
.new-session-btn { .app::before {
content: '';
position: fixed; position: fixed;
top: 2rem; top: 0;
right: 2rem; left: 0;
padding: 0.75rem 1.5rem; width: 200%;
background: rgba(102, 126, 234, 0.8); height: 200%;
border: 1px solid rgba(255, 255, 255, 0.2); background: radial-gradient(circle at 20% 50%, rgba(102, 126, 234, 0.1) 0%, transparent 50%),
color: white; radial-gradient(circle at 80% 80%, rgba(118, 75, 162, 0.08) 0%, transparent 50%);
border-radius: 12px; animation: float1 20s ease-in-out infinite;
font-weight: 600; pointer-events: none;
cursor: pointer; z-index: 0;
transition: all 0.3s;
box-shadow: 0 8px 32px rgba(31, 38, 135, 0.2);
backdrop-filter: blur(10px);
z-index: 50;
} }
.new-session-btn:hover { .app::after {
background: rgba(102, 126, 234, 0.95); content: '';
transform: translateY(-2px); position: fixed;
box-shadow: 0 12px 40px rgba(102, 126, 234, 0.4); bottom: 0;
border-color: rgba(255, 255, 255, 0.3); right: 0;
width: 300px;
height: 300px;
background: radial-gradient(circle, rgba(102, 126, 234, 0.12) 0%, transparent 70%);
animation: float2 25s ease-in-out infinite;
pointer-events: none;
z-index: 0;
border-radius: 50%;
}
.app > * {
position: relative;
z-index: 1;
}
.input-container {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 2rem;
}
.session-layout {
display: grid;
grid-template-columns: 320px 1fr;
min-height: 100vh;
gap: 0;
}
.timeline-sidebar {
background: rgba(48, 43, 99, 0.4);
border-right: 1px solid rgba(255, 255, 255, 0.1);
backdrop-filter: blur(20px);
padding: 1.5rem;
overflow-y: auto;
max-height: 100vh;
box-shadow: inset -1px 0 0 rgba(102, 126, 234, 0.2);
}
.sidebar-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.sidebar-header h2 {
font-size: 1.2rem;
margin: 0;
background: linear-gradient(135deg, #667eea, #764ba2);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.new-btn-small {
width: 32px;
height: 32px;
padding: 0;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.2rem;
background: rgba(102, 126, 234, 0.6);
}
.new-btn-small:hover {
background: rgba(102, 126, 234, 0.9);
transform: rotate(20deg);
}
.timeline-content {
display: flex;
flex-direction: column;
gap: 1rem;
}
.main-content {
padding: 2rem;
overflow-y: auto;
max-height: 100vh;
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(102, 126, 234, 0.4);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(102, 126, 234, 0.6);
}
/* Mobile responsive */
@media (max-width: 1024px) {
.session-layout {
grid-template-columns: 250px 1fr;
}
.timeline-sidebar {
padding: 1rem;
}
.main-content {
padding: 1rem;
}
}
@media (max-width: 768px) {
.session-layout {
grid-template-columns: 1fr;
}
.timeline-sidebar {
border-right: none;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
max-height: none;
}
.main-content {
max-height: none;
}
} }
</style> </style>

View File

@ -192,7 +192,16 @@ const removeFile = () => {
.collaborative-input { .collaborative-input {
min-height: 100vh; min-height: 100vh;
padding: 2rem; padding: 2rem;
background: linear-gradient(135deg, #0f0c29 0%, #302b63 50%, #24243e 100%); background: linear-gradient(
-45deg,
#0f0c29 0%,
#302b63 25%,
#24243e 50%,
#302b63 75%,
#0f0c29 100%
);
background-size: 400% 400%;
animation: gradient-shift 15s ease infinite;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
} }
@ -200,20 +209,53 @@ const removeFile = () => {
.collaborative-input::before { .collaborative-input::before {
content: ''; content: '';
position: fixed; position: fixed;
top: -50%; top: 0;
right: -50%; left: 0;
width: 200%; width: 200%;
height: 200%; height: 200%;
background: radial-gradient(circle, rgba(102, 126, 234, 0.1) 0%, transparent 70%); background: radial-gradient(circle at 20% 50%, rgba(102, 126, 234, 0.1) 0%, transparent 50%),
radial-gradient(circle at 80% 80%, rgba(118, 75, 162, 0.08) 0%, transparent 50%);
animation: float1 20s ease-in-out infinite;
pointer-events: none; pointer-events: none;
z-index: 0; z-index: 0;
} }
@keyframes gradient-shift {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
@keyframes float1 {
0%, 100% {
transform: translate(0, 0) rotate(0deg);
opacity: 0.03;
}
25% {
transform: translate(100px, -100px) rotate(90deg);
opacity: 0.05;
}
50% {
transform: translate(-50px, 150px) rotate(180deg);
opacity: 0.03;
}
75% {
transform: translate(-100px, -50px) rotate(270deg);
opacity: 0.04;
}
}
.container { .container {
max-width: 900px; max-width: 900px;
margin: 0 auto; margin: 0 auto;
position: relative; position: relative;
z-index: 1; z-index: 2;
} }
.header { .header {

View File

@ -3,6 +3,7 @@ import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useCollaborationStore } from '../stores/collaboration' import { useCollaborationStore } from '../stores/collaboration'
import { useWebSocket } from '../composables/useWebSocket' import { useWebSocket } from '../composables/useWebSocket'
import DocumentViewer from './DocumentViewer.vue' import DocumentViewer from './DocumentViewer.vue'
import TimelinePanel from './TimelinePanel.vue'
const props = defineProps({ const props = defineProps({
sessionId: { sessionId: {
@ -14,21 +15,35 @@ const props = defineProps({
const emit = defineEmits(['session-completed']) const emit = defineEmits(['session-completed'])
const collaborationStore = useCollaborationStore() const collaborationStore = useCollaborationStore()
const ws = useWebSocket(null, props.sessionId) const ws = useWebSocket(props.sessionId)
const isRunningRound = ref(false) const isRunningRound = ref(false)
const sessionStarted = ref(false) const sessionStarted = ref(false)
const showTimeline = ref(false) const isAutoRunning = ref(false)
const selectedVersion = ref(null) const autoRunTimeout = ref(null)
const currentSession = computed(() => collaborationStore.currentSession) const currentSession = computed(() => collaborationStore.currentSession)
const currentDocument = computed(() => collaborationStore.currentDocument) const currentDocument = computed(() => collaborationStore.currentDocument)
const agents = computed(() => currentSession.value?.agents || []) const agents = computed(() => currentSession.value?.agents || [])
const conversationHistory = computed(() => collaborationStore.conversationHistory) const conversationHistory = computed(() => collaborationStore.conversationHistory)
// True convergence: ALL 7 agents must say no changes
const hasConverged = computed(() => { const hasConverged = computed(() => {
if (conversationHistory.value.length === 0) return false if (conversationHistory.value.length === 0) return false
const lastRound = conversationHistory.value[conversationHistory.value.length - 1] const lastRound = conversationHistory.value[conversationHistory.value.length - 1]
return !lastRound.agentsMadeChanges || lastRound.agentsMadeChanges.length === 0 // Check if last round has no changes from ANY agent
return !lastRound.agentsMadeChanges || (Array.isArray(lastRound.agentsMadeChanges) && lastRound.agentsMadeChanges.length === 0)
})
// Count of agents in current session
const agentCount = computed(() => agents.value?.length || 0)
// Check if all available agents have participated without changes
const allAgentsConverged = computed(() => {
if (conversationHistory.value.length === 0) return false
const lastRound = conversationHistory.value[conversationHistory.value.length - 1]
// All agents in this session must have zero changes
return hasConverged.value && agentCount.value > 0
}) })
onMounted(async () => { onMounted(async () => {
@ -47,7 +62,10 @@ onMounted(async () => {
} }
}, 100) }, 100)
onUnmounted(() => clearInterval(messageInterval)) onUnmounted(() => {
clearInterval(messageInterval)
if (autoRunTimeout.value) clearTimeout(autoRunTimeout.value)
})
// If session hasn't been started, start it // If session hasn't been started, start it
if (currentSession.value?.status === 'created') { if (currentSession.value?.status === 'created') {
@ -62,19 +80,50 @@ const handleWebSocketMessage = (message) => {
if (message.type === 'initial_document_created') { if (message.type === 'initial_document_created') {
sessionStarted.value = true sessionStarted.value = true
collaborationStore.updateDocumentFromMessage(message) collaborationStore.updateDocumentFromMessage(message)
// Start first auto-round after initial document
scheduleNextRound(2000)
} else if (message.type === 'document_modified') { } else if (message.type === 'document_modified') {
collaborationStore.updateDocumentFromMessage(message) collaborationStore.updateDocumentFromMessage(message)
} else if (message.type === 'round_complete') { } else if (message.type === 'round_complete') {
isRunningRound.value = false isRunningRound.value = false
collaborationStore.updateDocumentFromMessage(message) collaborationStore.updateDocumentFromMessage(message)
if (message.hasConverged) {
// Auto-complete on convergence // Check convergence
if (hasConverged.value && allAgentsConverged.value) {
// All agents converged - auto-complete after delay
isAutoRunning.value = false
setTimeout(() => { setTimeout(() => {
completeSession() completeSession()
}, 2000) }, 2000)
} else {
// Schedule next round with delay
scheduleNextRound(1500)
} }
} else if (message.type === 'session_error') { } else if (message.type === 'session_error') {
console.error('Session error:', message.error) console.error('Session error:', message.error)
isRunningRound.value = false
isAutoRunning.value = false
}
}
const scheduleNextRound = (delay) => {
if (autoRunTimeout.value) clearTimeout(autoRunTimeout.value)
isAutoRunning.value = true
autoRunTimeout.value = setTimeout(() => {
runNextRoundAuto()
}, delay)
}
const runNextRoundAuto = async () => {
if (isRunningRound.value || hasConverged.value) return
isRunningRound.value = true
try {
await collaborationStore.runRound(props.sessionId)
} catch (error) {
console.error('Error running auto round:', error)
isRunningRound.value = false
isAutoRunning.value = false
} }
} }
@ -86,20 +135,10 @@ const startSession = async () => {
} }
} }
const runNextRound = async () => {
if (isRunningRound.value || hasConverged.value) return
isRunningRound.value = true
try {
await collaborationStore.runRound(props.sessionId)
} catch (error) {
console.error('Error running round:', error)
isRunningRound.value = false
}
}
const completeSession = async () => { const completeSession = async () => {
try { try {
isAutoRunning.value = false
if (autoRunTimeout.value) clearTimeout(autoRunTimeout.value)
await collaborationStore.completeSession(props.sessionId) await collaborationStore.completeSession(props.sessionId)
emit('session-completed') emit('session-completed')
} catch (error) { } catch (error) {
@ -112,113 +151,287 @@ const downloadDocument = () => {
const extension = format === 'md' ? 'md' : 'txt' const extension = format === 'md' ? 'md' : 'txt'
collaborationStore.downloadDocument(`collaborative-document.${extension}`) collaborationStore.downloadDocument(`collaborative-document.${extension}`)
} }
const viewVersion = (versionNumber) => {
selectedVersion.value = selectedVersion.value === versionNumber ? null : versionNumber
}
</script> </script>
<template> <template>
<div class="collaborative-session"> <div class="collaborative-session">
<!-- Header with minimal controls -->
<div class="session-header"> <div class="session-header">
<div class="header-content"> <div class="header-content">
<h1>Design Session</h1> <h1>Collaborative Design</h1>
<p class="session-meta"> <p class="session-meta">
<span>Session #{{ sessionId }}</span> <span>Session #{{ sessionId }}</span>
<span class="badge" :class="{ active: sessionStarted }"> <span class="badge" :class="{ active: sessionStarted, converged: allAgentsConverged }">
{{ sessionStarted ? 'Active' : 'Waiting' }} {{ allAgentsConverged ? 'Converged' : sessionStarted ? 'Active' : 'Waiting' }}
</span> </span>
</p> </p>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<button
@click="runNextRound"
:disabled="!sessionStarted || isRunningRound || hasConverged"
class="btn btn-primary"
>
{{ isRunningRound ? 'Round in Progress...' : 'Next Review Round' }}
</button>
<button
@click="completeSession"
:disabled="!sessionStarted"
class="btn btn-secondary"
>
Complete Session
</button>
<button <button
@click="downloadDocument" @click="downloadDocument"
:disabled="!currentDocument" :disabled="!currentDocument"
class="btn btn-outline" class="download-btn"
title="Download the document"
> >
Download Download
</button> </button>
<button <div v-if="isAutoRunning || isRunningRound" class="auto-run-indicator">
@click="showTimeline = !showTimeline" <span class="pulse"></span>
class="btn btn-outline" Auto-running...
> </div>
Timeline
</button>
</div> </div>
</div> </div>
<!-- Status Message --> <!-- Status message -->
<div v-if="hasConverged" class="convergence-message"> <div v-if="allAgentsConverged" class="convergence-message">
Convergence reached. All agents satisfied. <span class="checkmark">[OK]</span>
All {{ agentCount }} agents have reviewed and approved. Auto-completing...
</div> </div>
<!-- Agent List --> <!-- Team agents display -->
<div class="agents-section"> <div class="team-display">
<h3>Team ({{ agents.length }} agents)</h3> <h3>Team</h3>
<div class="agents-grid"> <div class="team-grid">
<div v-for="agent in agents" :key="agent" class="agent-badge"> <div v-for="agent in agents" :key="agent" class="team-member">
{{ formatAgentName(agent) }} {{ formatAgentName(agent) }}
</div> </div>
</div> </div>
</div> </div>
<!-- Timeline View --> <!-- Main Document Viewer -->
<div v-if="showTimeline" class="timeline-section">
<h3>Modification Timeline</h3>
<div class="timeline">
<div v-for="(round, index) in conversationHistory" :key="index" class="timeline-item">
<div class="timeline-marker">
{{ index + 1 }}
</div>
<div class="timeline-content">
<div class="round-title">Round {{ round.roundNumber }}</div>
<div v-if="Array.isArray(round.agentsMadeChanges) && round.agentsMadeChanges.length > 0" class="agents-modified">
Modified by: {{ round.agentsMadeChanges.join(', ') }}
</div>
<div v-else class="no-changes">
No changes
</div>
</div>
</div>
</div>
</div>
<!-- Document Viewer -->
<div class="document-section"> <div class="document-section">
<div class="document-header">
<h2>Architecture Document</h2>
<span v-if="currentDocument" class="doc-status">Live</span>
</div>
<DocumentViewer <DocumentViewer
:document="currentDocument" :document="currentDocument || 'Waiting for initial document...'"
:format="currentSession?.documentFormat || 'md'" :format="currentSession?.documentFormat || 'md'"
/> />
</div> </div>
<!-- Round Information -->
<div class="round-info">
<p>Current Round: <strong>{{ collaborationStore.currentRound }}</strong></p>
<p v-if="conversationHistory.length > 0">
Last Round: {{ conversationHistory.length }} agents reviewed
</p>
</div>
</div> </div>
</template> </template>
<style scoped>
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.collaborative-session {
width: 100%;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.session-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem;
background: rgba(255, 255, 255, 0.07);
backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 16px;
box-shadow: 0 8px 32px rgba(31, 38, 135, 0.1);
}
.header-content h1 {
margin: 0;
font-size: 1.8rem;
font-weight: 700;
background: linear-gradient(135deg, #667eea, #764ba2);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.session-meta {
margin-top: 0.5rem;
display: flex;
gap: 1rem;
align-items: center;
font-size: 0.9rem;
color: rgba(255, 255, 255, 0.7);
}
.badge {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.9rem;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 20px;
font-size: 0.85rem;
font-weight: 500;
}
.badge.active {
background: rgba(102, 126, 234, 0.2);
border-color: rgba(102, 126, 234, 0.4);
color: rgba(102, 126, 234, 0.95);
}
.badge.converged {
background: rgba(76, 175, 80, 0.2);
border-color: rgba(76, 175, 80, 0.4);
color: rgba(76, 175, 80, 0.95);
}
.header-actions {
display: flex;
gap: 1rem;
align-items: center;
}
.download-btn {
padding: 0.75rem 1.5rem;
background: rgba(102, 126, 234, 0.8);
border: 1px solid rgba(255, 255, 255, 0.2);
color: white;
border-radius: 10px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
backdrop-filter: blur(10px);
}
.download-btn:hover:not(:disabled) {
background: rgba(102, 126, 234, 0.95);
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(102, 126, 234, 0.3);
}
.download-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.auto-run-indicator {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.5rem 1rem;
background: rgba(76, 175, 80, 0.15);
border: 1px solid rgba(76, 175, 80, 0.3);
border-radius: 10px;
font-size: 0.85rem;
color: rgba(76, 175, 80, 0.9);
font-weight: 500;
}
.pulse {
display: inline-block;
width: 6px;
height: 6px;
background: rgba(76, 175, 80, 0.8);
border-radius: 50%;
animation: pulse 1.5s infinite;
}
.convergence-message {
display: flex;
align-items: center;
gap: 1rem;
padding: 1.2rem;
background: rgba(76, 175, 80, 0.1);
border: 1px solid rgba(76, 175, 80, 0.3);
border-radius: 12px;
color: rgba(76, 175, 80, 0.95);
font-weight: 500;
backdrop-filter: blur(10px);
}
.checkmark {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
background: rgba(76, 175, 80, 0.3);
border-radius: 50%;
font-weight: 700;
font-size: 0.9rem;
}
.team-display {
padding: 1rem;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
}
.team-display h3 {
margin: 0 0 0.75rem 0;
font-size: 0.95rem;
color: rgba(255, 255, 255, 0.8);
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 600;
}
.team-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 0.6rem;
}
.team-member {
padding: 0.6rem 0.9rem;
background: rgba(102, 126, 234, 0.12);
border: 1px solid rgba(102, 126, 234, 0.25);
border-radius: 8px;
font-size: 0.85rem;
color: rgba(102, 126, 234, 0.95);
font-weight: 500;
text-align: center;
transition: all 0.3s ease;
}
.team-member:hover {
background: rgba(102, 126, 234, 0.2);
border-color: rgba(102, 126, 234, 0.4);
}
.document-section {
flex: 1;
display: flex;
flex-direction: column;
}
.document-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.document-header h2 {
margin: 0;
font-size: 1.2rem;
color: white;
}
.doc-status {
display: inline-block;
padding: 0.3rem 0.7rem;
background: rgba(76, 175, 80, 0.2);
border: 1px solid rgba(76, 175, 80, 0.3);
border-radius: 6px;
font-size: 0.75rem;
font-weight: 600;
color: rgba(76, 175, 80, 0.9);
text-transform: uppercase;
letter-spacing: 0.5px;
}
</style>
<script> <script>
function formatAgentName(agent) { function formatAgentName(agent) {
return agent return agent
@ -227,227 +440,3 @@ function formatAgentName(agent) {
.join(' ') .join(' ')
} }
</script> </script>
<style scoped>
.collaborative-session {
max-width: 1400px;
margin: 0 auto;
padding: 2rem;
}
.session-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 2rem;
padding: 1.5rem;
background: rgba(255, 255, 255, 0.08);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 20px;
color: white;
box-shadow: 0 8px 32px rgba(31, 38, 135, 0.2);
}
.header-content h1 {
margin: 0;
font-size: 2rem;
font-weight: 700;
}
.session-meta {
margin-top: 0.5rem;
opacity: 0.9;
display: flex;
gap: 1rem;
}
.badge {
display: inline-block;
padding: 0.25rem 0.75rem;
background: rgba(255, 255, 255, 0.2);
border-radius: 20px;
font-size: 0.875rem;
}
.badge.active {
background: rgba(76, 175, 80, 0.4);
}
.header-actions {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
justify-content: flex-end;
}
.btn {
padding: 0.75rem 1.25rem;
border-radius: 10px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
font-size: 0.95rem;
}
.btn-primary {
background: rgba(255, 255, 255, 0.95);
color: #667eea;
border: none;
}
.btn-primary:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(255, 255, 255, 0.3);
}
.btn-secondary,
.btn-outline {
background: rgba(255, 255, 255, 0.15);
color: white;
border: 1px solid rgba(255, 255, 255, 0.3);
backdrop-filter: blur(5px);
}
.btn-secondary:hover:not(:disabled),
.btn-outline:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.25);
border-color: rgba(255, 255, 255, 0.5);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.convergence-message {
padding: 1rem;
background: rgba(76, 175, 80, 0.15);
border: 1px solid rgba(76, 175, 80, 0.4);
border-radius: 12px;
color: rgba(76, 175, 80, 0.9);
margin-bottom: 1.5rem;
text-align: center;
font-weight: 600;
backdrop-filter: blur(5px);
}
.agents-section {
margin-bottom: 2rem;
}
.agents-section h3 {
margin-bottom: 1rem;
color: white;
}
.agents-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1rem;
}
.agent-badge {
padding: 0.75rem;
background: rgba(102, 126, 234, 0.1);
border: 1px solid rgba(102, 126, 234, 0.3);
border-radius: 10px;
text-align: center;
font-size: 0.95rem;
color: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(5px);
}
.timeline-section {
margin-bottom: 2rem;
padding: 1.5rem;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 12px;
backdrop-filter: blur(10px);
}
.timeline-section h3 {
margin-top: 0;
color: white;
}
.timeline {
display: flex;
flex-direction: column;
gap: 1rem;
}
.timeline-item {
display: flex;
gap: 1rem;
padding: 1rem;
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
border-left: 3px solid rgba(102, 126, 234, 0.6);
backdrop-filter: blur(5px);
}
.timeline-marker {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
background: rgba(102, 126, 234, 0.6);
color: white;
border-radius: 50%;
flex-shrink: 0;
font-weight: 700;
}
.timeline-content {
flex: 1;
}
.round-title {
font-weight: 600;
color: white;
margin-bottom: 0.25rem;
}
.agents-modified {
font-size: 0.875rem;
color: rgba(255, 255, 255, 0.7);
}
.no-changes {
font-size: 0.875rem;
color: rgba(255, 255, 255, 0.5);
font-style: italic;
}
.document-section {
margin: 2rem 0;
}
.round-info {
padding: 1rem;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 12px;
text-align: center;
color: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(10px);
}
@media (max-width: 768px) {
.session-header {
flex-direction: column;
gap: 1rem;
}
.header-actions {
justify-content: flex-start;
}
.agents-grid {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
}
}
</style>

View File

@ -64,12 +64,12 @@ function getStatusColor() {
function getStatusEmoji() { function getStatusEmoji() {
switch (quality.value) { switch (quality.value) {
case 'excellent': return '' case 'excellent': return 'O'
case 'good': return '' case 'good': return 'O'
case 'fair': return '' case 'fair': return 'O'
case 'poor': return '' case 'poor': return 'O'
case 'offline': return '' case 'offline': return 'O'
default: return '' default: return 'O'
} }
} }
</script> </script>

View File

@ -0,0 +1,275 @@
<script setup>
import { computed } from 'vue'
import { useCollaborationStore } from '../stores/collaboration'
const collaborationStore = useCollaborationStore()
const conversationHistory = computed(() => collaborationStore.conversationHistory)
const currentRound = computed(() => collaborationStore.currentRound)
const allAgents = ['lead_architect', 'backend_engineer', 'frontend_engineer', 'ui_designer', 'devops_engineer', 'product_manager', 'security_specialist']
const getAgentStatus = (round) => {
if (!round.agentsMadeChanges) return []
return allAgents.map(agent => ({
name: formatAgentName(agent),
made_changes: round.agentsMadeChanges.includes(agent) || round.agentsMadeChanges.some(a => a.includes(agent))
}))
}
const roundProgress = computed(() => {
if (conversationHistory.value.length === 0) return 0
return Math.round((conversationHistory.value.length / 10) * 100)
})
function formatAgentName(agent) {
return agent
.split('_')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
}
</script>
<template>
<div class="timeline-panel">
<!-- Progress Bar -->
<div class="progress-section">
<div class="progress-header">
<span class="progress-label">Session Progress</span>
<span class="progress-percent">{{ roundProgress }}%</span>
</div>
<div class="progress-bar">
<div class="progress-fill" :style="{ width: roundProgress + '%' }"></div>
</div>
<div class="round-counter">Round {{ currentRound }}</div>
</div>
<!-- Timeline -->
<div class="timeline-list">
<div
v-for="(round, index) in conversationHistory"
:key="index"
class="timeline-entry"
>
<div class="entry-header">
<div class="round-number">{{ index + 1 }}</div>
<div class="round-label">Round {{ round.roundNumber }}</div>
</div>
<!-- Agent status for this round -->
<div class="agent-list">
<div
v-for="agent in getAgentStatus(round)"
:key="agent.name"
class="agent-item"
:class="{ active: agent.made_changes }"
:title="agent.made_changes ? 'Made changes' : 'No changes'"
>
<span class="agent-dot" :class="{ changed: agent.made_changes }"></span>
<span class="agent-short">{{ agent.name.split(' ')[0].slice(0, 1) }}</span>
</div>
</div>
<!-- Changes summary -->
<div v-if="round.agentsMadeChanges && round.agentsMadeChanges.length > 0" class="changes-count">
{{ round.agentsMadeChanges.length }} agent{{ round.agentsMadeChanges.length !== 1 ? 's' : '' }} modified
</div>
<div v-else class="no-changes-text">No changes</div>
</div>
<!-- Empty state -->
<div v-if="conversationHistory.length === 0" class="empty-state">
<p>Waiting for first round...</p>
</div>
</div>
</div>
</template>
<style scoped>
.timeline-panel {
display: flex;
flex-direction: column;
gap: 1.5rem;
height: 100%;
}
.progress-section {
padding: 1rem;
background: rgba(102, 126, 234, 0.1);
border: 1px solid rgba(102, 126, 234, 0.3);
border-radius: 12px;
backdrop-filter: blur(10px);
}
.progress-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
font-size: 0.875rem;
}
.progress-label {
color: rgba(255, 255, 255, 0.7);
font-weight: 500;
}
.progress-percent {
color: var(--primary);
font-weight: 600;
}
.progress-bar {
height: 6px;
background: rgba(255, 255, 255, 0.05);
border-radius: 3px;
overflow: hidden;
margin-bottom: 0.5rem;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #667eea, #764ba2);
border-radius: 3px;
transition: width 0.3s ease;
}
.round-counter {
text-align: center;
font-size: 0.875rem;
color: rgba(255, 255, 255, 0.6);
font-weight: 500;
}
.timeline-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
flex: 1;
overflow-y: auto;
}
.timeline-entry {
padding: 0.875rem;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(102, 126, 234, 0.2);
border-radius: 10px;
backdrop-filter: blur(10px);
transition: all 0.3s ease;
}
.timeline-entry:hover {
background: rgba(255, 255, 255, 0.06);
border-color: rgba(102, 126, 234, 0.4);
}
.entry-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.round-number {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
background: rgba(102, 126, 234, 0.5);
border-radius: 50%;
font-size: 0.75rem;
font-weight: 700;
color: white;
}
.round-label {
font-size: 0.875rem;
font-weight: 600;
color: rgba(255, 255, 255, 0.9);
}
.agent-list {
display: flex;
gap: 0.4rem;
margin-bottom: 0.5rem;
flex-wrap: wrap;
}
.agent-item {
display: flex;
align-items: center;
gap: 0.3rem;
padding: 0.25rem 0.5rem;
background: rgba(255, 255, 255, 0.05);
border-radius: 6px;
font-size: 0.7rem;
color: rgba(255, 255, 255, 0.5);
cursor: help;
transition: all 0.2s ease;
}
.agent-item.active {
background: rgba(76, 175, 80, 0.2);
color: rgba(76, 175, 80, 0.9);
border: 1px solid rgba(76, 175, 80, 0.3);
}
.agent-dot {
width: 4px;
height: 4px;
background: rgba(255, 255, 255, 0.3);
border-radius: 50%;
}
.agent-dot.changed {
background: rgba(76, 175, 80, 0.8);
width: 5px;
height: 5px;
}
.agent-short {
font-weight: 600;
font-size: 0.65rem;
}
.changes-count {
font-size: 0.75rem;
color: rgba(76, 175, 80, 0.8);
font-weight: 500;
}
.no-changes-text {
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.4);
font-style: italic;
}
.empty-state {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: rgba(255, 255, 255, 0.4);
text-align: center;
font-size: 0.875rem;
}
/* Scrollbar */
.timeline-list::-webkit-scrollbar {
width: 6px;
}
.timeline-list::-webkit-scrollbar-track {
background: transparent;
}
.timeline-list::-webkit-scrollbar-thumb {
background: rgba(102, 126, 234, 0.3);
border-radius: 3px;
}
.timeline-list::-webkit-scrollbar-thumb:hover {
background: rgba(102, 126, 234, 0.5);
}
</style>

View File

@ -1,6 +1,6 @@
import { ref, onUnmounted } from 'vue' import { ref, onUnmounted } from 'vue'
export function useWebSocket(debateId = null, sessionId = null) { export function useWebSocket(sessionId = null) {
const ws = ref(null) const ws = ref(null)
const connected = ref(false) const connected = ref(false)
const messages = ref([]) const messages = ref([])
@ -9,9 +9,7 @@ export function useWebSocket(debateId = null, sessionId = null) {
function connect() { function connect() {
let url = WS_URL let url = WS_URL
if (debateId) { if (sessionId) {
url += `?debateId=${debateId}`
} else if (sessionId) {
url += `?sessionId=${sessionId}` url += `?sessionId=${sessionId}`
} }

View File

@ -1,79 +1,136 @@
:root { :root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; --primary: #667eea;
line-height: 1.5; --primary-dark: #5568d3;
--secondary: #764ba2;
--accent: #f093fb;
--dark-bg: #0f0c29;
--mid-bg: #302b63;
--light-bg: #24243e;
--text-primary: #ffffff;
--text-secondary: rgba(255, 255, 255, 0.7);
--glass-bg: rgba(255, 255, 255, 0.05);
--glass-border: rgba(255, 255, 255, 0.1);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.6;
font-weight: 400; font-weight: 400;
color-scheme: dark;
color-scheme: light dark; color: var(--text-primary);
color: rgba(255, 255, 255, 0.87); background-color: var(--dark-bg);
background-color: #242424;
font-synthesis: none; font-synthesis: none;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
a { * {
font-weight: 500; margin: 0;
color: #646cff; padding: 0;
text-decoration: inherit; box-sizing: border-box;
}
a:hover {
color: #535bf2;
} }
body { body {
margin: 0; margin: 0;
display: flex;
place-items: center;
min-width: 320px; min-width: 320px;
min-height: 100vh; min-height: 100vh;
} }
h1 { a {
font-size: 3.2em; color: var(--primary);
line-height: 1.1; text-decoration: none;
transition: all 0.3s ease;
} }
a:hover {
color: var(--accent);
text-decoration: underline;
}
h1, h2, h3, h4, h5, h6 {
font-weight: 600;
line-height: 1.2;
}
h1 { font-size: 2.5em; }
h2 { font-size: 2em; }
h3 { font-size: 1.5em; }
button { button {
border-radius: 8px; border-radius: 12px;
border: 1px solid transparent; border: 1px solid var(--glass-border);
padding: 0.6em 1.2em; padding: 0.75em 1.5em;
font-size: 1em; font-size: 1em;
font-weight: 500; font-weight: 500;
font-family: inherit; font-family: inherit;
background-color: #1a1a1a; background: rgba(102, 126, 234, 0.8);
color: var(--text-primary);
cursor: pointer; cursor: pointer;
transition: border-color 0.25s; transition: all 0.3s ease;
} backdrop-filter: blur(10px);
button:hover { box-shadow: 0 8px 32px rgba(31, 38, 135, 0.1);
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
} }
.card { button:hover:not(:disabled) {
padding: 2em; background: rgba(102, 126, 234, 0.95);
transform: translateY(-2px);
box-shadow: 0 12px 40px rgba(102, 126, 234, 0.3);
border-color: var(--glass-border);
}
button:active:not(:disabled) {
transform: translateY(0);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
button.secondary {
background: rgba(118, 75, 162, 0.6);
}
button.secondary:hover:not(:disabled) {
background: rgba(118, 75, 162, 0.8);
}
button.outline {
background: transparent;
border: 1px solid var(--primary);
color: var(--primary);
}
button.outline:hover:not(:disabled) {
background: rgba(102, 126, 234, 0.2);
border-color: var(--accent);
color: var(--accent);
}
input, textarea, select {
background: rgba(255, 255, 255, 0.05);
border: 1px solid var(--glass-border);
color: var(--text-primary);
padding: 0.75em 1em;
border-radius: 8px;
font-family: inherit;
transition: all 0.3s ease;
}
input:focus, textarea:focus, select:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 20px rgba(102, 126, 234, 0.3);
background: rgba(255, 255, 255, 0.08);
} }
#app { #app {
max-width: 1280px; min-height: 100vh;
margin: 0 auto;
padding: 2rem;
text-align: center;
} }
@media (prefers-color-scheme: light) { @media (prefers-reduced-motion: reduce) {
:root { *, *::before, *::after {
color: #213547; animation-duration: 0.01ms !important;
background-color: #ffffff; animation-iteration-count: 1 !important;
} transition-duration: 0.01ms !important;
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
} }
} }