Dashboard real-time updates con SSE y tabla de rooms

- Implementación de Server-Sent Events (SSE) para actualizaciones en tiempo real
- Nuevo componente RoomsTable para vista de águila con animaciones de tokens
- Componente RoomCard extraído para reutilización
- Modal para ver detalles de rooms
- SystemMessageDisplay usando AnimatedNumber creativamente
- Indicador de conexión SSE con fallback a polling
- Colores dinámicos de texto basados en brillo del fondo
- Backend: broadcast de actualizaciones del dashboard
- Backend: mensajes del sistema (excepto cambios de ronda) visibles en dashboard
- Configuración de Vite para acceso desde red local
This commit is contained in:
2025-08-11 23:07:09 -06:00
parent deb63d4e38
commit 32f69805f0
9 changed files with 1689 additions and 265 deletions

View File

@@ -0,0 +1,353 @@
<template>
<div class="room-card">
<div class="room-header">
<span class="room-id">Room {{ room.roomId.slice(0, 8) }}</span>
<span class="room-status" :class="`status-${room.metadata?.gameStatus || 'waiting'}`">
{{ room.metadata?.gameStatus || 'waiting' }}
</span>
</div>
<div class="room-details">
<div class="detail-row">
<span class="detail-label">Players:</span>
<span class="detail-value">{{ room.clients }}/{{ room.maxClients }}</span>
</div>
<div class="detail-row">
<span class="detail-label">Round:</span>
<span class="detail-value">{{ room.metadata?.currentRound || 1 }}/3</span>
</div>
<div class="detail-row">
<span class="detail-label">Game Variant:</span>
<span class="detail-value">{{ room.metadata?.currentVariant || 'G1' }}</span>
</div>
<div class="detail-row">
<span class="detail-label">Created:</span>
<span class="detail-value">{{ formatTime(room.createdAt) }}</span>
</div>
</div>
<div class="room-actions">
<button
v-if="room.metadata?.gameStatus === 'playing'"
@click="$emit('pause', room.roomId)"
class="btn btn-action btn-pause"
>
Pause
</button>
<button
v-if="room.metadata?.gameStatus === 'paused'"
@click="$emit('resume', room.roomId)"
class="btn btn-action btn-resume"
>
Resume
</button>
<button
@click="$emit('restart', room.roomId)"
class="btn btn-action btn-restart"
>
🔄 Restart
</button>
<button
@click="$emit('viewDetails', room.roomId)"
class="btn btn-action btn-view"
>
📊 Details
</button>
</div>
<div v-if="roomDetails" class="room-stats">
<h4>Room Statistics</h4>
<div v-if="roomDetails.players" class="players-list">
<div v-for="player in roomDetails.players"
:key="player.sessionId"
class="player-row">
<div class="player-info">
<span class="player-name">{{ player.name }}</span>
<span class="player-role">{{ player.role }}</span>
</div>
<div class="player-tokens">
<span class="token pavo">🦃 {{ player.pavoTokens || 0 }}</span>
<span class="token elote">🌽 {{ player.eloteTokens || 0 }}</span>
<span class="token shame">😳 {{ player.shameTokens || 0 }}</span>
</div>
<button
@click="$emit('kickPlayer', room.roomId, player.sessionId)"
class="btn btn-kick"
>
Kick
</button>
</div>
</div>
<div class="stats-info">
<div class="stat-item">
<span>Time Remaining:</span>
<span>{{ formatSeconds(roomDetails.timeRemaining) }}</span>
</div>
<div v-if="roomDetails.winner" class="stat-item">
<span>Winner:</span>
<span class="winner-name">{{ roomDetails.winner }}</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
interface Room {
roomId: string;
clients: number;
maxClients: number;
createdAt: number;
metadata?: {
gameStatus?: string;
currentRound?: number;
currentVariant?: string;
};
}
interface RoomDetails {
players?: Array<{
sessionId: string;
name: string;
role: string;
pavoTokens?: number;
eloteTokens?: number;
shameTokens?: number;
}>;
timeRemaining: number;
winner?: string;
}
defineProps<{
room: Room;
roomDetails?: RoomDetails;
}>();
defineEmits<{
pause: [roomId: string];
resume: [roomId: string];
restart: [roomId: string];
viewDetails: [roomId: string];
kickPlayer: [roomId: string, playerId: string];
}>();
function formatTime(timestamp: number): string {
const date = new Date(timestamp);
return date.toLocaleTimeString();
}
function formatSeconds(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
</script>
<style scoped>
.room-card {
background: white;
color: #333;
border-radius: 15px;
padding: 20px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
}
.room-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
padding-bottom: 15px;
border-bottom: 2px solid #f0f0f0;
}
.room-id {
font-weight: bold;
font-family: monospace;
}
.room-status {
padding: 5px 15px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
}
.status-waiting {
background: #e8f5e9;
color: #4caf50;
}
.status-playing {
background: #e3f2fd;
color: #2196f3;
}
.status-paused {
background: #fff3e0;
color: #ff9800;
}
.status-finished {
background: #f3e5f5;
color: #9c27b0;
}
.room-details {
margin-bottom: 20px;
}
.detail-row {
display: flex;
justify-content: space-between;
padding: 5px 0;
}
.detail-label {
color: #666;
}
.detail-value {
font-weight: 600;
}
.room-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.btn {
padding: 8px 16px;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
}
.btn-action {
flex: 1;
min-width: 80px;
}
.btn-pause {
background: #ff9800;
color: white;
}
.btn-resume {
background: #4caf50;
color: white;
}
.btn-restart {
background: #2196f3;
color: white;
}
.btn-view {
background: #9c27b0;
color: white;
}
.btn-kick {
background: #f44336;
color: white;
padding: 4px 12px;
}
.btn:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
}
.room-stats {
margin-top: 20px;
padding-top: 20px;
border-top: 2px solid #f0f0f0;
}
.room-stats h4 {
margin-bottom: 15px;
color: #666;
}
.players-list {
margin-bottom: 15px;
}
.player-row {
display: flex;
align-items: center;
padding: 12px;
background: #f8f9fa;
border-radius: 8px;
margin-bottom: 8px;
gap: 12px;
}
.player-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
}
.player-name {
font-weight: 600;
font-size: 14px;
}
.player-role {
font-size: 12px;
color: #666;
text-transform: uppercase;
}
.player-tokens {
display: flex;
gap: 8px;
align-items: center;
}
.token {
background: white;
padding: 4px 8px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
border: 1px solid #e0e0e0;
}
.token.pavo {
color: #d84315;
}
.token.elote {
color: #f57f17;
}
.token.shame {
color: #c62828;
}
.stats-info {
background: #f8f9fa;
padding: 15px;
border-radius: 8px;
}
.stat-item {
display: flex;
justify-content: space-between;
padding: 5px 0;
}
.winner-name {
color: #4caf50;
font-weight: bold;
}
</style>

View File

@@ -0,0 +1,203 @@
<template>
<div v-if="isOpen" class="modal-overlay" @click="closeModal">
<div class="modal-container" @click.stop>
<div class="modal-header">
<h3>Room Details</h3>
<button @click="closeModal" class="btn-close">
</button>
</div>
<div class="modal-content">
<RoomCard
v-if="room"
:room="room"
:room-details="roomDetails"
@pause="handlePause"
@resume="handleResume"
@restart="handleRestart"
@view-details="handleViewDetails"
@kick-player="handleKickPlayer"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import RoomCard from './RoomCard.vue';
interface Room {
roomId: string;
clients: number;
maxClients: number;
createdAt: number;
metadata?: {
gameStatus?: string;
currentRound?: number;
currentVariant?: string;
};
}
interface RoomDetails {
players?: Array<{
sessionId: string;
name: string;
role: string;
pavoTokens?: number;
eloteTokens?: number;
shameTokens?: number;
}>;
timeRemaining: number;
winner?: string;
}
const props = defineProps<{
isOpen: boolean;
room?: Room;
roomDetails?: RoomDetails;
}>();
const emit = defineEmits<{
close: [];
pause: [roomId: string];
resume: [roomId: string];
restart: [roomId: string];
viewDetails: [roomId: string];
kickPlayer: [roomId: string, playerId: string];
}>();
function closeModal() {
emit('close');
}
function handlePause(roomId: string) {
emit('pause', roomId);
}
function handleResume(roomId: string) {
emit('resume', roomId);
}
function handleRestart(roomId: string) {
emit('restart', roomId);
}
function handleViewDetails(roomId: string) {
emit('viewDetails', roomId);
}
function handleKickPlayer(roomId: string, playerId: string) {
emit('kickPlayer', roomId, playerId);
}
// Close modal on Escape key
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && props.isOpen) {
closeModal();
}
}
document.addEventListener('keydown', handleKeydown);
</script>
<style scoped>
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 20px;
backdrop-filter: blur(4px);
}
.modal-container {
background: white;
border-radius: 16px;
max-width: 600px;
width: 100%;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
animation: modalSlideIn 0.3s ease-out;
}
@keyframes modalSlideIn {
from {
opacity: 0;
transform: translateY(-30px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px;
border-bottom: 1px solid #e0e0e0;
background: #f8f9fa;
border-radius: 16px 16px 0 0;
}
.modal-header h3 {
margin: 0;
color: #333;
font-size: 1.3rem;
}
.btn-close {
background: none;
border: none;
font-size: 20px;
cursor: pointer;
color: #666;
padding: 4px 8px;
border-radius: 4px;
transition: all 0.2s;
}
.btn-close:hover {
background: #e0e0e0;
color: #333;
}
.modal-content {
padding: 0;
}
/* Override RoomCard styles for modal context */
.modal-content :deep(.room-card) {
box-shadow: none;
border-radius: 0 0 16px 16px;
margin: 0;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.modal-overlay {
padding: 10px;
}
.modal-container {
max-height: 95vh;
}
.modal-header {
padding: 16px 20px;
}
.modal-header h3 {
font-size: 1.1rem;
}
}
</style>

View File

@@ -0,0 +1,467 @@
<template>
<div class="rooms-table-container">
<div class="table-header">
<h3>🎮 Game Rooms Overview</h3>
<div class="table-controls">
<button @click="$emit('refresh')" class="btn btn-refresh">
🔄 Refresh
</button>
</div>
</div>
<div v-if="rooms.length === 0" class="no-rooms">
No active game rooms
</div>
<div v-else class="table-wrapper">
<table class="rooms-table">
<thead>
<tr>
<th>Room ID</th>
<th>Status</th>
<th>Players</th>
<th>Round</th>
<th>Variant</th>
<th>P1 Tokens</th>
<th>P2 Tokens</th>
<th>System Message</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="room in rooms" :key="room.roomId" class="room-row">
<td class="room-id">
<code>{{ room.roomId.slice(0, 8) }}</code>
</td>
<td>
<span class="status-badge" :class="`status-${room.metadata?.gameStatus || 'waiting'}`">
{{ room.metadata?.gameStatus || 'waiting' }}
</span>
</td>
<td class="players-count">
{{ room.clients }}/{{ room.maxClients }}
</td>
<td class="round-info">
{{ room.metadata?.currentRound || 1 }}/3
</td>
<td class="variant-info">
<span class="variant-badge">{{ room.metadata?.currentVariant || 'G1' }}</span>
</td>
<td class="tokens-cell">
<div v-if="getRoomDetails(room.roomId)?.players?.[0]" class="player-section">
<div class="player-name-chip" :style="{
backgroundColor: getPlayerColor(getRoomDetails(room.roomId).players[0], 0),
color: getReadableTextColor(getPlayerColor(getRoomDetails(room.roomId).players[0], 0))
}">
{{ getRoomDetails(room.roomId).players[0].name }}
</div>
<div class="token-summary">
<span class="token pavo">🦃 <AnimatedNumber :value="getRoomDetails(room.roomId).players[0].pavoTokens || 0" :duration-ms="800" /></span>
<span class="token elote">🌽 <AnimatedNumber :value="getRoomDetails(room.roomId).players[0].eloteTokens || 0" :duration-ms="800" /></span>
<span class="token shame">😳 <AnimatedNumber :value="getRoomDetails(room.roomId).players[0].shameTokens || 0" :duration-ms="800" /></span>
</div>
</div>
<div v-else class="no-data">-</div>
</td>
<td class="tokens-cell">
<div v-if="getRoomDetails(room.roomId)?.players?.[1]" class="player-section">
<div class="player-name-chip" :style="{
backgroundColor: getPlayerColor(getRoomDetails(room.roomId).players[1], 1),
color: getReadableTextColor(getPlayerColor(getRoomDetails(room.roomId).players[1], 1))
}">
{{ getRoomDetails(room.roomId).players[1].name }}
</div>
<div class="token-summary">
<span class="token pavo">🦃 <AnimatedNumber :value="getRoomDetails(room.roomId).players[1].pavoTokens || 0" :duration-ms="800" /></span>
<span class="token elote">🌽 <AnimatedNumber :value="getRoomDetails(room.roomId).players[1].eloteTokens || 0" :duration-ms="800" /></span>
<span class="token shame">😳 <AnimatedNumber :value="getRoomDetails(room.roomId).players[1].shameTokens || 0" :duration-ms="800" /></span>
</div>
</div>
<div v-else class="no-data">-</div>
</td>
<td class="system-message-cell">
<SystemMessageDisplay
:message="getRoomDetails(room.roomId)?.recentSystemMessage"
/>
</td>
<td class="created-time">
{{ formatTime(room.createdAt) }}
</td>
<td class="actions-cell">
<button
@click="$emit('viewRoomModal', room.roomId)"
class="btn btn-details"
title="View detailed card"
>
📊 Details
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script setup lang="ts">
import AnimatedNumber from '../views/games/AnimatedNumber.vue';
import SystemMessageDisplay from './SystemMessageDisplay.vue';
interface Room {
roomId: string;
clients: number;
maxClients: number;
createdAt: number;
metadata?: {
gameStatus?: string;
currentRound?: number;
currentVariant?: string;
};
}
interface RoomDetails {
players?: Array<{
sessionId: string;
name: string;
role: string;
pavoTokens?: number;
eloteTokens?: number;
shameTokens?: number;
}>;
timeRemaining: number;
winner?: string;
recentSystemMessage?: {
text: string;
kind: string;
timestamp: number;
} | null;
}
const props = defineProps<{
rooms: Room[];
roomDetails: { [key: string]: RoomDetails };
}>();
defineEmits<{
refresh: [];
viewRoomModal: [roomId: string];
}>();
function getRoomDetails(roomId: string) {
return props.roomDetails[roomId];
}
function formatTime(timestamp: number): string {
const date = new Date(timestamp);
return date.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit'
});
}
function getPlayerColor(player: any, playerIndex: number): string {
// Use player's actual color if available
if (player?.color) {
return player.color;
}
// Fallback colors for P1 and P2
const fallbackColors = ['#667eea', '#f093fb'];
return fallbackColors[playerIndex] || '#999';
}
function getReadableTextColor(hex?: string): string {
const c = (hex || '').trim();
const m = c.match(/^#?([a-fA-F0-9]{6})$/);
let r=102, g=126, b=234; // fallback to #667eea
if (m) {
const int = parseInt(m[1], 16);
r = (int >> 16) & 255; g = (int >> 8) & 255; b = int & 255;
}
// Perceived brightness formula
const yiq = (r * 299 + g * 587 + b * 114) / 1000;
return yiq >= 140 ? '#111111' : '#ffffff';
}
</script>
<style scoped>
.rooms-table-container {
background: white;
border-radius: 12px;
padding: 20px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.table-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 2px solid #f0f0f0;
}
.table-header h3 {
margin: 0;
color: #333;
font-size: 1.4rem;
}
.table-controls {
display: flex;
gap: 10px;
}
.btn {
padding: 8px 16px;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.btn-refresh {
background: #f0f0f0;
color: #666;
}
.btn-refresh:hover {
background: #e0e0e0;
}
.btn-details {
background: #9c27b0;
color: white;
padding: 6px 12px;
font-size: 12px;
}
.btn-details:hover {
background: #7b1fa2;
transform: translateY(-1px);
}
.no-rooms {
text-align: center;
padding: 40px;
color: #666;
font-style: italic;
}
.table-wrapper {
overflow-x: auto;
}
.rooms-table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
color: #333;
}
.rooms-table th {
background: #f8f9fa;
padding: 12px 8px;
text-align: left;
font-weight: 600;
color: #495057;
border-bottom: 2px solid #dee2e6;
white-space: nowrap;
}
.rooms-table td {
padding: 12px 8px;
border-bottom: 1px solid #dee2e6;
vertical-align: middle;
color: #333;
}
.room-row:hover {
background: #f8f9fa;
}
.room-id code {
background: #e9ecef;
padding: 4px 8px;
border-radius: 4px;
font-family: 'Monaco', 'Menlo', monospace;
font-size: 12px;
}
.status-badge {
padding: 4px 12px;
border-radius: 12px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
white-space: nowrap;
}
.status-waiting {
background: #e8f5e9;
color: #4caf50;
}
.status-playing {
background: #e3f2fd;
color: #2196f3;
}
.status-paused {
background: #fff3e0;
color: #ff9800;
}
.status-finished {
background: #f3e5f5;
color: #9c27b0;
}
.players-count {
font-weight: 600;
text-align: center;
color: #333;
}
.round-info {
text-align: center;
font-weight: 600;
color: #333;
}
.variant-badge {
background: #e1f5fe;
color: #0277bd;
padding: 2px 8px;
border-radius: 8px;
font-size: 11px;
font-weight: 600;
}
.tokens-cell {
min-width: 160px;
}
.player-section {
display: flex;
flex-direction: column;
gap: 6px;
align-items: flex-start;
}
.player-name-chip {
padding: 3px 8px;
border-radius: 12px;
font-size: 11px;
font-weight: 600;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 120px;
}
.token-summary {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 4px;
justify-content: flex-start;
}
.token {
display: inline-flex;
align-items: center;
background: #f8f9fa;
padding: 2px 6px;
border-radius: 8px;
font-size: 11px;
font-weight: 600;
border: 1px solid #e9ecef;
gap: 2px;
}
.token :deep(.anim-number) {
font-size: inherit;
font-weight: inherit;
height: auto;
line-height: inherit;
}
.token :deep(.viewport) {
height: auto;
line-height: inherit;
}
.token :deep(.val) {
line-height: inherit;
}
.token.pavo {
color: #d84315;
}
.token.elote {
color: #f57f17;
}
.token.shame {
color: #c62828;
}
.no-data {
color: #999;
font-style: italic;
text-align: center;
}
.created-time {
color: #555;
font-size: 12px;
white-space: nowrap;
}
.system-message-cell {
min-width: 160px;
max-width: 200px;
padding: 8px;
}
.actions-cell {
text-align: center;
white-space: nowrap;
}
/* Responsive adjustments */
@media (max-width: 1200px) {
.rooms-table {
font-size: 12px;
}
.rooms-table th,
.rooms-table td {
padding: 8px 6px;
}
.token-summary {
gap: 1px;
}
.token {
font-size: 10px;
padding: 1px 4px;
}
.token :deep(.anim-number) {
font-size: inherit;
}
.system-message-cell {
min-width: 120px;
max-width: 150px;
}
}
</style>

View File

@@ -0,0 +1,150 @@
<template>
<div class="system-message-container">
<AnimatedNumber
:value="messageCounter"
:duration-ms="600"
@value-change="onMessageChange"
/>
<div v-if="currentMessage" :class="['system-message', `kind-${currentMessage.kind}`]">
{{ currentMessage.text }}
</div>
<div v-else class="system-message no-message">
Waiting for activity...
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch, computed } from 'vue';
import AnimatedNumber from '../views/games/AnimatedNumber.vue';
interface SystemMessage {
text: string;
kind: string;
timestamp: number;
}
const props = defineProps<{
message?: SystemMessage | null;
}>();
// Use a counter that increments when message changes to trigger animation
const messageCounter = ref(0);
const currentMessage = ref<SystemMessage | null>(null);
// Watch for message changes
watch(() => props.message, (newMessage) => {
if (newMessage && newMessage.timestamp !== currentMessage.value?.timestamp) {
currentMessage.value = newMessage;
messageCounter.value++; // This triggers the AnimatedNumber animation
}
}, { immediate: true });
function onMessageChange() {
// This fires when the AnimatedNumber animation completes
// Could add additional effects here
}
</script>
<style scoped>
.system-message-container {
position: relative;
min-height: 24px;
max-width: 200px;
}
.system-message-container :deep(.anim-number) {
position: absolute;
visibility: hidden;
pointer-events: none;
}
.system-message {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 4px 8px;
font-size: 11px;
font-weight: 500;
color: #495057;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
animation: messageSlideIn 0.6s ease-out;
}
@keyframes messageSlideIn {
0% {
opacity: 0;
transform: translateY(-8px) scale(0.95);
}
100% {
opacity: 1;
transform: translateY(0) scale(1);
}
}
/* Different styles for different message kinds */
.kind-p2_accept {
background: linear-gradient(135deg, #d4edda 0%, #c3e6cb 100%);
border-color: #28a745;
color: #155724;
}
.kind-p2_reject {
background: linear-gradient(135deg, #fff3cd 0%, #ffeaa7 100%);
border-color: #ffc107;
color: #856404;
}
.kind-p2_snatch {
background: linear-gradient(135deg, #f8d7da 0%, #f5c6cb 100%);
border-color: #dc3545;
color: #721c24;
}
.kind-p1_propose {
background: linear-gradient(135deg, #cce5ff 0%, #b3d9ff 100%);
border-color: #007bff;
color: #004085;
}
.kind-p1_no_offer {
background: linear-gradient(135deg, #e2e3e5 0%, #d1ecf1 100%);
border-color: #6c757d;
color: #383d41;
}
.kind-p1_shame {
background: linear-gradient(135deg, #fff3cd 0%, #ffeaa7 100%);
border-color: #ffc107;
color: #856404;
}
.kind-p1_report {
background: linear-gradient(135deg, #e7e3ff 0%, #d1c4e9 100%);
border-color: #9c27b0;
color: #4a148c;
}
.kind-variant_change {
background: linear-gradient(135deg, #e1f5fe 0%, #b3e5fc 100%);
border-color: #03a9f4;
color: #01579b;
}
.kind-round_advance {
background: linear-gradient(135deg, #e8f5e8 0%, #c8e6c9 100%);
border-color: #4caf50;
color: #1b5e20;
}
.no-message {
background: #f8f9fa;
border-color: #dee2e6;
color: #6c757d;
font-style: italic;
opacity: 0.7;
}
</style>