From e3228af19677401f180e4368024a8cff45e3e801 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Sun, 5 Jul 2026 19:55:13 +0700 Subject: [PATCH] feat(logging): Implement structured logging across the frontend - Introduced a new `logger` module for structured logging with levels, timestamps, and styled console output. - Replaced ad-hoc console logging with structured logger in various modules including API client, WebSocket, auth, and feature components. - Enhanced logging in `app.rs`, `auth.rs`, `dashboard`, `messages`, `live`, and `polish` features. - Updated UI components to include logging for user interactions and state changes. - Rewrote `app.css` for a premium design overhaul, introducing glassmorphism, gradients, and improved responsiveness. - Added a new `plan.md` file outlining the scope and changes made in this commit. --- services/frontend/frontend/src/api/auth.rs | 3 + services/frontend/frontend/src/api/client.rs | 110 +- services/frontend/frontend/src/api/config.rs | 1 - .../frontend/frontend/src/api/dashboard.rs | 8 + services/frontend/frontend/src/api/mascot.rs | 1 - .../frontend/frontend/src/api/messages.rs | 10 + .../frontend/frontend/src/api/recordings.rs | 1 - services/frontend/frontend/src/api/voice.rs | 1 - services/frontend/frontend/src/app.css | 1669 ++++------------- services/frontend/frontend/src/app.rs | 17 +- services/frontend/frontend/src/auth.rs | 6 + .../dashboard/components/stats_overview.rs | 1 - .../frontend/src/features/dashboard/mod.rs | 28 +- .../frontend/src/features/live/mod.rs | 8 + .../messages/components/message_card.rs | 1 + .../messages/components/message_feed.rs | 1 + .../features/messages/hooks/use_messages.rs | 15 + .../frontend/src/features/messages/mod.rs | 22 +- .../polish/components/theme_toggle.rs | 4 + .../frontend/src/features/polish/mod.rs | 10 +- .../frontend/src/layout/dashboard_layout.rs | 1 + .../frontend/frontend/src/layout/header.rs | 1 + .../frontend/src/layout/mobile_tab_bar.rs | 1 + .../frontend/frontend/src/layout/sidebar.rs | 1 + .../frontend/frontend/src/layout/tab_strip.rs | 1 + services/frontend/frontend/src/lib.rs | 9 +- services/frontend/frontend/src/logger.rs | 177 ++ services/frontend/frontend/src/ui/button.rs | 1 + services/frontend/frontend/src/ui/card.rs | 1 + .../frontend/frontend/src/ui/empty_state.rs | 1 + services/frontend/frontend/src/ui/modal.rs | 1 + services/frontend/frontend/src/ui/toast.rs | 16 + services/frontend/frontend/src/ws/context.rs | 15 +- services/frontend/frontend/src/ws/socket.rs | 24 +- 34 files changed, 767 insertions(+), 1400 deletions(-) create mode 100644 services/frontend/frontend/src/logger.rs diff --git a/services/frontend/frontend/src/api/auth.rs b/services/frontend/frontend/src/api/auth.rs index 8fbf9ae..e52090f 100644 --- a/services/frontend/frontend/src/api/auth.rs +++ b/services/frontend/frontend/src/api/auth.rs @@ -1,5 +1,8 @@ use crate::api::client::{request, ApiError}; use serde::{Deserialize, Serialize}; +use crate::{log_error, log_info, log_warn, make_logger}; + +make_logger!(); #[derive(Serialize)] struct LoginPayload { diff --git a/services/frontend/frontend/src/api/client.rs b/services/frontend/frontend/src/api/client.rs index 98ffba6..7138509 100644 --- a/services/frontend/frontend/src/api/client.rs +++ b/services/frontend/frontend/src/api/client.rs @@ -2,6 +2,9 @@ use serde::de::DeserializeOwned; use wasm_bindgen::prelude::*; use wasm_bindgen_futures::JsFuture; use web_sys::{Headers, Request, RequestInit, RequestMode, Response}; +use crate::{log_debug, log_error, make_logger}; + +make_logger!(); #[derive(Debug)] pub struct ApiError { @@ -44,9 +47,13 @@ pub async fn request( ) -> Result { let url = format!("{}{}", get_base_url(), path); - let headers = Headers::new().map_err(|_| ApiError { - message: "Failed to create headers".to_string(), - status_code: 0, + let headers = Headers::new().map_err(|_| { + let msg = "Failed to create headers"; + log_error!("API {} {} failed: {}", method, path, msg); + ApiError { + message: msg.to_string(), + status_code: 0, + } })?; if let Some(password) = get_auth_header() { @@ -57,6 +64,8 @@ pub async fn request( headers.set("Content-Type", "application/json").ok(); } + log_debug!("{} {} ->", method, path); + let opts = RequestInit::new(); opts.set_method(method); opts.set_headers(&headers); @@ -66,67 +75,106 @@ pub async fn request( opts.set_body(&JsValue::from_str(json_body)); } - let request = Request::new_with_str_and_init(&url, &opts).map_err(|e| ApiError { - message: format!("Failed to create request: {:?}", e), - status_code: 0, + let request = Request::new_with_str_and_init(&url, &opts).map_err(|e| { + let msg = format!("Failed to create request: {:?}", e); + log_error!("API {} {} failed: {}", method, path, msg); + ApiError { + message: msg, + status_code: 0, + } })?; - let window = web_sys::window().ok_or(ApiError { - message: "No window".to_string(), - status_code: 0, + let window = web_sys::window().ok_or_else(|| { + let msg = "No window"; + log_error!("API {} {} failed: {}", method, path, msg); + ApiError { + message: msg.to_string(), + status_code: 0, + } })?; let resp_value = JsFuture::from(window.fetch_with_request(&request)) .await - .map_err(|e| ApiError { - message: format!("Fetch failed: {:?}", e), - status_code: 0, + .map_err(|e| { + let msg = format!("Fetch failed: {:?}", e); + log_error!("API {} {} failed: {}", method, path, msg); + ApiError { + message: msg, + status_code: 0, + } })?; - let response: Response = resp_value.dyn_into().map_err(|_| ApiError { - message: "Invalid response".to_string(), - status_code: 0, + let response: Response = resp_value.dyn_into().map_err(|_| { + let msg = "Invalid response"; + log_error!("API {} {} failed: {}", method, path, msg); + ApiError { + message: msg.to_string(), + status_code: 0, + } })?; let status = response.status(); if status >= 400 { - let text = JsFuture::from(response.text().map_err(|_| ApiError { - message: "Failed to read error body".to_string(), - status_code: status, + let text = JsFuture::from(response.text().map_err(|_| { + let msg = "Failed to read error body"; + log_error!("API {} {} failed: {}", method, path, msg); + ApiError { + message: msg.to_string(), + status_code: status, + } })?) .await .ok() .and_then(|v| v.as_string()) .unwrap_or_default(); + log_error!("API {} {} failed: status={} {}", method, path, status, text); return Err(ApiError { message: text, status_code: status, }); } - let text = JsFuture::from(response.text().map_err(|_| ApiError { - message: "Failed to read response body".to_string(), - status_code: status, + let text = JsFuture::from(response.text().map_err(|_| { + let msg = "Failed to read response body"; + log_error!("API {} {} failed: {}", method, path, msg); + ApiError { + message: msg.to_string(), + status_code: status, + } })?) .await - .map_err(|_| ApiError { - message: "Failed to await response".to_string(), - status_code: status, + .map_err(|_| { + let msg = "Failed to await response"; + log_error!("API {} {} failed: {}", method, path, msg); + ApiError { + message: msg.to_string(), + status_code: status, + } })? .as_string() - .ok_or(ApiError { - message: "Response is not text".to_string(), - status_code: status, + .ok_or_else(|| { + let msg = "Response is not text"; + log_error!("API {} {} failed: {}", method, path, msg); + ApiError { + message: msg.to_string(), + status_code: status, + } })?; - serde_json::from_str(&text).map_err(|e| ApiError { - message: format!( + log_debug!("{} {} <- {}", method, path, status); + + serde_json::from_str(&text).map_err(|e| { + let msg = format!( "JSON parse error: {} — body: {}", e, &text[..text.len().min(200)] - ), - status_code: status, + ); + log_error!("API {} {} failed: {}", method, path, msg); + ApiError { + message: msg, + status_code: status, + } }) } diff --git a/services/frontend/frontend/src/api/config.rs b/services/frontend/frontend/src/api/config.rs index 5e6fa68..61dab4e 100644 --- a/services/frontend/frontend/src/api/config.rs +++ b/services/frontend/frontend/src/api/config.rs @@ -1,6 +1,5 @@ use crate::api::client::{request, ApiError}; use serde::Deserialize; - #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AppConfigResponse { diff --git a/services/frontend/frontend/src/api/dashboard.rs b/services/frontend/frontend/src/api/dashboard.rs index 3fe7da6..5a046c8 100644 --- a/services/frontend/frontend/src/api/dashboard.rs +++ b/services/frontend/frontend/src/api/dashboard.rs @@ -1,8 +1,12 @@ use crate::api::client::{request, ApiError}; use shared_types::dashboard::*; +use crate::{log_debug, make_logger}; + +make_logger!(); /// GET /api/dashboard/stats pub async fn get_dashboard_stats() -> Result { + log_debug!("get_dashboard_stats"); request("GET", "/api/dashboard/stats", None).await } @@ -12,6 +16,7 @@ pub async fn get_dashboard_users( cursor: Option<&str>, search: Option<&str>, ) -> Result { + log_debug!("get_dashboard_users: limit={:?}, cursor={:?}, search={:?}", limit, cursor, search); let mut path = "/api/dashboard/users".to_string(); let mut params = vec![]; if let Some(l) = limit { @@ -38,6 +43,7 @@ pub struct PaginatedUsers { /// GET /api/dashboard/users/{userId} pub async fn get_dashboard_user_detail(user_id: &str) -> Result { + log_debug!("get_dashboard_user_detail: user_id={}", user_id); request("GET", &format!("/api/dashboard/users/{}", user_id), None).await } @@ -48,6 +54,7 @@ pub async fn get_dashboard_channels( search: Option<&str>, guild_id: Option<&str>, ) -> Result { + log_debug!("get_dashboard_channels: limit={:?}, cursor={:?}, search={:?}, guild_id={:?}", limit, cursor, search, guild_id); let mut path = "/api/dashboard/channels".to_string(); let mut params = vec![]; if let Some(l) = limit { @@ -79,6 +86,7 @@ pub struct PaginatedChannels { pub async fn get_dashboard_channel_detail( channel_id: &str, ) -> Result { + log_debug!("get_dashboard_channel_detail: channel_id={}", channel_id); request( "GET", &format!("/api/dashboard/channels/{}", channel_id), diff --git a/services/frontend/frontend/src/api/mascot.rs b/services/frontend/frontend/src/api/mascot.rs index 53d03d1..2df4bf5 100644 --- a/services/frontend/frontend/src/api/mascot.rs +++ b/services/frontend/frontend/src/api/mascot.rs @@ -1,6 +1,5 @@ use crate::api::client::{request, ApiError}; use serde::{Deserialize, Serialize}; - #[derive(Debug, Serialize)] struct MascotChatRequest<'a> { message: &'a str, diff --git a/services/frontend/frontend/src/api/messages.rs b/services/frontend/frontend/src/api/messages.rs index 750d11f..403ec1c 100644 --- a/services/frontend/frontend/src/api/messages.rs +++ b/services/frontend/frontend/src/api/messages.rs @@ -1,5 +1,8 @@ use crate::api::client::{request, ApiError}; use shared_types::message::{MessageRecord, PageResult}; +use crate::{log_debug, make_logger}; + +make_logger!(); /// GET /api/messages?guildId=&limit=&channelId=&cursor= pub async fn get_messages( @@ -8,6 +11,7 @@ pub async fn get_messages( channel_id: Option<&str>, cursor: Option<&str>, ) -> Result, ApiError> { + log_debug!("get_messages: guild_id={}, limit={:?}, channel_id={:?}, cursor={:?}", guild_id, limit, channel_id, cursor); let mut path = format!("/api/messages?guildId={}", guild_id); if let Some(l) = limit { path.push_str(&format!("&limit={}", l)); @@ -27,6 +31,7 @@ pub async fn get_review_messages( limit: Option, channel_id: Option<&str>, ) -> Result, ApiError> { + log_debug!("get_review_messages: limit={:?}, channel_id={:?}", limit, channel_id); let mut path = "/api/review".to_string(); let mut params = vec![]; if let Some(l) = limit { @@ -46,6 +51,7 @@ pub async fn get_images( guild_id: &str, limit: Option, ) -> Result, ApiError> { + log_debug!("get_images: guild_id={}, limit={:?}", guild_id, limit); let mut path = format!("/api/messages/images?guildId={}", guild_id); if let Some(l) = limit { path.push_str(&format!("&limit={}", l)); @@ -55,11 +61,13 @@ pub async fn get_images( /// GET /api/messages/detail/{id} pub async fn get_message_detail(id: &str) -> Result, ApiError> { + log_debug!("get_message_detail: id={}", id); request("GET", &format!("/api/messages/detail/{}", id), None).await } /// POST /api/messages/{id}/reanalyze pub async fn reanalyze_message(id: &str) -> Result<(), ApiError> { + log_debug!("reanalyze_message: id={}", id); let _: serde_json::Value = request( "POST", &format!("/api/messages/{}/reanalyze", id), @@ -71,6 +79,7 @@ pub async fn reanalyze_message(id: &str) -> Result<(), ApiError> { /// POST /api/messages/reanalyze-batch pub async fn reanalyze_batch() -> Result { + log_debug!("reanalyze_batch"); #[derive(serde::Deserialize)] #[allow(dead_code)] struct BatchResp { @@ -86,6 +95,7 @@ pub async fn search_messages( query: &str, limit: Option, ) -> Result, ApiError> { + log_debug!("search_messages: query={}, limit={:?}", query, limit); #[derive(serde::Deserialize)] struct SearchResult { results: Vec, diff --git a/services/frontend/frontend/src/api/recordings.rs b/services/frontend/frontend/src/api/recordings.rs index 2d3a812..b7dd738 100644 --- a/services/frontend/frontend/src/api/recordings.rs +++ b/services/frontend/frontend/src/api/recordings.rs @@ -1,6 +1,5 @@ use crate::api::client::{request, request_no_body, ApiError}; use shared_types::recording::VoiceRecordingListResponse; - /// GET /api/recordings?limit=&cursor= pub async fn get_recordings( limit: Option, diff --git a/services/frontend/frontend/src/api/voice.rs b/services/frontend/frontend/src/api/voice.rs index f1578d4..04a85c8 100644 --- a/services/frontend/frontend/src/api/voice.rs +++ b/services/frontend/frontend/src/api/voice.rs @@ -3,7 +3,6 @@ use serde::Serialize; use shared_types::guild::{Channel, Guild}; use shared_types::media::MediaState; use shared_types::voice::VoiceStatus; - /// GET /api/guilds pub async fn get_guilds() -> Result, ApiError> { request("GET", "/api/guilds", None).await diff --git a/services/frontend/frontend/src/app.css b/services/frontend/frontend/src/app.css index 443fe8d..b405c0c 100644 --- a/services/frontend/frontend/src/app.css +++ b/services/frontend/frontend/src/app.css @@ -1,80 +1,73 @@ -/* ── IMPHNEN Design System ────────────────────────────── * - * Brand: Approachable Modernism * - * Primary: #23a1eb | Secondary: #1877f2 | Tertiary: #5865f2 * - * Font: Poppins (300–800) * - * Rounded: sm=4px, md=8px, lg=16px, xl=24px, full=9999px * - * Spacing unit: 8px * +/* ── IMPHNEN Design System v3 — Premium Glass ──────────── * + * Brand: Approachable Modernism × Glass Aesthetic * + * Primary: #23a1eb → Gradient: #23a1eb → #7b86f5 * + * Font: Poppins (300–800) * + * Rounded: sm=6px, md=10px, lg=16px, xl=24px, full=9999px* + * Spacing unit: 8px * * ─────────────────────────────────────────────────────── */ /* ── Design Tokens ────────────────────────────────────── */ :root { - /* Brand — Primary */ --color-primary: #23a1eb; --color-primary-hover: #1a8fd9; --color-primary-active: #0877c1; --color-primary-muted: rgba(35, 161, 235, 0.12); + --gradient-primary: linear-gradient(135deg, #23a1eb 0%, #7b86f5 100%); + --gradient-primary-hover: linear-gradient(135deg, #1a8fd9 0%, #6b76f0 100%); + --gradient-brand: linear-gradient(135deg, #23a1eb 0%, #5865f2 50%, #7b86f5 100%); - /* Brand — Secondary (Facebook) */ --color-secondary: #1877f2; --color-secondary-muted: rgba(24, 119, 242, 0.12); - /* Brand — Tertiary (Discord) */ --color-tertiary: #5865f2; - --color-accent: #5865f2; - --color-accent-muted: rgba(88, 101, 242, 0.12); + --color-accent: #7b86f5; + --color-accent-muted: rgba(123, 134, 245, 0.12); - /* Surfaces — light-dominant (spec: clean, bright) */ - --surface-base: #ffffff; + --surface-base: #f4f6fb; --surface-raised: #ffffff; - --surface-overlay: #f5f5f5; - --surface-container-low: #f0f0f0; - --surface-container: #e8e8e8; - --surface-border: rgba(0, 0, 0, 0.08); - --surface-hover: rgba(0, 0, 0, 0.03); - --surface-glass: rgba(255, 255, 255, 0.85); + --surface-overlay: #eeeff4; + --surface-container-low: #e8eaf0; + --surface-container: #dde0e8; + --surface-border: rgba(0, 0, 0, 0.06); + --surface-hover: rgba(35, 161, 235, 0.05); + --surface-glass: rgba(255, 255, 255, 0.72); + --surface-glass-heavy: rgba(255, 255, 255, 0.88); - /* Text */ - --text-primary: #1a1a1a; - --text-secondary: #666666; - --text-tertiary: #999999; + --text-primary: #0f172a; + --text-secondary: #475569; + --text-tertiary: #94a3b8; --text-inverse: #ffffff; - /* Semantic */ --color-success: #10b981; --color-warning: #f59e0b; --color-error: #e4405f; --color-info: #23a1eb; --color-destructive: #e4405f; - /* AI status colors */ --color-ai-flagged: #e4405f; --color-ai-clean: #10b981; --color-ai-warn: #f59e0b; - --color-ai-pending: #999999; + --color-ai-pending: #94a3b8; --color-ai-processing: #23a1eb; --color-ai-error: #dc2626; --color-ai-deleted: #6b7280; - /* Shadows — spec elevation stack */ - --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.06); - --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08); - --shadow-lg: 0 16px 40px rgba(0, 0, 0, 0.12); - --shadow-xl: 0 16px 40px rgba(0, 0, 0, 0.12); + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.04), 0 1px 2px rgba(0, 0, 0, 0.03); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.06), 0 2px 4px rgba(0, 0, 0, 0.04); + --shadow-lg: 0 12px 40px rgba(0, 0, 0, 0.08), 0 4px 12px rgba(0, 0, 0, 0.04); + --shadow-xl: 0 24px 60px rgba(0, 0, 0, 0.10), 0 8px 24px rgba(0, 0, 0, 0.06); - /* Glows */ - --glow-primary: 0 0 20px rgba(35, 161, 235, 0.15); - --glow-error: 0 0 20px rgba(228, 64, 95, 0.12); - --glow-success: 0 0 20px rgba(16, 185, 129, 0.12); + --glow-primary: 0 0 24px rgba(35, 161, 235, 0.20); + --glow-error: 0 0 24px rgba(228, 64, 95, 0.18); + --glow-success: 0 0 24px rgba(16, 185, 129, 0.18); + --glow-accent: 0 0 24px rgba(123, 134, 245, 0.20); - /* Radii — spec: sm=0.25rem, DEFAULT=0.5rem, md=0.75rem, lg=1rem, xl=1.5rem */ - --radius-sm: 4px; - --radius-md: 8px; + --radius-sm: 6px; + --radius-md: 10px; --radius-lg: 16px; --radius-xl: 24px; --radius-full: 9999px; - /* Spacing — 8px unit scale (spec: unit=8px, xs=4px, sm=12px, md=24px, lg=40px, xl=64px) */ - --px: 1px; --space-0: 0px; --space-1: 4px; --space-2: 8px; @@ -87,17 +80,15 @@ --space-12: 48px; --space-16: 64px; - /* Layout */ --sidebar-width: 240px; --sidebar-collapsed-width: 64px; - --header-height: 56px; + --header-height: 60px; - /* Transitions — spec: 150ms cubic-bezier(0.4, 0, 0.2, 1) */ --transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1); - --transition-normal: 250ms cubic-bezier(0.4, 0, 0.2, 1); - --transition-slow: 400ms cubic-bezier(0.4, 0, 0.2, 1); + --transition-normal: 300ms cubic-bezier(0.4, 0, 0.2, 1); + --transition-slow: 500ms cubic-bezier(0.4, 0, 0.2, 1); + --transition-bounce: 400ms cubic-bezier(0.34, 1.56, 0.64, 1); - /* Z-index */ --z-sidebar: 30; --z-header: 40; --z-overlay: 50; @@ -105,100 +96,76 @@ --z-toast: 70; } -/* ── Dark Theme ─────────────────────────────────────────── */ [data-theme="dark"] { --color-primary: #54a2ff; --color-primary-hover: #7ab7ff; --color-primary-active: #3d8eff; --color-primary-muted: rgba(84, 162, 255, 0.15); + --gradient-primary: linear-gradient(135deg, #54a2ff 0%, #9da6ff 100%); + --gradient-brand: linear-gradient(135deg, #54a2ff 0%, #7b86f5 50%, #9da6ff 100%); --color-secondary: #4a8cff; --color-secondary-muted: rgba(74, 140, 255, 0.15); - --color-tertiary: #7b86f5; - --color-accent: #7b86f5; - --color-accent-muted: rgba(123, 134, 245, 0.15); + --color-accent: #9da6ff; + --color-accent-muted: rgba(157, 166, 255, 0.15); - /* Surfaces — dark inversion */ - --surface-base: #0d1117; - --surface-raised: #161b22; - --surface-overlay: #1c2333; - --surface-container-low: #21283a; - --surface-container: #2d3548; + --surface-base: #080c14; + --surface-raised: #0f1522; + --surface-overlay: #161e30; + --surface-container-low: #1c2540; + --surface-container: #253050; --surface-border: rgba(255, 255, 255, 0.06); - --surface-hover: rgba(255, 255, 255, 0.04); - --surface-glass: rgba(13, 17, 23, 0.82); + --surface-hover: rgba(84, 162, 255, 0.06); + --surface-glass: rgba(8, 12, 20, 0.78); + --surface-glass-heavy: rgba(8, 12, 20, 0.90); - /* Text */ - --text-primary: #e6edf5; - --text-secondary: #8b95a6; - --text-tertiary: #5b6577; - --text-inverse: #0d1117; + --text-primary: #e2e8f0; + --text-secondary: #8892a8; + --text-tertiary: #4a5470; + --text-inverse: #080c14; - /* Shadows — deeper for dark */ - --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3); + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.30); --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.35); - --shadow-lg: 0 16px 40px rgba(0, 0, 0, 0.4); - --shadow-xl: 0 16px 40px rgba(0, 0, 0, 0.45); + --shadow-lg: 0 12px 40px rgba(0, 0, 0, 0.40); + --shadow-xl: 0 24px 60px rgba(0, 0, 0, 0.45); - --glow-primary: 0 0 24px rgba(84, 162, 255, 0.2); - --glow-error: 0 0 24px rgba(228, 64, 95, 0.18); - --glow-success: 0 0 24px rgba(16, 185, 129, 0.18); + --glow-primary: 0 0 30px rgba(84, 162, 255, 0.20); + --glow-error: 0 0 30px rgba(240, 82, 115, 0.18); + --glow-success: 0 0 30px rgba(16, 185, 129, 0.18); + --glow-accent: 0 0 30px rgba(157, 166, 255, 0.20); - /* Errors stay vibrant in dark */ --color-error: #f05273; --color-destructive: #f05273; } -/* ── Base ─────────────────────────────────────────────── */ -*, *::before, *::after { - box-sizing: border-box; - margin: 0; - padding: 0; -} - -html { - font-size: 16px; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -body { - font-family: 'Poppins', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; - line-height: 1.6; - min-height: 100vh; - overflow-x: hidden; -} - +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } +html { font-size: 16px; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } +body { font-family: 'Poppins', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; line-height: 1.6; min-height: 100vh; overflow-x: hidden; background: var(--surface-base); } a { color: var(--color-primary); text-decoration: none; transition: color var(--transition-fast); } -a:hover { color: var(--color-primary-hover); text-decoration: underline; } +a:hover { color: var(--color-primary-hover); } img { max-width: 100%; height: auto; } svg { display: inline-block; vertical-align: middle; } -/* ── Scrollbar ────────────────────────────────────────── */ -::-webkit-scrollbar { width: 4px; height: 4px; } +::-webkit-scrollbar { width: 6px; height: 6px; } ::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-thumb { background: var(--color-primary); border-radius: var(--radius-full); } +::selection { background: rgba(35, 161, 235, 0.25); color: var(--text-primary); } +[data-theme="dark"] ::selection { background: rgba(84, 162, 255, 0.30); } /* ================================================================ - UTILITY CLASSES (Tailwind-compatible naming) + UTILITY CLASSES ================================================================ */ - -/* Display */ .flex { display: flex; } .inline-flex { display: inline-flex; } .grid { display: grid; } .block { display: block; } .hidden { display: none; } - -/* Flex direction */ .flex-col { flex-direction: column; } .flex-row { flex-direction: row; } .flex-wrap { flex-wrap: wrap; } .flex-1 { flex: 1 1 0%; } .flex-shrink-0, .shrink-0 { flex-shrink: 0; } - -/* Align / Justify */ .items-center { align-items: center; } .items-start { align-items: flex-start; } .items-end { align-items: flex-end; } @@ -206,8 +173,6 @@ svg { display: inline-block; vertical-align: middle; } .justify-center { justify-content: center; } .justify-between { justify-content: space-between; } .justify-end { justify-content: flex-end; } - -/* Gap */ .gap-0 { gap: 0; } .gap-0\.5 { gap: 2px; } .gap-1 { gap: var(--space-1); } @@ -217,20 +182,14 @@ svg { display: inline-block; vertical-align: middle; } .gap-4 { gap: var(--space-4); } .gap-6 { gap: var(--space-6); } .gap-8 { gap: var(--space-8); } - -/* Grid */ .grid-cols-2 { grid-template-columns: repeat(2, 1fr); } .grid-cols-3 { grid-template-columns: repeat(3, 1fr); } - -/* Sizing */ .w-full { width: 100%; } .w-auto { width: auto; } .h-full { height: 100%; } .h-auto { height: auto; } .min-w-0 { min-width: 0; } .min-h-0 { min-height: 0; } - -/* Fixed size classes (height/width as numbers) */ .h-3 { height: 12px; } .h-4 { height: 16px; } .h-5 { height: 20px; } @@ -249,8 +208,6 @@ svg { display: inline-block; vertical-align: middle; } .w-12 { width: 48px; } .w-16 { width: 64px; } .w-48 { width: 192px; } - -/* Position */ .relative { position: relative; } .absolute { position: absolute; } .fixed { position: fixed; } @@ -260,19 +217,13 @@ svg { display: inline-block; vertical-align: middle; } .right-0 { right: 0; } .bottom-0 { bottom: 0; } .left-0 { left: 0; } - -/* Overflow */ .overflow-auto { overflow: auto; } .overflow-hidden { overflow: hidden; } .overflow-y-auto { overflow-y: auto; } .overflow-x-auto { overflow-x: auto; } - -/* Z-index */ .z-0 { z-index: 0; } .z-10 { z-index: 10; } .z-50 { z-index: 50; } - -/* Margin */ .m-0 { margin: 0; } .mx-auto { margin-left: auto; margin-right: auto; } .ml-auto { margin-left: auto; } @@ -295,8 +246,6 @@ svg { display: inline-block; vertical-align: middle; } .mr-1 { margin-right: var(--space-1); } .mr-2 { margin-right: var(--space-2); } .mr-1\.5 { margin-right: 6px; } - -/* Padding */ .p-0 { padding: 0; } .p-1 { padding: var(--space-1); } .p-2 { padding: var(--space-2); } @@ -322,8 +271,6 @@ svg { display: inline-block; vertical-align: middle; } .pb-4 { padding-bottom: var(--space-4); } .pl-2 { padding-left: var(--space-2); } .pr-2 { padding-right: var(--space-2); } - -/* Border / Outline */ .border { border: var(--px) solid var(--surface-border); } .border-0 { border: none; } .border-t { border-top: var(--px) solid var(--surface-border); } @@ -335,10 +282,7 @@ svg { display: inline-block; vertical-align: middle; } .border-border\/30 { border-color: rgba(0, 0, 0, 0.03); } .border-border { border-color: var(--surface-border); } .border-primary { border-color: var(--color-primary); } - [data-theme="dark"] .border-border\/30 { border-color: rgba(255, 255, 255, 0.03); } - -/* Border radius */ .rounded-sm { border-radius: var(--radius-sm); } .rounded { border-radius: var(--radius-md); } .rounded-md { border-radius: var(--radius-md); } @@ -346,13 +290,9 @@ svg { display: inline-block; vertical-align: middle; } .rounded-xl { border-radius: var(--radius-xl); } .rounded-full { border-radius: var(--radius-full); } .rounded-t-lg { border-top-left-radius: var(--radius-lg); border-top-right-radius: var(--radius-lg); } - -/* Ring */ -.ring-2 { box-shadow: 0 0 0 2px var(--surface-base), 0 0 0 4px var(--color-primary); } -.ring-primary\/30 { box-shadow: 0 0 0 2px var(--surface-base), 0 0 0 4px rgba(35, 161, 235, 0.3); } -[data-theme="dark"] .ring-primary\/30 { box-shadow: 0 0 0 2px var(--surface-base), 0 0 0 4px rgba(84, 162, 255, 0.3); } - -/* Text alignment */ +.ring-2 { box-shadow: 0 0 0 2px var(--surface-raised), 0 0 0 4px var(--color-primary); } +.ring-primary\/30 { box-shadow: 0 0 0 2px var(--surface-raised), 0 0 0 4px rgba(35, 161, 235, 0.3); } +[data-theme="dark"] .ring-primary\/30 { box-shadow: 0 0 0 2px var(--surface-raised), 0 0 0 4px rgba(84, 162, 255, 0.3); } .text-left { text-align: left; } .text-center { text-align: center; } .text-right { text-align: right; } @@ -360,8 +300,6 @@ svg { display: inline-block; vertical-align: middle; } .whitespace-pre-wrap { white-space: pre-wrap; } .break-words { word-break: break-word; } .truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } - -/* Text size — using Poppins scale */ .text-\[10px\] { font-size: 10px; } .text-xs { font-size: 0.75rem; line-height: 1rem; } .text-sm { font-size: 0.875rem; line-height: 1.25rem; } @@ -375,15 +313,11 @@ svg { display: inline-block; vertical-align: middle; } .leading-5 { line-height: 1.25rem; } .leading-6 { line-height: 1.5rem; } .leading-relaxed { line-height: 1.625; } - -/* Font weight */ .font-normal { font-weight: 400; } .font-medium { font-weight: 500; } .font-semibold { font-weight: 600; } .font-bold { font-weight: 700; } .font-extrabold { font-weight: 800; } - -/* Colors */ .text-primary { color: var(--text-primary); } .text-secondary { color: var(--text-secondary); } .text-tertiary { color: var(--text-tertiary); } @@ -393,8 +327,6 @@ svg { display: inline-block; vertical-align: middle; } .text-success { color: var(--color-success); } .text-warning { color: var(--color-warning); } .text-inverse { color: var(--text-inverse); } - -/* Backgrounds — updated for light-first */ .bg-surface { background: var(--surface-raised); } .bg-surface\/50 { background: rgba(255, 255, 255, 0.5); } .bg-surface\/60 { background: rgba(255, 255, 255, 0.6); } @@ -409,35 +341,26 @@ svg { display: inline-block; vertical-align: middle; } .bg-error\/5 { background: rgba(228, 64, 95, 0.05); } .bg-black { background: #000; } .bg-transparent { background: transparent; } - -[data-theme="dark"] .bg-surface\/50 { background: rgba(22, 27, 34, 0.5); } -[data-theme="dark"] .bg-surface\/60 { background: rgba(22, 27, 34, 0.6); } -[data-theme="dark"] .bg-surface\/80 { background: rgba(22, 27, 34, 0.8); } +[data-theme="dark"] .bg-surface\/50 { background: rgba(15, 21, 34, 0.5); } +[data-theme="dark"] .bg-surface\/60 { background: rgba(15, 21, 34, 0.6); } +[data-theme="dark"] .bg-surface\/80 { background: rgba(15, 21, 34, 0.8); } [data-theme="dark"] .bg-primary\/5 { background: rgba(84, 162, 255, 0.05); } [data-theme="dark"] .bg-primary\/10 { background: rgba(84, 162, 255, 0.1); } [data-theme="dark"] .bg-error\/5 { background: rgba(240, 82, 115, 0.05); } - -/* Opacity */ .opacity-0 { opacity: 0; } .opacity-50 { opacity: 0.5; } .opacity-60 { opacity: 0.6; } .opacity-70 { opacity: 0.7; } .opacity-80 { opacity: 0.8; } .opacity-85 { opacity: 0.85; } - -/* Object fit */ .object-contain { object-fit: contain; } .object-cover { object-fit: cover; } - -/* Cursor */ .cursor-pointer { cursor: pointer; } .cursor-default { cursor: default; } .cursor-not-allowed { cursor: not-allowed; } .select-none { user-select: none; } .pointer-events-none { pointer-events: none; } .pointer-events-auto { pointer-events: auto; } - -/* Transforms & Transitions */ .transition-all { transition: all var(--transition-fast); } .transition-transform { transition: transform var(--transition-fast); } .transition-opacity { transition: opacity var(--transition-fast); } @@ -445,673 +368,254 @@ svg { display: inline-block; vertical-align: middle; } .transition-shadow { transition: box-shadow var(--transition-fast); } .hover\:scale-105:hover { transform: scale(1.05); } .hover\:opacity-80:hover { opacity: 0.8; } - -/* Animation */ .animate-spin { animation: spin 1s linear infinite; } @keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } /* ================================================================ - BUTTON — spec: button-primary / button-secondary / etc. - padding: 12px 24px | height: 44px | rounded-lg (16px) - font: label-md (14px/600/0.02em) | transition: 150ms + BUTTON — Premium Glass ================================================================ */ .btn { - display: inline-flex; - align-items: center; - justify-content: center; - gap: var(--space-2); - padding: 12px 24px; - height: 44px; - border: 1px solid transparent; - border-radius: var(--radius-lg); - font-family: inherit; - font-size: 14px; - font-weight: 600; - line-height: 20px; - letter-spacing: 0.02em; - cursor: pointer; - transition: all var(--transition-fast); - white-space: nowrap; - user-select: none; - -webkit-tap-highlight-color: transparent; + display: inline-flex; align-items: center; justify-content: center; gap: var(--space-2); + padding: 12px 24px; height: 44px; border: 1px solid transparent; border-radius: var(--radius-lg); + font-family: inherit; font-size: 14px; font-weight: 600; line-height: 20px; + letter-spacing: 0.02em; cursor: pointer; transition: all var(--transition-fast); + white-space: nowrap; user-select: none; -webkit-tap-highlight-color: transparent; + text-decoration: none; } .btn:active:not(:disabled) { transform: scale(0.97); } .btn:disabled { opacity: 0.4; cursor: not-allowed; pointer-events: none; } - -/* Primary — spec: bg #23a1eb, text white */ -.btn-primary { background: var(--color-primary); color: white; } -.btn-primary:hover:not(:disabled) { background: var(--color-primary-hover); box-shadow: var(--glow-primary); } -.btn-primary:active:not(:disabled) { background: var(--color-primary-active); } - -/* Secondary — spec: transparent bg, text primary, border outline */ -.btn-secondary { background: transparent; color: var(--color-primary); border-color: #cccccc; } -.btn-secondary:hover:not(:disabled) { background: var(--surface-container-low); border-color: var(--color-primary); color: var(--color-primary); } -.btn-secondary:active:not(:disabled) { background: var(--surface-container); } - -/* Destructive */ -.btn-destructive { background: var(--color-error); color: white; } -.btn-destructive:hover:not(:disabled) { background: #d63856; box-shadow: var(--glow-error); } -.btn-destructive:active:not(:disabled) { background: #c22f4d; } - -/* Outline */ -.btn-outline { background: transparent; border-color: var(--surface-border); color: var(--text-secondary); } -.btn-outline:hover:not(:disabled) { background: var(--surface-hover); border-color: var(--color-primary); color: var(--color-primary); } - -/* Ghost */ -.btn-ghost { background: transparent; color: var(--text-secondary); border: none; height: auto; padding: 0.5rem 0.75rem; } +.btn-primary { background: var(--gradient-primary); color: white; border: none; } +.btn-primary:hover:not(:disabled) { background: var(--gradient-primary-hover); box-shadow: var(--glow-primary); transform: translateY(-1px); } +.btn-secondary { background: transparent; color: var(--color-primary); border: 1.5px solid var(--color-primary); } +.btn-secondary:hover:not(:disabled) { background: var(--color-primary-muted); box-shadow: var(--glow-primary); transform: translateY(-1px); } +.btn-destructive { background: var(--color-error); color: white; border: none; } +.btn-destructive:hover:not(:disabled) { background: #d63856; box-shadow: var(--glow-error); transform: translateY(-1px); } +.btn-outline { background: var(--surface-glass-heavy); border: 1px solid var(--surface-border); color: var(--text-secondary); backdrop-filter: blur(8px); } +.btn-outline:hover:not(:disabled) { background: var(--surface-raised); border-color: var(--color-primary); color: var(--color-primary); box-shadow: var(--shadow-sm); } +.btn-ghost { background: transparent; color: var(--text-secondary); border: none; height: auto; padding: 0.5rem 0.75rem; border-radius: var(--radius-md); } .btn-ghost:hover:not(:disabled) { background: var(--surface-hover); color: var(--text-primary); } - -/* Tertiary — like ghost but with primary color text */ -.btn-tertiary { background: transparent; color: var(--color-primary); border: none; height: auto; padding: 0.5rem 0.75rem; } +.btn-tertiary { background: transparent; color: var(--color-primary); border: none; height: auto; padding: 0.5rem 0.75rem; border-radius: var(--radius-md); } .btn-tertiary:hover:not(:disabled) { background: var(--color-primary-muted); } - -/* Link — inline link style */ .btn-link { background: transparent; color: var(--color-primary); border: none; height: auto; padding: 0; font-weight: 500; letter-spacing: normal; } .btn-link:hover:not(:disabled) { text-decoration: underline; } - -/* Sizes */ .btn-sm { padding: 8px 16px; height: 36px; font-size: 12px; line-height: 16px; border-radius: var(--radius-md); letter-spacing: 0.03em; } .btn-lg { padding: 14px 28px; height: 48px; font-size: 16px; border-radius: var(--radius-lg); } - -/* Icon buttons */ .btn-icon { width: 44px; height: 44px; padding: 0; display: inline-flex; align-items: center; justify-content: center; border-radius: var(--radius-lg); font-size: 1.25rem; } .btn-icon-sm { width: 36px; height: 36px; padding: 0; display: inline-flex; align-items: center; justify-content: center; border-radius: var(--radius-md); font-size: 1rem; } /* ================================================================ - BADGE — spec: primary-container bg, on-primary-container text - rounded-full | padding: 4px 12px | label-sm (12px/500/0.03em) | inline-flex + BADGE — Premium ================================================================ */ .badge { - display: inline-flex; - align-items: center; - padding: 4px 12px; - border-radius: var(--radius-full); - font-size: 12px; - font-weight: 500; - line-height: 16px; - letter-spacing: 0.03em; - background: var(--surface-container); - color: var(--text-secondary); - border: none; + display: inline-flex; align-items: center; padding: 4px 12px; border-radius: var(--radius-full); + font-size: 12px; font-weight: 500; line-height: 16px; letter-spacing: 0.03em; + background: var(--surface-container); color: var(--text-secondary); border: none; + transition: all var(--transition-fast); } -.badge-primary { background: rgba(35, 161, 235, 0.12); color: #0d4a7a; } -.badge-success { background: rgba(16, 185, 129, 0.12); color: #065f46; } -.badge-warning { background: rgba(245, 158, 11, 0.12); color: #92400e; } -.badge-destructive { background: rgba(228, 64, 95, 0.12); color: #7a1a2e; } +.badge-primary { background: linear-gradient(135deg, rgba(35, 161, 235, 0.15), rgba(123, 134, 245, 0.10)); color: var(--color-primary); } +.badge-success { background: rgba(16, 185, 129, 0.12); color: #059669; } +.badge-warning { background: rgba(245, 158, 11, 0.12); color: #d97706; } +.badge-destructive { background: rgba(228, 64, 95, 0.12); color: #e11d48; } .badge-outline { background: transparent; border: 1px solid var(--surface-border); color: var(--text-tertiary); } -.badge-info { background: rgba(35, 161, 235, 0.12); color: #0d4a7a; } -.badge-secondary { background: rgba(24, 119, 242, 0.12); color: #003d99; } - -[data-theme="dark"] .badge-primary { background: rgba(84, 162, 255, 0.15); color: #bce1fb; } -[data-theme="dark"] .badge-success { background: rgba(16, 185, 129, 0.15); color: #a7f3d0; } -[data-theme="dark"] .badge-warning { background: rgba(245, 158, 11, 0.15); color: #fde68a; } -[data-theme="dark"] .badge-destructive { background: rgba(240, 82, 115, 0.15); color: #fecdd3; } -[data-theme="dark"] .badge-info { background: rgba(84, 162, 255, 0.15); color: #bce1fb; } -[data-theme="dark"] .badge-secondary { background: rgba(74, 140, 255, 0.15); color: #cce0ff; } +.badge-info { background: rgba(35, 161, 235, 0.12); color: var(--color-primary); } +.badge-secondary { background: rgba(24, 119, 242, 0.12); color: var(--color-secondary); } +[data-theme="dark"] .badge-primary { background: linear-gradient(135deg, rgba(84, 162, 255, 0.18), rgba(157, 166, 255, 0.12)); color: #7ab7ff; } +[data-theme="dark"] .badge-success { background: rgba(16, 185, 129, 0.15); color: #6ee7b7; } +[data-theme="dark"] .badge-warning { background: rgba(245, 158, 11, 0.15); color: #fcd34d; } +[data-theme="dark"] .badge-destructive { background: rgba(240, 82, 115, 0.15); color: #fda4af; } +[data-theme="dark"] .badge-info { background: rgba(84, 162, 255, 0.15); color: #7ab7ff; } +[data-theme="dark"] .badge-secondary { background: rgba(74, 140, 255, 0.15); color: #93c5fd; } /* ================================================================ - CARD — spec: rounded-xl (24px) | padding md (24px) | border - bg surface | shadow-sm | transition + CARD — Glass Premium ================================================================ */ .card { - background: var(--surface-raised); - border: 1px solid #cccccc; - border-radius: var(--radius-xl); - overflow: hidden; - box-shadow: var(--shadow-sm); - transition: all 300ms cubic-bezier(0.4, 0, 0.2, 1); -} -.card:hover { - border-color: var(--color-primary); - box-shadow: var(--shadow-md); -} -.card-header { - padding: var(--space-4) var(--space-6); - border-bottom: var(--px) solid var(--surface-border); -} -.card-title { - font-size: 1rem; - font-weight: 600; - color: var(--text-primary); -} -.card-description { - font-size: 0.875rem; - color: var(--text-secondary); - margin-top: var(--space-1); + background: var(--surface-glass-heavy); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); + border: 1px solid var(--surface-border); border-radius: var(--radius-xl); overflow: hidden; + box-shadow: var(--shadow-sm); transition: all var(--transition-normal); } +.card:hover { border-color: rgba(35, 161, 235, 0.12); box-shadow: var(--shadow-md); transform: translateY(-1px); } +.card-header { padding: var(--space-4) var(--space-6); border-bottom: var(--px) solid var(--surface-border); } +.card-title { font-size: 1rem; font-weight: 600; color: var(--text-primary); letter-spacing: -0.01em; } +.card-description { font-size: 0.875rem; color: var(--text-secondary); margin-top: var(--space-1); } .card-content { padding: var(--space-6); } -.card-footer { - padding: var(--space-4) var(--space-6); - border-top: var(--px) solid var(--surface-border); -} +.card-footer { padding: var(--space-4) var(--space-6); border-top: var(--px) solid var(--surface-border); } /* ================================================================ - INPUT FIELD — spec: bg surface-container-low, border outline-variant - rounded DEFAULT (8px) | padding sm (12px) | body-md (16px/400/0.01em) - focus: border primary + ring + INPUT FIELD — Glass ================================================================ */ .input { - display: block; - width: 100%; - padding: var(--space-3); - background: var(--surface-container-low); - border: 1px solid #cccccc; - border-radius: var(--radius-md); - color: var(--text-primary); - font-family: inherit; - font-size: 16px; - font-weight: 400; - line-height: 24px; - letter-spacing: 0.01em; - transition: border-color var(--transition-fast); - outline: none; + display: block; width: 100%; padding: var(--space-3); + background: var(--surface-container-low); border: 1.5px solid transparent; + border-radius: var(--radius-md); color: var(--text-primary); font-family: inherit; + font-size: 16px; font-weight: 400; line-height: 24px; letter-spacing: 0.01em; + transition: all var(--transition-fast); outline: none; } .input::placeholder { color: var(--text-tertiary); } -.input:focus { border-color: var(--color-primary); box-shadow: 0 0 0 3px rgba(35, 161, 235, 0.1); } -.input[aria-invalid="true"], .input-error { border-color: var(--color-error); box-shadow: 0 0 0 3px rgba(228, 64, 95, 0.1); } - -/* Select dropdown */ +.input:focus { border-color: var(--color-primary); box-shadow: 0 0 0 3px rgba(35, 161, 235, 0.12), var(--glow-primary); background: var(--surface-raised); } +.input[aria-invalid="true"], .input-error { border-color: var(--color-error); box-shadow: 0 0 0 3px rgba(228, 64, 95, 0.12); } .select { - display: block; - width: 100%; - padding: var(--space-3) 2rem var(--space-3) var(--space-3); - background: var(--surface-container-low); - border: 1px solid #cccccc; - border-radius: var(--radius-md); - color: var(--text-primary); - font-family: inherit; - font-size: 0.875rem; - cursor: pointer; - transition: border-color var(--transition-fast); - outline: none; - appearance: none; - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23999999' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E"); - background-repeat: no-repeat; - background-position: right 0.75rem center; + display: block; width: 100%; padding: var(--space-3) 2rem var(--space-3) var(--space-3); + background: var(--surface-container-low); border: 1.5px solid transparent; + border-radius: var(--radius-md); color: var(--text-primary); font-family: inherit; + font-size: 0.875rem; cursor: pointer; transition: all var(--transition-fast); + outline: none; appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E"); + background-repeat: no-repeat; background-position: right 0.75rem center; } -.select:focus { border-color: var(--color-primary); box-shadow: 0 0 0 3px rgba(35, 161, 235, 0.1); } +.select:focus { border-color: var(--color-primary); box-shadow: 0 0 0 3px rgba(35, 161, 235, 0.12); background: var(--surface-raised); } /* ================================================================ SKELETON ================================================================ */ .skeleton { - background: linear-gradient(110deg, var(--surface-container-low) 30%, var(--surface-hover) 50%, var(--surface-container-low) 70%); - background-size: 200% 100%; - animation: shimmer 1.8s ease-in-out infinite; - border-radius: var(--radius-md); + background: linear-gradient(110deg, var(--surface-container-low) 30%, rgba(35, 161, 235, 0.04) 50%, var(--surface-container-low) 70%); + background-size: 200% 100%; animation: shimmer 1.8s ease-in-out infinite; border-radius: var(--radius-md); } .skeleton-circular { border-radius: 50%; } -@keyframes shimmer { - 0% { background-position: 200% 0; } - 100% { background-position: -200% 0; } -} +@keyframes shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } } /* ================================================================ TABS ================================================================ */ .tabs { display: flex; flex-direction: column; } -.tab-list { - display: flex; - gap: 2px; - padding: 4px; - background: var(--surface-container-low); - border-radius: var(--radius-lg); -} -.tab-trigger { - padding: 8px 16px; - background: transparent; - border: none; - border-radius: var(--radius-md); - color: var(--text-secondary); - font-family: inherit; - font-size: 14px; - font-weight: 500; - cursor: pointer; - transition: all var(--transition-fast); -} +.tab-list { display: flex; gap: 2px; padding: 4px; background: var(--surface-container-low); border-radius: var(--radius-lg); } +.tab-trigger { padding: 8px 16px; background: transparent; border: none; border-radius: var(--radius-md); color: var(--text-secondary); font-family: inherit; font-size: 14px; font-weight: 500; cursor: pointer; transition: all var(--transition-fast); } .tab-trigger:hover { color: var(--text-primary); background: var(--surface-hover); } -.tab-trigger.active, .tab-trigger[aria-selected="true"] { - background: var(--surface-raised); - color: var(--text-primary); - font-weight: 600; - box-shadow: var(--shadow-sm); -} -.tab-content { padding-top: var(--space-4); } +.tab-trigger.active, .tab-trigger[aria-selected="true"] { background: var(--surface-glass-heavy); backdrop-filter: blur(8px); color: var(--color-primary); font-weight: 600; box-shadow: var(--shadow-sm); } +.tab-content { padding-top: var(--space-4); animation: fade-in-up var(--transition-normal) ease-out; } /* ================================================================ APP SHELL ================================================================ */ -.app-shell { - position: relative; - z-index: 1; - display: flex; - flex-direction: column; - height: 100vh; - background: var(--surface-base); - color: var(--text-primary); -} - -.app-header { - height: var(--header-height); - border-bottom: var(--px) solid var(--surface-border); - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--space-4); - padding: 0 var(--space-6); - background: var(--surface-glass); - backdrop-filter: blur(16px); - -webkit-backdrop-filter: blur(16px); - position: sticky; - top: 0; - z-index: var(--z-header); -} - +.app-shell { position: relative; z-index: 1; display: flex; flex-direction: column; height: 100vh; background: var(--surface-base); color: var(--text-primary); } +.app-header { height: var(--header-height); border-bottom: 1px solid var(--surface-border); display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); padding: 0 var(--space-6); background: var(--surface-glass); backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px); position: sticky; top: 0; z-index: var(--z-header); } .app-brand { display: flex; align-items: center; gap: var(--space-3); } -.app-brand-mark { - font-weight: 700; - font-size: 1.125rem; - color: var(--color-primary); - letter-spacing: -0.02em; -} -.app-brand-subtitle { - color: var(--text-tertiary); - font-size: 0.75rem; - font-weight: 500; - padding: 2px 8px; - background: var(--surface-container-low); - border-radius: var(--radius-full); -} - -.app-main { - flex: 1; - min-height: 0; - display: flex; -} - -.app-sidebar { - width: var(--sidebar-width); - flex-shrink: 0; - border-right: var(--px) solid var(--surface-border); - padding: var(--space-3); - background: var(--surface-glass); - backdrop-filter: blur(12px); - -webkit-backdrop-filter: blur(12px); -} - -.app-content { - flex: 1; - min-width: 0; - overflow-y: auto; - padding: var(--space-6); - animation: content-enter 400ms cubic-bezier(0.16, 1, 0.3, 1) both; -} -/* Child panels inside app-content get staggered entrance */ -.app-content > * { animation: card-in 400ms cubic-bezier(0.16, 1, 0.3, 1) both; } +.app-brand-mark { font-weight: 800; font-size: 1.25rem; background: var(--gradient-brand); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; letter-spacing: -0.03em; } +.app-brand-subtitle { color: var(--text-tertiary); font-size: 0.6875rem; font-weight: 500; text-transform: uppercase; letter-spacing: 0.08em; padding: 3px 10px; background: var(--surface-container-low); border-radius: var(--radius-full); border: 1px solid var(--surface-border); } +.app-main { flex: 1; min-height: 0; display: flex; position: relative; } +.app-sidebar { width: var(--sidebar-width); flex-shrink: 0; border-right: 1px solid var(--surface-border); padding: var(--space-3); background: var(--surface-glass); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); display: flex; flex-direction: column; gap: var(--space-1); } +.app-content { flex: 1; min-width: 0; overflow-y: auto; padding: var(--space-6); animation: content-enter 500ms cubic-bezier(0.16, 1, 0.3, 1) both; } +.app-content > * { animation: card-in 500ms cubic-bezier(0.16, 1, 0.3, 1) both; } .app-content > :nth-child(1) { animation-delay: 30ms; } .app-content > :nth-child(2) { animation-delay: 60ms; } .app-content > :nth-child(3) { animation-delay: 90ms; } - -/* ── Theme Toggle (spec: ghost button style) ───── */ -.theme-toggle { - width: 44px; - height: 44px; - border: 1px solid #cccccc; - border-radius: var(--radius-lg); - background: transparent; - color: var(--text-tertiary); - cursor: pointer; - font-size: 1.25rem; - transition: all var(--transition-fast); - -webkit-tap-highlight-color: transparent; -} -.theme-toggle:hover { color: var(--color-primary); border-color: var(--color-primary); box-shadow: var(--glow-primary); } +.theme-toggle { width: 44px; height: 44px; border: 1px solid var(--surface-border); border-radius: var(--radius-lg); background: var(--surface-glass-heavy); backdrop-filter: blur(8px); color: var(--text-tertiary); cursor: pointer; font-size: 1.25rem; transition: all var(--transition-fast); -webkit-tap-highlight-color: transparent; } +.theme-toggle:hover { color: var(--color-primary); border-color: var(--color-primary); box-shadow: var(--glow-primary); transform: translateY(-1px); } .theme-toggle:active { transform: scale(0.92); } - -/* ── Sidebar Navigation (spec: nav-link style) ─── */ -.sidebar-btn { - display: flex; - align-items: center; - width: 100%; - justify-content: flex-start; - padding: 8px 12px; - border: none; - border-radius: var(--radius-md); - background: transparent; - color: var(--text-primary); - font-family: inherit; - font-size: 14px; - font-weight: 500; - line-height: 20px; - letter-spacing: 0.02em; - cursor: pointer; - transition: all var(--transition-fast); - position: relative; -} -.sidebar-btn:hover { - background: var(--surface-hover); - color: var(--color-primary); -} -.sidebar-btn.is-active { - background: var(--surface-container-low); - color: var(--color-primary); - font-weight: 600; -} -.sidebar-btn.is-active::after { - content: ''; - position: absolute; - left: 0; - top: 50%; - transform: translateY(-50%); - width: 3px; - height: 20px; - border-radius: var(--radius-full); - background: var(--color-primary); - animation: indicator-grow 200ms cubic-bezier(0.16, 1, 0.3, 1) both; -} +.sidebar-btn { display: flex; align-items: center; width: 100%; justify-content: flex-start; padding: 10px 12px; border: none; border-radius: var(--radius-md); background: transparent; color: var(--text-secondary); font-family: inherit; font-size: 14px; font-weight: 500; line-height: 20px; letter-spacing: 0.02em; cursor: pointer; transition: all var(--transition-fast); position: relative; gap: var(--space-3); } +.sidebar-btn:hover { background: var(--surface-hover); color: var(--color-primary); transform: translateX(2px); } +.sidebar-btn.is-active { background: linear-gradient(135deg, var(--color-primary-muted), rgba(123, 134, 245, 0.06)); color: var(--color-primary); font-weight: 600; } +.sidebar-btn.is-active::after { content: ''; position: absolute; left: 0; top: 50%; transform: translateY(-50%); width: 3px; height: 24px; border-radius: var(--radius-full); background: var(--gradient-primary); box-shadow: var(--glow-primary); animation: indicator-grow 250ms cubic-bezier(0.16, 1, 0.3, 1) both; } /* ── Status Badge ─────────────────────────────── */ -.status-badge { - display: inline-flex; - align-items: center; - gap: var(--space-1); - padding: 2px 8px; - border-radius: var(--radius-full); - font-size: 0.6875rem; - font-weight: 500; -} -.status-badge::before { - content: ''; - width: 5px; - height: 5px; - border-radius: 50%; - flex-shrink: 0; -} -.status-badge-flagged { background: rgba(228, 64, 95, 0.1); color: var(--color-ai-flagged); } -.status-badge-flagged::before { background: var(--color-ai-flagged); box-shadow: 0 0 6px rgba(228, 64, 95, 0.5); } -.status-badge-clean { background: rgba(16, 185, 129, 0.1); color: var(--color-ai-clean); } -.status-badge-clean::before { background: var(--color-ai-clean); } -.status-badge-warn { background: rgba(245, 158, 11, 0.1); color: var(--color-ai-warn); } -.status-badge-warn::before { background: var(--color-ai-warn); } -.status-badge-pending { background: rgba(153, 153, 153, 0.1); color: var(--color-ai-pending); } +.status-badge { display: inline-flex; align-items: center; gap: var(--space-1); padding: 3px 10px; border-radius: var(--radius-full); font-size: 11px; font-weight: 600; letter-spacing: 0.02em; transition: all var(--transition-fast); } +.status-badge::before { content: ''; width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; } +.status-badge-flagged { background: rgba(228, 64, 95, 0.12); color: var(--color-ai-flagged); } +.status-badge-flagged::before { background: var(--color-ai-flagged); box-shadow: 0 0 8px rgba(228, 64, 95, 0.6); } +.status-badge-clean { background: rgba(16, 185, 129, 0.12); color: var(--color-ai-clean); } +.status-badge-clean::before { background: var(--color-ai-clean); box-shadow: 0 0 8px rgba(16, 185, 129, 0.4); } +.status-badge-warn { background: rgba(245, 158, 11, 0.12); color: var(--color-ai-warn); } +.status-badge-warn::before { background: var(--color-ai-warn); box-shadow: 0 0 8px rgba(245, 158, 11, 0.4); } +.status-badge-pending { background: rgba(148, 163, 184, 0.12); color: var(--color-ai-pending); } .status-badge-pending::before { background: var(--color-ai-pending); } -.status-badge-processing { background: rgba(35, 161, 235, 0.1); color: var(--color-ai-processing); } +.status-badge-processing { background: rgba(35, 161, 235, 0.12); color: var(--color-ai-processing); } .status-badge-processing::before { background: var(--color-ai-processing); animation: pulse-dot 1.5s ease-in-out infinite; } -.status-badge-error { background: rgba(220, 38, 38, 0.1); color: var(--color-ai-error); } -.status-badge-error::before { background: var(--color-ai-error); } - +.status-badge-error { background: rgba(220, 38, 38, 0.12); color: var(--color-ai-error); } +.status-badge-error::before { background: var(--color-ai-error); box-shadow: 0 0 8px rgba(220, 38, 38, 0.4); } @keyframes pulse-dot { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } } - -[data-theme="dark"] .status-badge-pending { background: rgba(91, 101, 119, 0.15); } -[data-theme="dark"] .status-badge-processing { background: rgba(84, 162, 255, 0.15); } +[data-theme="dark"] .status-badge-pending { background: rgba(74, 84, 112, 0.20); } +[data-theme="dark"] .status-badge-processing { background: rgba(84, 162, 255, 0.18); } /* ================================================================ MESSAGES PANEL ================================================================ */ -.messages-panel { - display: flex; - flex-direction: column; - gap: var(--space-5); -} - -.message-stats { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: var(--space-1); -} - -.search-row { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: var(--space-2); -} - -.filter-chip { - padding: 0.25rem 0.625rem; - border-radius: var(--radius-full); - font-size: 0.6875rem; - font-weight: 500; - border: var(--px) solid transparent; - background: var(--surface-hover); - color: var(--text-tertiary); - cursor: pointer; - transition: all var(--transition-fast); - font-family: inherit; -} +.messages-panel { display: flex; flex-direction: column; gap: var(--space-5); } +.message-stats { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-1); } +.search-row { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); } +.filter-chip { padding: 0.25rem 0.75rem; border-radius: var(--radius-full); font-size: 0.6875rem; font-weight: 500; border: 1px solid transparent; background: var(--surface-hover); color: var(--text-tertiary); cursor: pointer; transition: all var(--transition-fast); font-family: inherit; } .filter-chip:hover { color: var(--text-secondary); background: var(--surface-container-low); } .filter-chip:active { transform: scale(0.95); } -.filter-chip.active { background: var(--color-primary); color: white; } -.active { background: var(--color-primary); color: white; } - -/* ── Message Card ─────────────────────────────── */ -.message-card { - background: var(--surface-raised); - border: var(--px) solid var(--surface-border); - border-radius: var(--radius-lg); - overflow: hidden; - transition: border-color var(--transition-fast), box-shadow var(--transition-fast); -} -.message-card:hover { - border-color: rgba(35, 161, 235, 0.15); - box-shadow: var(--shadow-sm); -} - -.message-row { - padding: var(--space-3) 0; - border-bottom: var(--px) solid var(--surface-border); - transition: background var(--transition-fast); -} -.message-row:hover { - background: var(--surface-hover); - margin: 0 calc(-1 * var(--space-4)); - padding-left: var(--space-4); - padding-right: var(--space-4); - border-radius: var(--radius-sm); -} +.filter-chip.active { background: var(--gradient-primary); color: white; } +.active { background: var(--gradient-primary); color: white; } +.message-card { background: var(--surface-glass-heavy); backdrop-filter: blur(8px); border: 1px solid var(--surface-border); border-radius: var(--radius-lg); overflow: hidden; transition: all var(--transition-fast); } +.message-card:hover { border-color: rgba(35, 161, 235, 0.20); box-shadow: var(--shadow-md); transform: translateY(-1px); } +.message-row { padding: var(--space-3) 0; border-bottom: 1px solid var(--surface-border); transition: all var(--transition-fast); } +.message-row:hover { background: var(--surface-hover); margin: 0 calc(-1 * var(--space-4)); padding-left: var(--space-4); padding-right: var(--space-4); border-radius: var(--radius-sm); } .message-row:last-child { border-bottom: none; } .message-row:last-child:hover { margin-bottom: calc(-1 * var(--space-3)); } - -.message-timestamp { - font-size: 0.6875rem; - color: var(--text-tertiary); - font-variant-numeric: tabular-nums; -} - -.custom-emoji { - display: inline-block; - height: 20px; - width: 20px; - vertical-align: middle; - object-fit: contain; -} +.message-timestamp { font-size: 0.6875rem; color: var(--text-tertiary); font-variant-numeric: tabular-nums; } +.custom-emoji { display: inline-block; height: 20px; width: 20px; vertical-align: middle; object-fit: contain; } /* ================================================================ - TOAST + TOAST — Premium ================================================================ */ -.toast-container { - position: fixed; - top: var(--space-4); - right: var(--space-4); - z-index: var(--z-toast); - display: flex; - flex-direction: column; - gap: var(--space-2); - pointer-events: none; -} -.toast { - display: flex; - align-items: center; - gap: var(--space-3); - padding: 0.75rem 1rem; - background: var(--surface-raised); - border: var(--px) solid var(--surface-border); - border-radius: var(--radius-lg); - box-shadow: var(--shadow-lg); - min-width: 300px; - max-width: 420px; - pointer-events: auto; - animation: slide-in-right var(--transition-normal) ease-out; -} +.toast-container { position: fixed; top: var(--space-4); right: var(--space-4); z-index: var(--z-toast); display: flex; flex-direction: column; gap: var(--space-2); pointer-events: none; } +.toast { display: flex; align-items: center; gap: var(--space-3); padding: 0.75rem 1rem; background: var(--surface-glass-heavy); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); border: 1px solid var(--surface-border); border-radius: var(--radius-lg); box-shadow: var(--shadow-lg); min-width: 300px; max-width: 420px; pointer-events: auto; animation: slide-in-right var(--transition-bounce); } .toast-success { border-left: 3px solid var(--color-success); } .toast-error { border-left: 3px solid var(--color-error); } .toast-warning { border-left: 3px solid var(--color-warning); } .toast-info { border-left: 3px solid var(--color-primary); } -.toast-close { margin-left: auto; background: none; border: none; color: var(--text-tertiary); cursor: pointer; padding: 0.25rem; } +.toast-close { margin-left: auto; background: none; border: none; color: var(--text-tertiary); cursor: pointer; padding: 0.25rem; transition: color var(--transition-fast); } +.toast-close:hover { color: var(--text-primary); } /* ================================================================ MODAL ================================================================ */ -.modal-overlay { - position: fixed; - inset: 0; - background: rgba(0, 0, 0, 0.5); - backdrop-filter: blur(4px); - display: flex; - align-items: center; - justify-content: center; - z-index: var(--z-modal); - animation: fade-in var(--transition-fast) ease-out; -} -.modal-content { - background: var(--surface-raised); - border-radius: var(--radius-xl); - border: var(--px) solid var(--surface-border); - box-shadow: var(--shadow-xl); - max-width: 90vw; - max-height: 85vh; - overflow: auto; - animation: scale-in var(--transition-normal) ease-out; -} -.modal-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: var(--space-4) var(--space-6); - border-bottom: var(--px) solid var(--surface-border); -} +.modal-overlay { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.55); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); display: flex; align-items: center; justify-content: center; z-index: var(--z-modal); animation: fade-in var(--transition-fast) ease-out; } +.modal-content { background: var(--surface-glass-heavy); backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px); border-radius: var(--radius-xl); border: 1px solid var(--surface-border); box-shadow: var(--shadow-xl); max-width: 90vw; max-height: 85vh; overflow: auto; animation: scale-in var(--transition-bounce); } +.modal-header { display: flex; align-items: center; justify-content: space-between; padding: var(--space-4) var(--space-6); border-bottom: 1px solid var(--surface-border); } .modal-body { padding: var(--space-6); } -.modal-footer { - display: flex; - justify-content: flex-end; - gap: var(--space-2); - padding: var(--space-4) var(--space-6); - border-top: var(--px) solid var(--surface-border); -} - -/* ================================================================ - EMPTY STATE - ================================================================ */ -.empty-state { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding: var(--space-16) var(--space-4); - text-align: center; -} -.empty-state-title { - font-size: 0.9375rem; - font-weight: 600; - color: var(--text-secondary); - margin-bottom: var(--space-2); -} -.empty-state-description { - font-size: 0.8125rem; - color: var(--text-tertiary); - max-width: 280px; - line-height: 1.5; -} +.modal-footer { display: flex; justify-content: flex-end; gap: var(--space-2); padding: var(--space-4) var(--space-6); border-top: 1px solid var(--surface-border); } +.empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: var(--space-16) var(--space-4); text-align: center; } +.empty-state-title { font-size: 0.9375rem; font-weight: 600; color: var(--text-secondary); margin-bottom: var(--space-2); } +.empty-state-description { font-size: 0.8125rem; color: var(--text-tertiary); max-width: 280px; line-height: 1.5; } /* ================================================================ ANIMATIONS ================================================================ */ @keyframes fade-in { from { opacity: 0; } to { opacity: 1; } } -@keyframes fade-in-up { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } } -@keyframes scale-in { from { opacity: 0; transform: scale(0.95); } to { opacity: 1; transform: scale(1); } } -@keyframes slide-in-right { from { opacity: 0; transform: translateX(100%); } to { opacity: 1; transform: translateX(0); } } -@keyframes bar-pulse { 0%, 100% { transform: scaleY(1); } 50% { transform: scaleY(0.6); } } -@keyframes glow-pulse { 0%, 100% { box-shadow: var(--glow-primary); } 50% { box-shadow: 0 0 30px rgba(35, 161, 235, 0.3); } } - +@keyframes fade-in-up { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } } +@keyframes scale-in { from { opacity: 0; transform: scale(0.92); } to { opacity: 1; transform: scale(1); } } +@keyframes slide-in-right { from { opacity: 0; transform: translateX(120%); } to { opacity: 1; transform: translateX(0); } } +@keyframes bar-pulse { 0%, 100% { transform: scaleY(1); } 50% { transform: scaleY(0.5); } } +@keyframes glow-pulse { 0%, 100% { box-shadow: var(--glow-primary); } 50% { box-shadow: 0 0 40px rgba(35, 161, 235, 0.35); } } .animate-fade-in { animation: fade-in var(--transition-normal) ease-out; } .animate-fade-in-up { animation: fade-in-up var(--transition-normal) ease-out; } .animate-scale-in { animation: scale-in var(--transition-normal) ease-out; } .animate-slide-in-right { animation: slide-in-right var(--transition-normal) ease-out; } -.animate-content-enter { animation: content-enter 400ms cubic-bezier(0.16, 1, 0.3, 1) both; } -.animate-float { animation: float 3s ease-in-out infinite; } +.animate-content-enter { animation: content-enter 500ms cubic-bezier(0.16, 1, 0.3, 1) both; } +.animate-float { animation: float 4s ease-in-out infinite; } +.animate-glow { animation: glow-pulse 2s ease-in-out infinite; } .animate-stagger-1 { animation-delay: 50ms; } .animate-stagger-2 { animation-delay: 100ms; } .animate-stagger-3 { animation-delay: 150ms; } .animate-stagger-4 { animation-delay: 200ms; } - -@keyframes content-enter { - from { opacity: 0; transform: translateY(12px); } - to { opacity: 1; transform: translateY(0); } -} -@keyframes float { - 0%, 100% { transform: translateY(0); } - 50% { transform: translateY(-6px); } -} -@keyframes indicator-grow { - from { height: 0; opacity: 0; } - to { height: 20px; opacity: 1; } -} -@keyframes pulse-connecting { - 0%, 100% { opacity: 1; transform: scale(1); } - 50% { opacity: 0.4; transform: scale(0.8); } -} -@keyframes rotate-in { - from { transform: rotate(-90deg); opacity: 0; } - to { transform: rotate(0); opacity: 1; } -} -@keyframes orb-drift { - 0% { transform: translate(0, 0); } - 25% { transform: translate(30px, -20px); } - 50% { transform: translate(-20px, 10px); } - 75% { transform: translate(10px, -30px); } - 100% { transform: translate(0, 0); } -} -@keyframes card-in { - from { opacity: 0; transform: translateY(16px); } - to { opacity: 1; transform: translateY(0); } -} - -/* Reduced motion */ -@media (prefers-reduced-motion: reduce) { - *, *::before, *::after { - animation-duration: 0.01ms !important; - animation-iteration-count: 1 !important; - transition-duration: 0.01ms !important; - } -} +@keyframes content-enter { from { opacity: 0; transform: translateY(16px); } to { opacity: 1; transform: translateY(0); } } +@keyframes float { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-8px); } } +@keyframes indicator-grow { from { height: 0; opacity: 0; } to { height: 24px; opacity: 1; } } +@keyframes pulse-connecting { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.4; transform: scale(0.7); } } +@keyframes orb-drift { 0% { transform: translate(0, 0); } 25% { transform: translate(40px, -30px); } 50% { transform: translate(-30px, 20px); } 75% { transform: translate(20px, -40px); } 100% { transform: translate(0, 0); } } +@keyframes card-in { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } } +@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; } } /* ================================================================ - PARTICLE BACKGROUND + PARTICLE BACKGROUND — Premium Orbs ================================================================ */ .particle-bg { position: fixed; inset: 0; overflow: hidden; pointer-events: none; z-index: 0; } -.particle-orb { position: absolute; border-radius: 50%; filter: blur(80px); } -.particle-orb:nth-child(1) { width: 500px; height: 500px; top: -100px; right: -100px; background: var(--color-primary); opacity: 0.08; animation: orb-drift 20s ease-in-out infinite; } -.particle-orb:nth-child(2) { width: 400px; height: 400px; bottom: -100px; left: -100px; background: var(--color-tertiary); opacity: 0.08; animation: orb-drift 25s ease-in-out infinite reverse; } -.particle-orb:nth-child(3) { width: 300px; height: 300px; top: 50%; left: 50%; transform: translate(-50%, -50%); background: var(--color-accent); opacity: 0.08; animation: orb-drift 30s ease-in-out infinite; animation-delay: -5s; } +.particle-orb { position: absolute; border-radius: 50%; opacity: 0.5; will-change: transform; } +.particle-orb:nth-child(1) { width: 600px; height: 600px; top: -200px; right: -150px; background: radial-gradient(circle, var(--color-primary) 0%, transparent 70%); filter: blur(100px); animation: orb-drift 25s ease-in-out infinite; } +.particle-orb:nth-child(2) { width: 500px; height: 500px; bottom: -150px; left: -150px; background: radial-gradient(circle, var(--color-tertiary) 0%, transparent 70%); filter: blur(100px); animation: orb-drift 30s ease-in-out infinite reverse; } +.particle-orb:nth-child(3) { width: 350px; height: 350px; top: 40%; left: 60%; background: radial-gradient(circle, var(--color-accent) 0%, transparent 70%); filter: blur(100px); animation: orb-drift 35s ease-in-out infinite; animation-delay: -8s; } +.particle-orb:nth-child(4) { width: 250px; height: 250px; top: 10%; left: 20%; background: radial-gradient(circle, var(--color-success) 0%, transparent 70%); opacity: 0.3; filter: blur(100px); animation: orb-drift 20s ease-in-out infinite; animation-delay: -3s; } @media (max-width: 768px) { .particle-bg { display: none; } } /* ================================================================ IMAGE GRID ================================================================ */ -.image-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); - gap: var(--space-2); -} -.image-grid-item { - aspect-ratio: 1; - border-radius: var(--radius-md); - overflow: hidden; - cursor: pointer; - transition: opacity var(--transition-fast); -} -.image-grid-item:hover { opacity: 0.8; } +.image-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: var(--space-2); } +.image-grid-item { aspect-ratio: 1; border-radius: var(--radius-md); overflow: hidden; cursor: pointer; transition: all var(--transition-fast); border: 1px solid var(--surface-border); } +.image-grid-item:hover { opacity: 0.85; transform: scale(1.02); box-shadow: var(--shadow-sm); } .image-grid-item img { width: 100%; height: 100%; object-fit: cover; } /* ================================================================ @@ -1121,32 +625,24 @@ svg { display: inline-block; vertical-align: middle; } .dashboard-header { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-4); } .dashboard-stats-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--space-4); } .dashboard-metric-card { padding: var(--space-5); overflow: hidden; } -.dashboard-metric-card:hover { transform: translateY(-2px); box-shadow: var(--shadow-md); } +.dashboard-metric-card:hover { transform: translateY(-3px); box-shadow: var(--shadow-md); border-color: rgba(35, 161, 235, 0.15); } .dashboard-metric-content { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); } -.dashboard-metric-label { color: var(--text-secondary); font-size: 0.75rem; font-weight: 500; letter-spacing: 0.02em; } -.dashboard-metric-value { margin-top: var(--space-1); font-size: 1.5rem; font-weight: 700; letter-spacing: -0.025em; color: var(--text-primary); } -.dashboard-metric-icon { display: flex; align-items: center; justify-content: center; width: 2.5rem; height: 2.5rem; border-radius: var(--radius-lg); font-size: 1.25rem; } +.dashboard-metric-label { color: var(--text-secondary); font-size: 0.75rem; font-weight: 500; letter-spacing: 0.02em; text-transform: uppercase; } +.dashboard-metric-value { margin-top: var(--space-1); font-size: 1.625rem; font-weight: 800; letter-spacing: -0.03em; color: var(--text-primary); } +.dashboard-metric-icon { display: flex; align-items: center; justify-content: center; width: 2.75rem; height: 2.75rem; border-radius: var(--radius-lg); font-size: 1.25rem; } .dashboard-wide-card { grid-column: span 2; } .dashboard-top-channels, .dashboard-summary-list { display: flex; flex-direction: column; gap: var(--space-2); } -.dashboard-top-channel-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--space-2); - padding: var(--space-2) var(--space-3); - border-radius: var(--radius-md); - background: var(--surface-container-low); - color: var(--text-secondary); - font-size: 0.875rem; -} +.dashboard-top-channel-row { display: flex; align-items: center; justify-content: space-between; gap: var(--space-2); padding: var(--space-2) var(--space-3); border-radius: var(--radius-md); background: var(--surface-container-low); color: var(--text-secondary); font-size: 0.875rem; transition: all var(--transition-fast); } +.dashboard-top-channel-row:hover { background: var(--surface-overlay); transform: translateX(2px); } .dashboard-moderation-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-3); } -.dashboard-queue-card { padding: var(--space-4); border: 1px solid var(--surface-border); border-radius: var(--radius-lg); background: var(--surface-container-low); text-align: center; } -.dashboard-queue-value { font-size: 1.5rem; font-weight: 700; color: var(--text-primary); } -.dashboard-queue-label { margin-top: var(--space-1); color: var(--text-secondary); font-size: 0.75rem; } +.dashboard-queue-card { padding: var(--space-5); border: 1px solid var(--surface-border); border-radius: var(--radius-lg); background: var(--surface-container-low); text-align: center; transition: all var(--transition-fast); } +.dashboard-queue-card:hover { border-color: var(--color-primary); box-shadow: var(--shadow-sm); transform: translateY(-1px); } +.dashboard-queue-value { font-size: 1.75rem; font-weight: 800; color: var(--text-primary); letter-spacing: -0.03em; } +.dashboard-queue-label { margin-top: var(--space-1); color: var(--text-secondary); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.04em; } .dashboard-list-card { overflow: hidden; } .dashboard-list-toolbar { margin-bottom: var(--space-4); } -.dashboard-summary-row { display: flex; align-items: flex-start; gap: var(--space-3); padding: var(--space-3); border: 1px solid var(--surface-border); border-radius: var(--radius-lg); background: var(--surface-raised); transition: background var(--transition-fast), border-color var(--transition-fast); } -.dashboard-summary-row:hover { border-color: var(--color-primary); background: var(--surface-container-low); } +.dashboard-summary-row { display: flex; align-items: flex-start; gap: var(--space-3); padding: var(--space-3); border: 1px solid var(--surface-border); border-radius: var(--radius-lg); background: var(--surface-glass-heavy); backdrop-filter: blur(8px); transition: all var(--transition-fast); } +.dashboard-summary-row:hover { border-color: var(--color-primary); background: var(--surface-raised); box-shadow: var(--shadow-sm); } .dashboard-summary-avatar { display: flex; align-items: center; justify-content: center; flex-shrink: 0; width: 2.5rem; height: 2.5rem; border-radius: var(--radius-full); background: var(--surface-container); color: var(--text-secondary); font-weight: 700; overflow: hidden; } .dashboard-summary-avatar-img { width: 100%; height: 100%; object-fit: cover; } .dashboard-channel-avatar { color: var(--color-primary); background: rgba(35, 161, 235, 0.1); } @@ -1157,87 +653,56 @@ svg { display: inline-block; vertical-align: middle; } .dashboard-list-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--space-3); padding: var(--space-10) var(--space-4); text-align: center; } /* ================================================================ - MASCOt CHATBOT + MASCOT CHATBOT ================================================================ */ .mascot-widget { position: fixed; right: var(--space-6); bottom: var(--space-6); z-index: var(--z-toast); } -.mascot-launcher { - width: 3.5rem; height: 3.5rem; border: none; - border-radius: var(--radius-full); - background: var(--color-primary); - color: white; box-shadow: var(--shadow-xl), var(--glow-primary); - cursor: pointer; font-size: 1.5rem; - transition: transform var(--transition-fast); - animation: float 3s ease-in-out infinite; -} -.mascot-launcher:hover { transform: translateY(-2px) scale(1.05); animation: none; } -.mascot-panel { - width: min(24rem, calc(100vw - 2rem)); - height: min(32.5rem, calc(100vh - 7rem)); - display: flex; flex-direction: column; overflow: hidden; - border: var(--px) solid var(--surface-border); - border-radius: var(--radius-xl); - background: var(--surface-raised); box-shadow: var(--shadow-xl); - animation: fade-in-up var(--transition-normal) ease-out; -} -.mascot-panel.minimized { height: 3.5rem; } -.mascot-header { - display: flex; align-items: center; justify-content: space-between; - gap: var(--space-3); padding: var(--space-3); - background: var(--color-primary); - color: white; flex-shrink: 0; -} -.mascot-header-icon { display: flex; align-items: center; justify-content: center; width: 1.75rem; height: 1.75rem; border-radius: var(--radius-md); background: rgba(255,255,255,0.15); } +.mascot-launcher { width: 3.75rem; height: 3.75rem; border: none; border-radius: var(--radius-full); background: var(--gradient-primary); color: white; box-shadow: var(--shadow-xl), var(--glow-primary); cursor: pointer; font-size: 1.5rem; transition: all var(--transition-fast); animation: float 4s ease-in-out infinite; } +.mascot-launcher:hover { transform: translateY(-3px) scale(1.05); animation: none; box-shadow: var(--shadow-lg), var(--glow-primary); } +.mascot-panel { width: min(24rem, calc(100vw - 2rem)); height: min(32.5rem, calc(100vh - 7rem)); display: flex; flex-direction: column; overflow: hidden; border: 1px solid var(--surface-border); border-radius: var(--radius-xl); background: var(--surface-glass-heavy); backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px); box-shadow: var(--shadow-xl); animation: fade-in-up var(--transition-bounce); } +.mascot-panel.minimized { height: 3.75rem; } +.mascot-header { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); padding: var(--space-3); background: var(--gradient-primary); color: white; flex-shrink: 0; } +.mascot-header-icon { display: flex; align-items: center; justify-content: center; width: 2rem; height: 2rem; border-radius: var(--radius-md); background: rgba(255,255,255,0.15); } .mascot-title { font-size: 0.875rem; font-weight: 700; line-height: 1.15; letter-spacing: 0.02em; } .mascot-subtitle { margin-top: 0.125rem; font-size: 0.6875rem; opacity: 0.7; } -.mascot-icon-button { width: 1.75rem; height: 1.75rem; border: none; border-radius: var(--radius-md); background: transparent; color: white; cursor: pointer; } -.mascot-icon-button:hover { background: rgba(255,255,255,0.15); } +.mascot-icon-button { width: 2rem; height: 2rem; border: none; border-radius: var(--radius-md); background: transparent; color: white; cursor: pointer; transition: background var(--transition-fast); } +.mascot-icon-button:hover { background: rgba(255,255,255,0.20); } .mascot-messages { flex: 1; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; gap: var(--space-3); padding: var(--space-4); } -.mascot-message-row { display: flex; gap: var(--space-2); } +.mascot-message-row { display: flex; gap: var(--space-2); animation: fade-in-up var(--transition-fast) both; } .mascot-message-row.user { justify-content: flex-end; } .mascot-avatar { display: flex; align-items: center; justify-content: center; flex-shrink: 0; width: 1.5rem; height: 1.5rem; border-radius: var(--radius-full); background: var(--surface-container-low); font-size: 0.875rem; } .mascot-bubble { max-width: 17.5rem; padding: var(--space-2) var(--space-3); border-radius: var(--radius-xl); font-size: 0.875rem; line-height: 1.5; overflow-wrap: anywhere; } -.mascot-bubble.user { color: white; background: var(--color-primary); border-bottom-right-radius: var(--radius-sm); } -.mascot-bubble.mascot { color: var(--text-primary); background: var(--surface-container-low); border-bottom-left-radius: var(--radius-sm); } +.mascot-bubble.user { color: white; background: var(--gradient-primary); border-bottom-right-radius: var(--radius-sm); } +.mascot-bubble.mascot { color: var(--text-primary); background: var(--surface-container-low); border: 1px solid var(--surface-border); border-bottom-left-radius: var(--radius-sm); } .mascot-bubble.typing { display: flex; gap: 0.25rem; padding-top: 0.7rem; padding-bottom: 0.7rem; } -.mascot-bubble.typing span { width: 0.4rem; height: 0.4rem; border-radius: var(--radius-full); background: var(--text-tertiary); animation: notification-pulse 0.8s infinite; } +.mascot-bubble.typing span { width: 0.5rem; height: 0.5rem; border-radius: var(--radius-full); background: var(--text-tertiary); animation: notification-pulse 0.8s infinite; } .mascot-bubble.typing span:nth-child(2) { animation-delay: 0.1s; } .mascot-bubble.typing span:nth-child(3) { animation-delay: 0.2s; } @keyframes notification-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } } -.mascot-form { display: flex; gap: var(--space-2); padding: var(--space-3); border-top: var(--px) solid var(--surface-border); } -.mascot-input { flex: 1; min-width: 0; border: 1px solid #cccccc; border-radius: var(--radius-lg); padding: var(--space-2) var(--space-3); background: var(--surface-container-low); color: var(--text-primary); font-size: 0.875rem; } -.mascot-input:focus { outline: none; border-color: var(--color-primary); box-shadow: 0 0 0 3px rgba(35, 161, 235, 0.1); } -.mascot-send { width: 2.25rem; border: none; border-radius: var(--radius-lg); background: var(--color-primary); color: white; cursor: pointer; } +.mascot-form { display: flex; gap: var(--space-2); padding: var(--space-3); border-top: 1px solid var(--surface-border); } +.mascot-input { flex: 1; min-width: 0; border: 1.5px solid transparent; border-radius: var(--radius-lg); padding: var(--space-2) var(--space-3); background: var(--surface-container-low); color: var(--text-primary); font-size: 0.875rem; transition: all var(--transition-fast); } +.mascot-input:focus { outline: none; border-color: var(--color-primary); box-shadow: 0 0 0 3px rgba(35, 161, 235, 0.12); background: var(--surface-raised); } +.mascot-send { width: 2.25rem; border: none; border-radius: var(--radius-lg); background: var(--gradient-primary); color: white; cursor: pointer; transition: all var(--transition-fast); } +.mascot-send:hover { box-shadow: var(--glow-primary); } .mascot-send:disabled, .mascot-input:disabled { cursor: not-allowed; opacity: 0.55; } -/* ================================================================ - TONE HELPERS - ================================================================ */ -.tone-primary { background: rgba(35, 161, 235, 0.1); color: var(--color-primary); } -.tone-success { background: rgba(16, 185, 129, 0.1); color: var(--color-success); } -.tone-error { background: rgba(228, 64, 95, 0.1); color: var(--color-error); } -.tone-warning { background: rgba(245, 158, 11, 0.1); color: var(--color-warning); } -.tone-info { background: rgba(35, 161, 235, 0.1); color: var(--color-primary); } - -[data-theme="dark"] .tone-primary { background: rgba(84, 162, 255, 0.12); color: #7ab7ff; } +/* ── Tone Helpers ───────────────────────────────── */ +.tone-primary { background: linear-gradient(135deg, rgba(35, 161, 235, 0.12), rgba(123, 134, 245, 0.08)); color: var(--color-primary); } +.tone-success { background: rgba(16, 185, 129, 0.10); color: var(--color-success); } +.tone-error { background: rgba(228, 64, 95, 0.10); color: var(--color-error); } +.tone-warning { background: rgba(245, 158, 11, 0.10); color: var(--color-warning); } +.tone-info { background: rgba(35, 161, 235, 0.10); color: var(--color-primary); } +[data-theme="dark"] .tone-primary { background: linear-gradient(135deg, rgba(84, 162, 255, 0.15), rgba(157, 166, 255, 0.10)); color: #7ab7ff; } [data-theme="dark"] .tone-info { background: rgba(84, 162, 255, 0.12); color: #7ab7ff; } /* ================================================================ RESPONSIVE ================================================================ */ -@media (max-width: 1024px) { - .dashboard-stats-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } -} +@media (max-width: 1024px) { .dashboard-stats-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } } @media (max-width: 768px) { .grid-cols-2 { grid-template-columns: 1fr; } .grid-cols-3 { grid-template-columns: 1fr; } .app-main { flex-direction: column; } - .app-sidebar { - width: 100%; - border-right: none; - border-bottom: var(--px) solid var(--surface-border); - padding: var(--space-2); - overflow-x: auto; - } + .app-sidebar { width: 100%; border-right: none; border-bottom: 1px solid var(--surface-border); padding: var(--space-2); overflow-x: auto; backdrop-filter: blur(12px); } .app-sidebar .flex-col { flex-direction: row; overflow-x: auto; gap: var(--space-1); } .app-content { padding: var(--space-4); } .app-brand-subtitle { display: none; } @@ -1248,588 +713,140 @@ svg { display: inline-block; vertical-align: middle; } .mascot-panel { width: calc(100vw - 2rem); height: min(32.5rem, calc(100vh - 5rem)); } .hide-mobile { display: none !important; } } -@media (min-width: 769px) { - .hide-desktop { display: none !important; } -} +@media (min-width: 769px) { .hide-desktop { display: none !important; } } /* ================================================================ v2 — CLEAN SEMANTIC COMPONENT CLASSES ================================================================ */ - -/* ── Panel Card ──────────────────────────────── */ -.panel-card { - background: var(--surface-raised); - border: 1px solid #cccccc; - border-radius: var(--radius-lg); - box-shadow: var(--shadow-sm); - overflow: hidden; - transition: all 300ms cubic-bezier(0.4, 0, 0.2, 1); -} +.panel-card { background: var(--surface-glass-heavy); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); border: 1px solid var(--surface-border); border-radius: var(--radius-lg); box-shadow: var(--shadow-sm); overflow: hidden; transition: all var(--transition-normal); } .panel-card:hover { box-shadow: var(--shadow-md); } -.panel-card-head { - padding: var(--space-4) var(--space-5); - border-bottom: var(--px) solid var(--surface-border); -} -.panel-card-title { - font-size: 1rem; - font-weight: 600; - color: var(--text-primary); -} -.panel-card-desc { - font-size: 0.875rem; - color: var(--text-secondary); - margin-top: var(--space-1); -} - -/* ── Stats Bar ────────────────────────────────── */ -.stats-bar { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: var(--space-1); -} - -/* ── Search Area ──────────────────────────────── */ -.search-bar { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: var(--space-2); -} -.search-wrap { - position: relative; - flex: 1; - min-width: 200px; -} -.search-input { - width: 100%; - padding: 0.5rem 0.75rem 0.5rem 2.25rem; - background: var(--surface-container-low); - border: 1px solid #cccccc; - border-radius: var(--radius-full); - color: var(--text-primary); - font-family: inherit; - font-size: 0.875rem; - transition: all var(--transition-fast); - outline: none; -} +.panel-card-head { padding: var(--space-5) var(--space-6); border-bottom: 1px solid var(--surface-border); } +.panel-card-title { font-size: 1rem; font-weight: 600; color: var(--text-primary); } +.panel-card-desc { font-size: 0.875rem; color: var(--text-secondary); margin-top: var(--space-1); } +.stats-bar { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-1); } +.search-bar { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); } +.search-wrap { position: relative; flex: 1; min-width: 200px; } +.search-input { width: 100%; padding: 0.5rem 0.75rem 0.5rem 2.25rem; background: var(--surface-container-low); border: 1.5px solid transparent; border-radius: var(--radius-full); color: var(--text-primary); font-family: inherit; font-size: 0.875rem; transition: all var(--transition-fast); outline: none; } .search-input::placeholder { color: var(--text-tertiary); } -.search-input:focus { border-color: var(--color-primary); box-shadow: 0 0 0 3px rgba(35, 161, 235, 0.1); } -.search-icon { - position: absolute; - left: 0.75rem; - top: 50%; - transform: translateY(-50%); - color: var(--color-primary); - pointer-events: none; -} - -/* ── Filter Chips ─────────────────────────────── */ -.filter-group { - display: flex; - align-items: center; - gap: 0.375rem; - margin-left: auto; -} -.filter-chip-v2 { - padding: 0.25rem 0.625rem; - border-radius: var(--radius-full); - font-size: 0.6875rem; - font-weight: 500; - border: var(--px) solid transparent; - background: var(--surface-hover); - color: var(--text-tertiary); - cursor: pointer; - transition: all var(--transition-fast); - font-family: inherit; -} +.search-input:focus { border-color: var(--color-primary); box-shadow: 0 0 0 3px rgba(35, 161, 235, 0.12); background: var(--surface-raised); } +.search-icon { position: absolute; left: 0.75rem; top: 50%; transform: translateY(-50%); color: var(--color-primary); pointer-events: none; } +.filter-group { display: flex; align-items: center; gap: 0.375rem; margin-left: auto; } +.filter-chip-v2 { padding: 0.25rem 0.75rem; border-radius: var(--radius-full); font-size: 0.6875rem; font-weight: 500; border: 1px solid transparent; background: var(--surface-hover); color: var(--text-tertiary); cursor: pointer; transition: all var(--transition-fast); font-family: inherit; } .filter-chip-v2:hover { color: var(--text-secondary); background: var(--surface-container-low); } -.filter-chip-v2.is-active { background: var(--color-primary); color: white; } - -/* ── Message Cards (v2) ──────────────────────── */ -.msg-card { - background: var(--surface-raised); - border: var(--px) solid var(--surface-border); - border-radius: var(--radius-lg); - overflow: hidden; - transition: all var(--transition-fast); -} -.msg-card:hover { border-color: rgba(35, 161, 235, 0.15); box-shadow: var(--shadow-sm); transform: translateY(-1px); } +.filter-chip-v2.is-active { background: var(--gradient-primary); color: white; box-shadow: var(--glow-primary); } +.msg-card { background: var(--surface-glass-heavy); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); border: 1px solid var(--surface-border); border-radius: var(--radius-lg); overflow: hidden; transition: all var(--transition-fast); } +.msg-card:hover { border-color: rgba(35, 161, 235, 0.20); box-shadow: var(--shadow-md); transform: translateY(-2px); } .msg-card.is-deleted { opacity: 0.6; border-color: var(--color-error); } - -.msg-card-inner { - display: flex; - gap: var(--space-3); - padding: var(--space-4); -} - -.msg-card-avatar { - width: 40px; - height: 40px; - flex-shrink: 0; - border-radius: 50%; - object-fit: cover; - box-shadow: 0 0 0 2px var(--surface-raised), 0 0 0 4px rgba(35, 161, 235, 0.2); -} - -.msg-card-body { - min-width: 0; - flex: 1; -} - -.msg-card-head { - display: flex; - align-items: baseline; - gap: var(--space-2); - margin-bottom: var(--space-2); -} - -.msg-card-name { - font-size: 0.875rem; - font-weight: 600; - color: var(--text-primary); -} - -.msg-card-channel { - display: inline-flex; - align-items: center; - gap: var(--space-1); - font-size: 0.75rem; - color: var(--text-tertiary); - background: var(--surface-container-low); - padding: 1px 8px; - border-radius: var(--radius-full); -} -.msg-card-channel::before { - content: '#'; - opacity: 0.5; -} - -.msg-card-time { - font-size: 0.75rem; - color: var(--text-tertiary); - opacity: 0.6; -} - -.msg-card-messages { - /* container for multiple message rows */ -} -.msg-card-messages.separated > * + * { - margin-top: 0.625rem; - padding-top: 0.625rem; - border-top: var(--px) solid var(--surface-border); -} - -/* ── Message Rows ────────────────────────────── */ -.msg-row { - padding: var(--space-3) 0; - border-bottom: var(--px) solid var(--surface-border); - transition: background var(--transition-fast), padding var(--transition-fast), margin var(--transition-fast); -} -.msg-row:hover { - background: var(--surface-hover); - margin: 0 calc(-1 * var(--space-4)); - padding-left: var(--space-4); - padding-right: var(--space-4); - border-radius: var(--radius-sm); -} +.msg-card-inner { display: flex; gap: var(--space-3); padding: var(--space-4); } +.msg-card-avatar { width: 40px; height: 40px; flex-shrink: 0; border-radius: 50%; object-fit: cover; box-shadow: 0 0 0 2px var(--surface-raised), 0 0 0 4px rgba(35, 161, 235, 0.15); } +.msg-card-body { min-width: 0; flex: 1; } +.msg-card-head { display: flex; align-items: baseline; gap: var(--space-2); margin-bottom: var(--space-2); } +.msg-card-name { font-size: 0.875rem; font-weight: 600; color: var(--text-primary); } +.msg-card-channel { display: inline-flex; align-items: center; gap: var(--space-1); font-size: 0.75rem; color: var(--text-tertiary); background: var(--surface-container-low); padding: 1px 8px; border-radius: var(--radius-full); } +.msg-card-channel::before { content: '#'; opacity: 0.5; } +.msg-card-time { font-size: 0.75rem; color: var(--text-tertiary); opacity: 0.6; } +.msg-card-messages.separated > * + * { margin-top: 0.625rem; padding-top: 0.625rem; border-top: 1px solid var(--surface-border); } +.msg-row { padding: var(--space-3) 0; border-bottom: 1px solid var(--surface-border); transition: all var(--transition-fast); } +.msg-row:hover { background: var(--surface-hover); margin: 0 calc(-1 * var(--space-4)); padding-left: var(--space-4); padding-right: var(--space-4); border-radius: var(--radius-sm); } .msg-row:last-child { border-bottom: none; } -.msg-row:last-child:hover { margin-bottom: calc(-1 * var(--space-3)); } - -.msg-row-bar { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 0.25rem 0.5rem; -} - -.msg-row-time { - font-size: 0.6875rem; - color: var(--text-tertiary); - font-variant-numeric: tabular-nums; - min-width: 48px; -} - -.msg-row-badge { - display: inline-flex; - align-items: center; - gap: 0.125rem; - font-size: 0.75rem; -} +.msg-row-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 0.25rem 0.5rem; } +.msg-row-time { font-size: 0.6875rem; color: var(--text-tertiary); font-variant-numeric: tabular-nums; min-width: 48px; } +.msg-row-badge { display: inline-flex; align-items: center; gap: 0.125rem; font-size: 0.75rem; } .msg-row-badge.edited { color: var(--text-secondary); } .msg-row-badge.deleted { color: var(--color-error); } - -.msg-row-status { - margin-left: auto; - display: flex; - align-items: center; - gap: var(--space-1); -} - -.msg-row-body { - font-size: 0.875rem; - line-height: 1.5rem; - white-space: pre-wrap; - word-break: break-word; -} +.msg-row-status { margin-left: auto; display: flex; align-items: center; gap: var(--space-1); } +.msg-row-body { font-size: 0.875rem; line-height: 1.5rem; white-space: pre-wrap; word-break: break-word; } .msg-row-body.is-deleted { opacity: 0.6; color: var(--text-secondary); } - -/* ── Media in messages ───────────────────────── */ -.msg-media-row { - display: flex; - flex-wrap: wrap; - gap: var(--space-2); - margin-top: var(--space-2); -} -.msg-media-row.is-scroll { - overflow-x: auto; - flex-wrap: nowrap; -} - -.msg-thumb { - width: 64px; - height: 64px; - object-fit: cover; - border-radius: var(--radius-md); - border: var(--px) solid var(--surface-border); - transition: transform var(--transition-fast); -} -.msg-thumb:hover { transform: scale(1.05); } - -.msg-thumb-link { - display: block; - flex-shrink: 0; - overflow: hidden; - border-radius: var(--radius-md); - border: var(--px) solid var(--surface-border); -} - -.msg-sticker { - width: 48px; - height: 48px; - object-fit: contain; - border-radius: var(--radius-md); - border: var(--px) solid var(--surface-border); -} - -.msg-sticker-placeholder { - display: flex; - width: 48px; - height: 48px; - align-items: center; - justify-content: center; - border-radius: var(--radius-md); - border: var(--px) solid var(--surface-border); -} - -.msg-video { - height: 112px; - width: 192px; - flex-shrink: 0; - border-radius: var(--radius-md); - border: var(--px) solid var(--surface-border); - object-fit: cover; - background: #000; -} - -.msg-media-overflow { - display: flex; - width: 64px; - height: 64px; - align-items: center; - justify-content: center; - border-radius: var(--radius-md); - border: var(--px) solid var(--surface-border); - font-size: 0.75rem; - color: var(--text-secondary); -} -.msg-media-overflow.tall { - height: 112px; - width: 64px; -} - -/* ── AI / Categories ─────────────────────────── */ -.msg-cats { - display: flex; - flex-wrap: wrap; - gap: var(--space-1); - margin-top: var(--space-2); -} - -.msg-analysis { - margin-top: var(--space-2); - padding: var(--space-2) var(--space-3); - border-radius: var(--radius-md); - font-size: 0.75rem; - border-left: 3px solid; -} -.msg-analysis.flagged { background: rgba(228, 64, 95, 0.05); border-color: var(--color-ai-flagged); } -.msg-analysis.clean { background: rgba(16, 185, 129, 0.05); border-color: var(--color-ai-clean); } - -.msg-analysis-row { - display: flex; - align-items: flex-start; - gap: var(--space-2); -} -.msg-analysis-icon { - margin-top: 0.125rem; - flex-shrink: 0; -} +.msg-media-row { display: flex; flex-wrap: wrap; gap: var(--space-2); margin-top: var(--space-2); } +.msg-media-row.is-scroll { overflow-x: auto; flex-wrap: nowrap; } +.msg-thumb { width: 64px; height: 64px; object-fit: cover; border-radius: var(--radius-md); border: 1px solid var(--surface-border); transition: all var(--transition-fast); } +.msg-thumb:hover { transform: scale(1.08); } +.msg-thumb-link { display: block; flex-shrink: 0; overflow: hidden; border-radius: var(--radius-md); border: 1px solid var(--surface-border); transition: all var(--transition-fast); } +.msg-thumb-link:hover { border-color: var(--color-primary); } +.msg-sticker { width: 48px; height: 48px; object-fit: contain; border-radius: var(--radius-md); border: 1px solid var(--surface-border); } +.msg-sticker-placeholder { display: flex; width: 48px; height: 48px; align-items: center; justify-content: center; border-radius: var(--radius-md); border: 1px solid var(--surface-border); } +.msg-video { height: 112px; width: 192px; flex-shrink: 0; border-radius: var(--radius-md); border: 1px solid var(--surface-border); object-fit: cover; background: #000; } +.msg-media-overflow { display: flex; width: 64px; height: 64px; align-items: center; justify-content: center; border-radius: var(--radius-md); border: 1px solid var(--surface-border); font-size: 0.75rem; color: var(--text-secondary); background: var(--surface-container-low); } +.msg-media-overflow.tall { height: 112px; width: 64px; } +.msg-cats { display: flex; flex-wrap: wrap; gap: var(--space-1); margin-top: var(--space-2); } +.msg-analysis { margin-top: var(--space-2); padding: var(--space-2) var(--space-3); border-radius: var(--radius-md); font-size: 0.75rem; border-left: 3px solid; } +.msg-analysis.flagged { background: rgba(228, 64, 95, 0.06); border-color: var(--color-ai-flagged); } +.msg-analysis.clean { background: rgba(16, 185, 129, 0.06); border-color: var(--color-ai-clean); } +.msg-analysis-row { display: flex; align-items: flex-start; gap: var(--space-2); } +.msg-analysis-icon { margin-top: 0.125rem; flex-shrink: 0; } .msg-analysis-body { min-width: 0; flex: 1; } -.msg-analysis-summary { - display: block; - font-weight: 500; - margin-bottom: var(--space-1); -} -.msg-analysis-text { - font-size: 0.75rem; - line-height: 1.5; - white-space: pre-wrap; -} - -.msg-error { - margin-top: var(--space-2); - padding: var(--space-2) var(--space-3); - border-radius: var(--radius-md); - font-size: 0.75rem; - color: var(--color-warning); -} - -.msg-actions { - display: flex; - align-items: center; - gap: var(--space-2); - margin-top: var(--space-2); -} -.msg-retry-hint { - font-size: 0.75rem; - color: var(--text-tertiary); - opacity: 0.7; -} - -/* ─── Skeleton v2 ────────────────────────────── */ -.msg-skel { - display: flex; - gap: var(--space-3); - padding: var(--space-4); -} -.msg-skel-avatar { - width: 40px; - height: 40px; - border-radius: 50%; - background: linear-gradient(90deg, var(--surface-container-low) 25%, var(--surface-hover) 50%, var(--surface-container-low) 75%); - background-size: 200% 100%; - animation: shimmer 1.5s ease-in-out infinite; - flex-shrink: 0; -} -.msg-skel-lines { - min-width: 0; - flex: 1; - display: flex; - flex-direction: column; - gap: var(--space-3); -} -.msg-skel-line { - height: 20px; - background: linear-gradient(90deg, var(--surface-container-low) 25%, var(--surface-hover) 50%, var(--surface-container-low) 75%); - background-size: 200% 100%; - animation: shimmer 1.5s ease-in-out infinite; - border-radius: var(--radius-md); -} - -.msg-skel-badges { - display: flex; - gap: var(--space-2); -} -.msg-skel-badge { - height: 24px; - border-radius: 9999px; - background: linear-gradient(90deg, var(--surface-container-low) 25%, var(--surface-hover) 50%, var(--surface-container-low) 75%); - background-size: 200% 100%; - animation: shimmer 1.5s ease-in-out infinite; -} - -/* ── Feed ─────────────────────────────────────── */ -.feed-wrap { - display: flex; - flex-direction: column; - gap: var(--space-4); -} -.feed-empty { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding: var(--space-16) var(--space-4); - text-align: center; -} -.feed-empty-title { - font-size: 0.9375rem; - font-weight: 600; - color: var(--text-secondary); - margin-bottom: var(--space-2); -} -.feed-empty-desc { - font-size: 0.8125rem; - color: var(--text-tertiary); - max-width: 280px; - line-height: 1.5; -} +.msg-analysis-summary { display: block; font-weight: 500; margin-bottom: var(--space-1); } +.msg-analysis-text { font-size: 0.75rem; line-height: 1.5; white-space: pre-wrap; } +.msg-error { margin-top: var(--space-2); padding: var(--space-2) var(--space-3); border-radius: var(--radius-md); font-size: 0.75rem; color: var(--color-warning); background: rgba(245, 158, 11, 0.06); } +.msg-actions { display: flex; align-items: center; gap: var(--space-2); margin-top: var(--space-2); } +.msg-retry-hint { font-size: 0.75rem; color: var(--text-tertiary); opacity: 0.7; } +.msg-skel { display: flex; gap: var(--space-3); padding: var(--space-4); } +.msg-skel-avatar { width: 40px; height: 40px; border-radius: 50%; background: linear-gradient(90deg, var(--surface-container-low) 25%, rgba(35, 161, 235, 0.04) 50%, var(--surface-container-low) 75%); background-size: 200% 100%; animation: shimmer 1.5s ease-in-out infinite; flex-shrink: 0; } +.msg-skel-lines { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: var(--space-3); } +.msg-skel-line { height: 20px; background: linear-gradient(90deg, var(--surface-container-low) 25%, rgba(35, 161, 235, 0.04) 50%, var(--surface-container-low) 75%); background-size: 200% 100%; animation: shimmer 1.5s ease-in-out infinite; border-radius: var(--radius-md); } +.msg-skel-badges { display: flex; gap: var(--space-2); } +.msg-skel-badge { height: 24px; border-radius: 9999px; background: linear-gradient(90deg, var(--surface-container-low) 25%, rgba(35, 161, 235, 0.04) 50%, var(--surface-container-low) 75%); background-size: 200% 100%; animation: shimmer 1.5s ease-in-out infinite; } +.feed-wrap { display: flex; flex-direction: column; gap: var(--space-4); } +.feed-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: var(--space-16) var(--space-4); text-align: center; } +.feed-empty-title { font-size: 0.9375rem; font-weight: 600; color: var(--text-secondary); margin-bottom: var(--space-2); } +.feed-empty-desc { font-size: 0.8125rem; color: var(--text-tertiary); max-width: 280px; line-height: 1.5; } .feed-sentinel { height: var(--space-4); } -.feed-loader { - margin-top: var(--space-4); - text-align: center; -} -.feed-load-btn { } - -/* ── Auth Modal ──────────────────────────────── */ -.auth-box { - width: 380px; - text-align: center; -} -.auth-lock { - font-size: 3rem; - margin-bottom: var(--space-4); -} -.auth-title { - font-size: 1.25rem; - font-weight: 600; - margin-bottom: var(--space-2); -} -.auth-desc { - font-size: 0.875rem; - color: var(--text-secondary); - margin-bottom: var(--space-6); -} -.auth-form { - display: flex; - flex-direction: column; - gap: var(--space-3); -} -.auth-error { - font-size: 0.75rem; - color: var(--color-error); -} - -/* ── Search Results ──────────────────────────── */ -.search-count { - font-size: 0.875rem; - color: var(--text-secondary); -} - -/* ── Spinner icon (inline) ───────────────────── */ -.icon-spin { - animation: spin 1s linear infinite; -} - -/* ── Deemphasized text ──────────────────────── */ +.feed-loader { margin-top: var(--space-4); text-align: center; } +.auth-box { width: 400px; text-align: center; } +.auth-lock { font-size: 3rem; margin-bottom: var(--space-4); } +.auth-title { font-size: 1.375rem; font-weight: 700; margin-bottom: var(--space-2); background: var(--gradient-brand); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; } +.auth-desc { font-size: 0.875rem; color: var(--text-secondary); margin-bottom: var(--space-6); } +.auth-form { display: flex; flex-direction: column; gap: var(--space-3); } +.auth-error { font-size: 0.75rem; color: var(--color-error); padding: var(--space-2) var(--space-3); background: rgba(228, 64, 95, 0.08); border-radius: var(--radius-md); } +.search-count { font-size: 0.875rem; color: var(--text-secondary); } +.icon-spin { animation: spin 1s linear infinite; } .text-dim { color: var(--text-tertiary); opacity: 0.6; } -/* ================================================================ - LIVE PANEL - ================================================================ */ +/* ── LIVE PANEL ───────────────────────────────── */ .live-body { display: flex; flex-direction: column; gap: var(--space-6); } .live-head { display: flex; align-items: center; justify-content: space-between; } -.live-title { font-size: 1.5rem; font-weight: 700; letter-spacing: -0.025em; color: var(--text-primary); } +.live-title { font-size: 1.5rem; font-weight: 700; letter-spacing: -0.025em; background: var(--gradient-brand); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; } .live-desc { font-size: 0.875rem; color: var(--text-secondary); margin-top: var(--space-1); } .live-grid { display: grid; gap: var(--space-6); } .live-grid-3 { grid-template-columns: repeat(3, 1fr); } .live-span-2 { grid-column: span 2; } -@media (max-width: 1024px) { - .live-grid-3 { grid-template-columns: repeat(2, 1fr); } - .live-span-2 { grid-column: span 2; } -} -@media (max-width: 768px) { - .live-grid-3 { grid-template-columns: 1fr; } - .live-span-2 { grid-column: span 1; } -} - -/* ── Screen Sub Panel ──────────────────────────── */ +@media (max-width: 1024px) { .live-grid-3 { grid-template-columns: repeat(2, 1fr); } .live-span-2 { grid-column: span 2; } } +@media (max-width: 768px) { .live-grid-3 { grid-template-columns: 1fr; } .live-span-2 { grid-column: span 1; } } .scrn-actions { display: flex; gap: var(--space-2); } -.scrn-status { - display: inline-block; - padding: 2px 8px; - border-radius: var(--radius-sm); - font-size: 0.75rem; - color: var(--color-success); -} - -/* ── Now Playing ───────────────────────────────── */ +.scrn-status { display: inline-block; padding: 2px 8px; border-radius: var(--radius-sm); font-size: 0.75rem; color: var(--color-success); } .np-body { display: flex; flex-direction: column; gap: var(--space-2); } .np-meta { display: flex; align-items: center; justify-content: space-between; font-size: 0.75rem; color: var(--text-secondary); } .np-tags { display: flex; align-items: center; gap: var(--space-2); } -.np-sep { border-top: var(--px) solid var(--surface-border); padding-top: var(--space-3); } - -/* ── Waveform Player ───────────────────────────── */ -.wave-wrap { - border: var(--px) solid var(--surface-border); - border-radius: var(--radius-md); - padding: var(--space-3); -} -.wave-progress { - height: var(--space-2); - border-radius: var(--radius-full); - overflow: hidden; - margin-bottom: var(--space-2); -} -.wave-bar { height: 100%; border-radius: var(--radius-full); background: var(--color-primary); transition: width 200ms ease; } +.np-sep { border-top: 1px solid var(--surface-border); padding-top: var(--space-3); } +.wave-wrap { border: 1px solid var(--surface-border); border-radius: var(--radius-md); padding: var(--space-3); } +.wave-progress { height: var(--space-2); border-radius: var(--radius-full); overflow: hidden; margin-bottom: var(--space-2); } +.wave-bar { height: 100%; border-radius: var(--radius-full); background: var(--gradient-primary); transition: width 200ms ease; } .wave-info { display: flex; align-items: center; justify-content: space-between; } .wave-title { font-size: 0.875rem; font-weight: 500; max-width: 192px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .wave-time { display: flex; align-items: center; gap: var(--space-2); font-size: 0.75rem; font-family: monospace; } - -/* ── Mic Level Meter ───────────────────────────── */ .mic-row { display: flex; align-items: center; gap: var(--space-2); } -.mic-track { - position: relative; - flex: 1; - height: var(--space-2); - border-radius: var(--radius-full); - overflow: hidden; -} +.mic-track { position: relative; flex: 1; height: var(--space-2); border-radius: var(--radius-full); overflow: hidden; } .mic-clip { position: absolute; height: 100%; width: 2px; } - -/* ── Recordings Sub Panel ──────────────────────── */ .rec-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: var(--space-8); gap: var(--space-2); } .rec-list { display: flex; flex-direction: column; gap: var(--space-2); } -.rec-item { - display: flex; - align-items: center; - gap: var(--space-3); - padding: var(--space-3); - border-radius: var(--radius-md); - border: var(--px) solid var(--surface-border); - transition: all var(--transition-fast); -} -.rec-item:hover { background: var(--surface-hover); border-color: var(--color-primary); transform: translateX(2px); } +.rec-item { display: flex; align-items: center; gap: var(--space-3); padding: var(--space-3); border-radius: var(--radius-md); border: 1px solid var(--surface-border); transition: all var(--transition-fast); } +.rec-item:hover { background: var(--surface-hover); border-color: var(--color-primary); transform: translateX(4px); box-shadow: var(--shadow-sm); } .rec-info { flex: 1; min-width: 0; } .rec-name { font-size: 0.875rem; font-weight: 500; } .rec-meta { display: flex; align-items: center; gap: var(--space-2); font-size: 0.75rem; color: var(--text-secondary); margin-top: var(--space-1); } .rec-actions { display: flex; align-items: center; gap: 0.375rem; flex-shrink: 0; } .rec-footer { margin-top: var(--space-3); text-align: center; } - -/* ── Active Speakers ───────────────────────────── */ .speak-list { display: flex; flex-direction: column; gap: var(--space-2); } - -/* ── Music Sub Panel ───────────────────────────── */ .music-body { display: flex; flex-direction: column; gap: var(--space-2); } - -/* ── Dashboard Panel ───────────────────────────── */ .dash-panel { display: flex; flex-direction: column; gap: var(--space-6); } .dash-head { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-4); } -.dash-title { font-size: 1.5rem; font-weight: 700; letter-spacing: -0.025em; color: var(--text-primary); } +.dash-title { font-size: 1.5rem; font-weight: 700; letter-spacing: -0.025em; background: var(--gradient-brand); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; } .dash-desc { font-size: 0.875rem; color: var(--text-secondary); margin-top: var(--space-1); } - -/* ── Image Grid ────────────────────────────────── */ .img-empty { display: flex; align-items: center; justify-content: center; height: 128px; color: var(--text-secondary); font-style: italic; } - -/* ── Header (layout) ───────────────────────────── */ -.head-left { display: flex; align-items: center; gap: var(--space-2); } +.head-left { display: flex; align-items: center; gap: var(--space-3); } .head-right { display: flex; align-items: center; gap: var(--space-4); margin-left: auto; } .head-status { display: flex; align-items: center; gap: var(--space-1); font-size: 0.75rem; color: var(--text-secondary); } -/* Pulse the WS status dot when connecting — applied inline via JS sets style */ -.head-status-dot { width: 8px; height: 8px; border-radius: 50%; transition: background var(--transition-fast); } +.head-status-dot { width: 8px; height: 8px; border-radius: 50%; transition: all var(--transition-fast); } .head-status-dot.is-connecting { animation: pulse-connecting 1s ease-in-out infinite; } - -/* ── Mascot ────────────────────────────────────── */ .mascot-controls { display: flex; align-items: center; gap: var(--space-2); } .mascot-inline { display: flex; align-items: center; gap: var(--space-1); } diff --git a/services/frontend/frontend/src/app.rs b/services/frontend/frontend/src/app.rs index f41f7a5..fe74b63 100644 --- a/services/frontend/frontend/src/app.rs +++ b/services/frontend/frontend/src/app.rs @@ -8,6 +8,9 @@ use crate::ws::context::WsContext; use leptos::prelude::*; use shared_types::ui_state::Tab; use wasm_bindgen_futures::spawn_local; +use crate::{log_info, log_warn, make_logger}; + +make_logger!(); /// Derive WebSocket URL from the page's own origin. /// In development (serve on :8080, backend on :3001) use the detected host + /ws path. @@ -77,6 +80,7 @@ pub fn App() -> impl IntoView { provide_context(ws.clone()); ws.connect(); + log_info!("App mounted, WS connecting to {}", get_ws_url()); // Try to fetch config on startup (works if password is already in localStorage) spawn_local({ @@ -84,16 +88,11 @@ pub fn App() -> impl IntoView { async move { match config_api::get_config().await { Ok(cfg) => { - web_sys::console::log_2( - &"[config] fetched OK".into(), - &format!("monitorGuildId={:?}", cfg.monitor_guild_id).into(), - ); + log_info!("[config] fetched OK — monitorGuildId={:?}", cfg.monitor_guild_id); config.monitor_guild_id.set(cfg.monitor_guild_id); } Err(e) => { - web_sys::console::log_1( - &format!("[config] failed to fetch: {}", e).into(), - ); + log_warn!("[config] failed to fetch: {}", e); } } } @@ -110,9 +109,7 @@ pub fn App() -> impl IntoView { config.monitor_guild_id.set(cfg.monitor_guild_id); } Err(e) => { - web_sys::console::log_1( - &format!("[config] fetch after auth failed: {}", e).into(), - ); + log_info!("[config] fetch after auth failed: {}", e); } } } diff --git a/services/frontend/frontend/src/auth.rs b/services/frontend/frontend/src/auth.rs index 9f78528..eeae468 100644 --- a/services/frontend/frontend/src/auth.rs +++ b/services/frontend/frontend/src/auth.rs @@ -5,6 +5,9 @@ use crate::app::UiContext; use leptos::prelude::*; use shared_types::ui_state::Tab; use wasm_bindgen_futures::spawn_local; +use crate::{log_error, log_info, log_warn, make_logger}; + +make_logger!(); #[component] pub fn AuthOverlay() -> impl IntoView { @@ -32,6 +35,7 @@ pub fn AuthOverlay() -> impl IntoView { spawn_local(async move { match auth_api::login(&pwd_clone).await { Ok(true) => { + log_info!("Auth login successful"); // Store password in sessionStorage if let Some(storage) = web_sys::window() .and_then(|w| w.local_storage().ok()) @@ -43,9 +47,11 @@ pub fn AuthOverlay() -> impl IntoView { auth_clone.password.set(pwd_clone); } Ok(false) => { + log_warn!("Auth login failed - wrong password"); set_error_clone.set(Some("Login gagal — password salah".to_string())); } Err(e) => { + log_error!("Auth login error: {}", e.message); set_error_clone.set(Some(format!("Error: {}", e.message))); } } diff --git a/services/frontend/frontend/src/features/dashboard/components/stats_overview.rs b/services/frontend/frontend/src/features/dashboard/components/stats_overview.rs index 955382b..1bb9d08 100644 --- a/services/frontend/frontend/src/features/dashboard/components/stats_overview.rs +++ b/services/frontend/frontend/src/features/dashboard/components/stats_overview.rs @@ -1,6 +1,5 @@ use leptos::prelude::*; use shared_types::dashboard::{DashboardStats, TopChannel}; - #[component] pub fn StatsOverview( stats: Option, diff --git a/services/frontend/frontend/src/features/dashboard/mod.rs b/services/frontend/frontend/src/features/dashboard/mod.rs index ad51cd7..52f0da7 100644 --- a/services/frontend/frontend/src/features/dashboard/mod.rs +++ b/services/frontend/frontend/src/features/dashboard/mod.rs @@ -5,6 +5,9 @@ use leptos::prelude::*; use shared_types::dashboard::{DashboardChannel, DashboardStats, DashboardUser}; use std::sync::Arc; use wasm_bindgen_futures::spawn_local; +use crate::{log_error, log_info, make_logger}; + +make_logger!(); #[derive(Clone, PartialEq)] enum DashboardTab { @@ -36,10 +39,17 @@ pub fn DashboardPanel() -> impl IntoView { let fetch_stats: Arc = Arc::new(move || { stats_loading.set(true); stats_error.set(None); + log_info!("Dashboard fetching stats..."); spawn_local(async move { match crate::api::dashboard::get_dashboard_stats().await { - Ok(data) => stats.set(Some(data)), - Err(err) => stats_error.set(Some(format!("Failed to load stats: {}", err))), + Ok(data) => { + log_info!("Dashboard stats loaded: {} messages", data.total_messages); + stats.set(Some(data)); + } + Err(err) => { + log_error!("Dashboard stats error: {}", err); + stats_error.set(Some(format!("Failed to load stats: {}", err))); + } } stats_loading.set(false); }); @@ -51,6 +61,7 @@ pub fn DashboardPanel() -> impl IntoView { } users_loading.set(true); users_error.set(None); + log_info!("Dashboard fetching users..."); let cursor = if reset { None } else { users_cursor.get() }; let search = users_search.get(); @@ -64,6 +75,7 @@ pub fn DashboardPanel() -> impl IntoView { .await { Ok(page) => { + log_info!("Dashboard users loaded: {} users", page.data.len()); if reset { users.set(page.data); } else { @@ -73,7 +85,10 @@ pub fn DashboardPanel() -> impl IntoView { } users_cursor.set(page.next_cursor); } - Err(err) => users_error.set(Some(format!("Failed to load users: {}", err))), + Err(err) => { + log_error!("Dashboard users error: {}", err); + users_error.set(Some(format!("Failed to load users: {}", err))); + } } users_loading.set(false); }); @@ -85,6 +100,7 @@ pub fn DashboardPanel() -> impl IntoView { } channels_loading.set(true); channels_error.set(None); + log_info!("Dashboard fetching channels..."); let cursor = if reset { None } else { channels_cursor.get() }; let search = channels_search.get(); @@ -99,6 +115,7 @@ pub fn DashboardPanel() -> impl IntoView { .await { Ok(page) => { + log_info!("Dashboard channels loaded: {} channels", page.data.len()); if reset { channels.set(page.data); } else { @@ -108,7 +125,10 @@ pub fn DashboardPanel() -> impl IntoView { } channels_cursor.set(page.next_cursor); } - Err(err) => channels_error.set(Some(format!("Failed to load channels: {}", err))), + Err(err) => { + log_error!("Dashboard channels error: {}", err); + channels_error.set(Some(format!("Failed to load channels: {}", err))); + } } channels_loading.set(false); }); diff --git a/services/frontend/frontend/src/features/live/mod.rs b/services/frontend/frontend/src/features/live/mod.rs index 8120eb2..1a5ebd2 100644 --- a/services/frontend/frontend/src/features/live/mod.rs +++ b/services/frontend/frontend/src/features/live/mod.rs @@ -12,6 +12,9 @@ use components::{ use leptos::prelude::*; use shared_types::media::MediaState; use shared_types::voice::ActiveSpeaker; +use crate::{log_debug, log_info, make_logger}; + +make_logger!(); /// LivePanel — Composition shell for all voice and media components. /// Shows an auth overlay if not authenticated, otherwise shows voice controls. @@ -28,10 +31,12 @@ pub fn LivePanel() -> impl IntoView { // ── Wire WS events (runs on mount, persists while LivePanel is active) ── if let Some(ref ws) = ws { + log_info!("LivePanel wiring WS handlers"); // Voice active user — update speakers list *ws.on_voice_active_user.borrow_mut() = Some(Box::new({ let speakers = speakers.clone(); move |speaker: ActiveSpeaker| { + log_debug!("LivePanel voice_active_user: {}", speaker.user_id); speakers.update(|list| { if let Some(pos) = list.iter().position(|s| s.user_id == speaker.user_id) { list[pos] = speaker; @@ -46,6 +51,7 @@ pub fn LivePanel() -> impl IntoView { *ws.on_media_state.borrow_mut() = Some(Box::new({ let ms = media_state.clone(); move |state: MediaState| { + log_debug!("LivePanel media_state received"); ms.set(Some(state)); } })); @@ -54,6 +60,7 @@ pub fn LivePanel() -> impl IntoView { *ws.on_voice_recording_uploaded.borrow_mut() = Some(Box::new({ let set_refresh = set_recordings_refresh; move |_recording| { + log_debug!("LivePanel recording_uploaded received"); set_refresh.update(|v| *v = v.wrapping_add(1)); } })); @@ -62,6 +69,7 @@ pub fn LivePanel() -> impl IntoView { *ws.on_binary.borrow_mut() = Some(Box::new({ let playback = audio_playback.clone(); move |data: Vec| { + log_debug!("LivePanel binary PCM data received: {} bytes", data.len()); hooks::use_audio_playback::process_pcm_data(&playback, data); // Auto-start playback on first PCM data if !playback.active.get_untracked() { diff --git a/services/frontend/frontend/src/features/messages/components/message_card.rs b/services/frontend/frontend/src/features/messages/components/message_card.rs index 5655c8d..e5924e0 100644 --- a/services/frontend/frontend/src/features/messages/components/message_card.rs +++ b/services/frontend/frontend/src/features/messages/components/message_card.rs @@ -4,6 +4,7 @@ use shared_types::message::{AiSeverity, AiStatus, AttachmentRef, MessageRecord}; use std::sync::{Arc, OnceLock}; use wasm_bindgen::prelude::*; + // ─── Helpers ────────────────────────────────────────────── fn custom_emoji_regex() -> &'static Regex { diff --git a/services/frontend/frontend/src/features/messages/components/message_feed.rs b/services/frontend/frontend/src/features/messages/components/message_feed.rs index a02ff3f..d772a85 100644 --- a/services/frontend/frontend/src/features/messages/components/message_feed.rs +++ b/services/frontend/frontend/src/features/messages/components/message_feed.rs @@ -6,6 +6,7 @@ use wasm_bindgen::prelude::*; use wasm_bindgen::JsCast; use web_sys::IntersectionObserver; + const GROUP_WINDOW_MS: i64 = 5 * 60 * 1000; fn group_messages(messages: Vec) -> Vec> { diff --git a/services/frontend/frontend/src/features/messages/hooks/use_messages.rs b/services/frontend/frontend/src/features/messages/hooks/use_messages.rs index 02fc52f..b792048 100644 --- a/services/frontend/frontend/src/features/messages/hooks/use_messages.rs +++ b/services/frontend/frontend/src/features/messages/hooks/use_messages.rs @@ -4,6 +4,9 @@ use shared_types::message::{MessageRecord, PageResult}; use std::collections::HashMap; use std::sync::Arc; use wasm_bindgen_futures::spawn_local; +use crate::{log_debug, log_error, log_info, log_trace, log_warn, make_logger}; + +make_logger!(); /// Merges current messages with incoming messages, deduplicating by ID and sorting pub fn merge_messages(current: &[MessageRecord], incoming: &[MessageRecord]) -> Vec { @@ -77,9 +80,11 @@ pub fn use_messages() -> MessagesState { async move { error_signal.set(None); set_loading.set(true); + log_info!("Messages fetch start for guild {}", guild_id); match get_messages(&guild_id, Some(30), None, None).await { Ok(PageResult { data, next_cursor }) => { + log_info!("Messages fetch OK: count={}, cursor={:?}", data.len(), next_cursor); web_sys::console::log_3( &"[messages] fetch OK".into(), &format!("count={}", data.len()).into(), @@ -91,6 +96,7 @@ pub fn use_messages() -> MessagesState { set_loading.set(false); } Err(e) => { + log_warn!("Messages fetch error: {}", e); web_sys::console::log_2( &"[messages] fetch ERROR".into(), &format!("{}", e).into(), @@ -124,15 +130,18 @@ pub fn use_messages() -> MessagesState { loading_more_signal.set(true); error_signal.set(None); + log_info!("Messages load more for guild {}", guild_id); match get_messages(&guild_id, Some(30), None, Some(&cursor)).await { Ok(PageResult { data, next_cursor }) => { + log_info!("Messages load more OK: count={}, cursor={:?}", data.len(), next_cursor); let current = messages_signal.get(); messages_signal.set(merge_messages(¤t, &data)); cursor_signal.set(next_cursor); loading_more_signal.set(false); } Err(e) => { + log_warn!("Messages load more error: {}", e); error_signal.set(Some(format!("Failed to load more: {}", e))); loading_more_signal.set(false); } @@ -145,6 +154,7 @@ pub fn use_messages() -> MessagesState { spawn_local({ let message_id = message_id.clone(); async move { + log_info!("Messages reanalyze start for message {}", message_id); // Optimistic: flip status to Processing let mut msgs = messages_signal.get(); if let Some(pos) = msgs.iter().position(|m| m.id == message_id) { @@ -157,9 +167,11 @@ pub fn use_messages() -> MessagesState { // Call API match reanalyze_message(&message_id).await { Ok(_) => { + log_info!("Messages reanalyze OK for message {}", message_id); // Success: keep the Processing status (will be updated via WS) } Err(e) => { + log_warn!("Messages reanalyze error for message {}: {}", message_id, e); // Revert to Error status on failure let mut msgs = messages_signal.get(); if let Some(pos) = msgs.iter().position(|m| m.id == message_id) { @@ -179,8 +191,10 @@ pub fn use_messages() -> MessagesState { // Reanalyze all error messages let reanalyze_all_errors_impl = Arc::new(move || { spawn_local(async move { + log_info!("Messages reanalyze all errors start"); match reanalyze_batch().await { Ok(_count) => { + log_info!("Messages reanalyze all errors OK: count={}", _count); error_signal.set(None); // Optimistically mark all error messages as Processing let mut msgs = messages_signal.get(); @@ -192,6 +206,7 @@ pub fn use_messages() -> MessagesState { messages_signal.set(msgs); } Err(e) => { + log_info!("Messages reanalyze all errors failed: {}", e); error_signal.set(Some(format!("Batch reanalyze failed: {}", e))); } } diff --git a/services/frontend/frontend/src/features/messages/mod.rs b/services/frontend/frontend/src/features/messages/mod.rs index d10a40d..9daea54 100644 --- a/services/frontend/frontend/src/features/messages/mod.rs +++ b/services/frontend/frontend/src/features/messages/mod.rs @@ -2,6 +2,9 @@ use leptos::prelude::*; use shared_types::message::{AiStatus, MessageRecord, PageResult}; use std::sync::Arc; use wasm_bindgen_futures::spawn_local; +use crate::{log_debug, log_error, log_info, log_trace, log_warn, make_logger}; + +make_logger!(); /// Threads whose messages should be hidden from both the feed and the /// Images tab. A bot or selfbot may be spamming in a thread, polluting @@ -109,13 +112,16 @@ pub fn MessagesPanel() -> impl IntoView { } set_is_searching.set(true); let q_clone = query.trim().to_string(); + log_info!("Messages searching for: {}", q_clone); spawn_local(async move { match crate::api::messages::search_messages(&q_clone, Some(50)).await { Ok(results) => { + log_info!("Messages search found {} results", results.len()); set_search_results.set(results); set_show_search.set(true); } Err(_) => { + log_warn!("Messages search failed"); set_search_results.set(Vec::new()); } } @@ -143,10 +149,12 @@ pub fn MessagesPanel() -> impl IntoView { // WS event handlers (wire once on mount) let ws = use_context::(); if let Some(ref ws) = ws { + log_info!("MessagesPanel wiring WS handlers"); // Subscribe to real-time message events { let msgs = state.messages; *ws.on_message_created.borrow_mut() = Some(Box::new(move |msg| { + log_debug!("WS message_created received: {}", msg.id); let current = msgs.get(); msgs.set(merge_messages(¤t, &[msg])); })); @@ -154,6 +162,7 @@ pub fn MessagesPanel() -> impl IntoView { { let msgs = state.messages; *ws.on_message_updated.borrow_mut() = Some(Box::new(move |msg| { + log_debug!("WS message_updated received: {}", msg.id); let current = msgs.get(); msgs.set(merge_messages(¤t, &[msg])); })); @@ -161,6 +170,7 @@ pub fn MessagesPanel() -> impl IntoView { { let msgs = state.messages; *ws.on_message_deleted.borrow_mut() = Some(Box::new(move |id| { + log_debug!("WS message_deleted received: {}", id); let current = msgs.get(); msgs.set(current.into_iter().filter(|m| m.id != id).collect()); })); @@ -168,6 +178,7 @@ pub fn MessagesPanel() -> impl IntoView { { let msgs = state.messages; *ws.on_message_analyzed.borrow_mut() = Some(Box::new(move |msg| { + log_debug!("WS message_analyzed received: {}", msg.id); let current = msgs.get(); msgs.set(merge_messages(¤t, &[msg])); })); @@ -191,15 +202,13 @@ pub fn MessagesPanel() -> impl IntoView { let guild_id = use_context::() .and_then(|c| c.monitor_guild_id.get()); if let Some(gid) = guild_id { + log_info!("Messages fetching images for guild {}", gid); spawn_local({ let image_messages = image_messages.clone(); async move { match crate::api::messages::get_images(&gid, Some(100)).await { Ok(PageResult { data, .. }) => { - web_sys::console::log_2( - &"[images] fetch OK".into(), - &format!("count={}", data.len()).into(), - ); + log_info!("Messages images loaded: count={}", data.len()); image_messages.set( data.into_iter() .filter(|m| !is_excluded_thread(m)) @@ -207,10 +216,7 @@ pub fn MessagesPanel() -> impl IntoView { ); } Err(e) => { - web_sys::console::log_2( - &"[images] fetch ERROR".into(), - &format!("{}", e).into(), - ); + log_error!("Messages images error: {}", e); } } } diff --git a/services/frontend/frontend/src/features/polish/components/theme_toggle.rs b/services/frontend/frontend/src/features/polish/components/theme_toggle.rs index d22d270..b92ecd9 100644 --- a/services/frontend/frontend/src/features/polish/components/theme_toggle.rs +++ b/services/frontend/frontend/src/features/polish/components/theme_toggle.rs @@ -1,5 +1,8 @@ use crate::features::polish::{persist_theme, ThemeContext}; use leptos::prelude::*; +use crate::{log_info, make_logger}; + +make_logger!(); #[component] pub fn ThemeToggle() -> impl IntoView { @@ -21,6 +24,7 @@ pub fn ThemeToggle() -> impl IntoView { } else { "dark" }; + log_info!("Theme toggled to {}", next); ctx.theme.set(next.to_string()); persist_theme(next); } diff --git a/services/frontend/frontend/src/features/polish/mod.rs b/services/frontend/frontend/src/features/polish/mod.rs index 0ce7088..a51c135 100644 --- a/services/frontend/frontend/src/features/polish/mod.rs +++ b/services/frontend/frontend/src/features/polish/mod.rs @@ -1,6 +1,9 @@ pub mod components; use leptos::prelude::*; +use crate::{log_info, make_logger}; + +make_logger!(); #[derive(Clone)] pub struct ThemeContext { @@ -8,14 +11,17 @@ pub struct ThemeContext { } pub fn initial_theme() -> String { - web_sys::window() + let theme = web_sys::window() .and_then(|window| window.local_storage().ok().flatten()) .and_then(|storage| storage.get_item("imphnen-theme").ok().flatten()) .filter(|value| value == "dark" || value == "light") - .unwrap_or_else(|| "light".to_string()) + .unwrap_or_else(|| "light".to_string()); + log_info!("Initial theme resolved: {}", theme); + theme } pub fn persist_theme(theme: &str) { + log_info!("Persisting theme: {}", theme); if let Some(storage) = web_sys::window().and_then(|window| window.local_storage().ok().flatten()) { diff --git a/services/frontend/frontend/src/layout/dashboard_layout.rs b/services/frontend/frontend/src/layout/dashboard_layout.rs index 519d91b..2687494 100644 --- a/services/frontend/frontend/src/layout/dashboard_layout.rs +++ b/services/frontend/frontend/src/layout/dashboard_layout.rs @@ -6,6 +6,7 @@ use super::tab_strip::TabStrip; use leptos::children::Children; use leptos::prelude::*; + #[component] pub fn DashboardLayout(children: Children) -> impl IntoView { view! { diff --git a/services/frontend/frontend/src/layout/header.rs b/services/frontend/frontend/src/layout/header.rs index 9fef97d..13ad2a1 100644 --- a/services/frontend/frontend/src/layout/header.rs +++ b/services/frontend/frontend/src/layout/header.rs @@ -3,6 +3,7 @@ use crate::ws::context::WsContext; use crate::ws::socket::WsStatus; use leptos::prelude::*; + #[component] pub fn Header() -> impl IntoView { let ws = use_context::().expect("WsContext not provided"); diff --git a/services/frontend/frontend/src/layout/mobile_tab_bar.rs b/services/frontend/frontend/src/layout/mobile_tab_bar.rs index 8f43ac6..fd2e84b 100644 --- a/services/frontend/frontend/src/layout/mobile_tab_bar.rs +++ b/services/frontend/frontend/src/layout/mobile_tab_bar.rs @@ -3,6 +3,7 @@ use crate::app::UiContext; use leptos::prelude::*; use shared_types::ui_state::Tab; + #[component] pub fn MobileTabBar() -> impl IntoView { let ui = use_context::().expect("UiContext not provided"); diff --git a/services/frontend/frontend/src/layout/sidebar.rs b/services/frontend/frontend/src/layout/sidebar.rs index 5dae8be..6e1bda4 100644 --- a/services/frontend/frontend/src/layout/sidebar.rs +++ b/services/frontend/frontend/src/layout/sidebar.rs @@ -3,6 +3,7 @@ use crate::app::UiContext; use leptos::prelude::*; use shared_types::ui_state::Tab; + #[component] pub fn Sidebar() -> impl IntoView { let ui = use_context::().expect("UiContext not provided"); diff --git a/services/frontend/frontend/src/layout/tab_strip.rs b/services/frontend/frontend/src/layout/tab_strip.rs index 866baf9..f438a74 100644 --- a/services/frontend/frontend/src/layout/tab_strip.rs +++ b/services/frontend/frontend/src/layout/tab_strip.rs @@ -3,6 +3,7 @@ use crate::app::UiContext; use leptos::prelude::*; use shared_types::ui_state::Tab; + #[component] pub fn TabStrip() -> impl IntoView { let ui = use_context::().expect("UiContext not provided"); diff --git a/services/frontend/frontend/src/lib.rs b/services/frontend/frontend/src/lib.rs index f00e055..94147b8 100644 --- a/services/frontend/frontend/src/lib.rs +++ b/services/frontend/frontend/src/lib.rs @@ -3,18 +3,19 @@ pub mod app; pub mod auth; pub mod features; pub mod layout; +pub mod logger; pub mod ui; pub mod ws; + use wasm_bindgen::prelude::*; +make_logger!(); + #[wasm_bindgen(start)] pub fn start() { - // Set up panic hook for better error messages in the browser console console_error_panic_hook::set_once(); - // Initialize logger wasm_logger::init(wasm_logger::Config::default()); - - // Mount the Leptos app to the body + log_info!("IMPHNEN frontend starting..."); leptos::mount::mount_to_body(app::App); } diff --git a/services/frontend/frontend/src/logger.rs b/services/frontend/frontend/src/logger.rs new file mode 100644 index 0000000..e7472c8 --- /dev/null +++ b/services/frontend/frontend/src/logger.rs @@ -0,0 +1,177 @@ +// services/frontend/frontend/src/logger.rs +// Structured logging for WASM browser console with levels, timestamps, and styled output. + +/// Log level with numeric priority (lower = more verbose). +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum LogLevel { + Trace = 0, + Debug = 1, + Info = 2, + Warn = 3, + Error = 4, +} + +impl LogLevel { + fn as_str(&self) -> &'static str { + match self { + LogLevel::Trace => "TRACE", + LogLevel::Debug => "DEBUG", + LogLevel::Info => "INFO", + LogLevel::Warn => "WARN", + LogLevel::Error => "ERROR", + } + } + + /// CSS color for the browser console label. + fn console_style(&self) -> &'static str { + match self { + LogLevel::Trace => "color:#888", + LogLevel::Debug => "color:#54a2ff", + LogLevel::Info => "color:#23a1eb;font-weight:bold", + LogLevel::Warn => "color:#f59e0b;font-weight:bold", + LogLevel::Error => "color:#e4405f;font-weight:bold", + } + } +} + +/// A per-module logger that produces styled, timestamped console output. +#[derive(Clone)] +pub struct Logger { + module: &'static str, + min_level: LogLevel, +} + +impl Logger { + /// Create a logger for a given module path (call with `module_path!()`). + pub const fn new(module: &'static str, min_level: LogLevel) -> Self { + Self { module, min_level } + } + + /// Create a logger that shows everything (min_level = Trace). + pub const fn verbose(module: &'static str) -> Self { + Self::new(module, LogLevel::Trace) + } + + /// Format an ISO-like timestamp from `Date.now()`. + fn timestamp() -> String { + let d = js_sys::Date::new_0(); + // HH:MM:SS.mmm + format!( + "{:02}:{:02}:{:02}.{:03}", + d.get_hours(), + d.get_minutes(), + d.get_seconds(), + d.get_milliseconds() + ) + } + + fn should_log(&self, level: LogLevel) -> bool { + level >= self.min_level + } + + fn log_inner(&self, level: LogLevel, message: &str) { + if !self.should_log(level) { + return; + } + let ts = Self::timestamp(); + let lvl_str = level.as_str(); + let style = level.console_style(); + let styled = format!("%c{:.7} [{}] {}", ts, self.module, message); + match level { + LogLevel::Error => { + web_sys::console::error_3( + &styled.into(), + &style.into(), + &"".into(), + ); + } + LogLevel::Warn => { + web_sys::console::warn_3( + &styled.into(), + &style.into(), + &"".into(), + ); + } + _ => { + web_sys::console::log_3( + &styled.into(), + &style.into(), + &"".into(), + ); + } + } + } + + pub fn trace(&self, msg: &str) { + self.log_inner(LogLevel::Trace, msg); + } + + pub fn debug(&self, msg: &str) { + self.log_inner(LogLevel::Debug, msg); + } + + pub fn info(&self, msg: &str) { + self.log_inner(LogLevel::Info, msg); + } + + pub fn warn(&self, msg: &str) { + self.log_inner(LogLevel::Warn, msg); + } + + pub fn error(&self, msg: &str) { + self.log_inner(LogLevel::Error, msg); + } + + /// Log with a dynamic format string. + pub fn info_fmt(&self, fmt: &str, args: &[&dyn std::fmt::Display]) { + let msg = if args.is_empty() { + fmt.to_string() + } else { + let mut s = String::new(); + let mut iter = args.iter(); + for part in fmt.split("{}") { + s.push_str(part); + if let Some(arg) = iter.next() { + s.push_str(&arg.to_string()); + } + } + s + }; + self.log_inner(LogLevel::Info, &msg); + } +} + +/// Macro to create a module-level logger at `Info` level. +/// Usage: `log::module!()` at the top of a source file (after imports). +#[macro_export] +macro_rules! make_logger { + () => { + static LOGGER: std::sync::LazyLock<$crate::logger::Logger> = + std::sync::LazyLock::new(|| { + $crate::logger::Logger::new(module_path!(), $crate::logger::LogLevel::Trace) + }); + }; +} + +/// Convenience macros that log through the module's static LOGGER. +/// Usage: `log_info!("something happened")`. +#[macro_export] +macro_rules! log_trace { + ($($arg:tt)*) => { LOGGER.trace(&format!($($arg)*)); }; +} +#[macro_export] +macro_rules! log_debug { + ($($arg:tt)*) => { LOGGER.debug(&format!($($arg)*)); }; +} +#[macro_export] +macro_rules! log_info { + ($($arg:tt)*) => { LOGGER.info(&format!($($arg)*)); }; +} +#[macro_export] +macro_rules! log_warn { + ($($arg:tt)*) => { LOGGER.warn(&format!($($arg)*)); }; +} +#[macro_export] +macro_rules! log_error { + ($($arg:tt)*) => { LOGGER.error(&format!($($arg)*)); }; +} diff --git a/services/frontend/frontend/src/ui/button.rs b/services/frontend/frontend/src/ui/button.rs index 4556413..468cacf 100644 --- a/services/frontend/frontend/src/ui/button.rs +++ b/services/frontend/frontend/src/ui/button.rs @@ -1,5 +1,6 @@ use leptos::prelude::*; + #[derive(Clone, Default)] pub enum ButtonVariant { #[default] diff --git a/services/frontend/frontend/src/ui/card.rs b/services/frontend/frontend/src/ui/card.rs index e0341e0..a272452 100644 --- a/services/frontend/frontend/src/ui/card.rs +++ b/services/frontend/frontend/src/ui/card.rs @@ -1,5 +1,6 @@ use leptos::prelude::*; + #[component] pub fn Card( #[prop(optional)] elevated: bool, diff --git a/services/frontend/frontend/src/ui/empty_state.rs b/services/frontend/frontend/src/ui/empty_state.rs index 662f5d5..47072a0 100644 --- a/services/frontend/frontend/src/ui/empty_state.rs +++ b/services/frontend/frontend/src/ui/empty_state.rs @@ -1,6 +1,7 @@ // services/frontend-leptos/frontend/src/ui/empty_state.rs use leptos::prelude::*; + #[component] pub fn EmptyState( #[prop(optional)] icon: Option, diff --git a/services/frontend/frontend/src/ui/modal.rs b/services/frontend/frontend/src/ui/modal.rs index fe51348..f23167f 100644 --- a/services/frontend/frontend/src/ui/modal.rs +++ b/services/frontend/frontend/src/ui/modal.rs @@ -2,6 +2,7 @@ use leptos::prelude::*; use std::sync::Arc; + #[component] pub fn Modal( is_open: RwSignal, diff --git a/services/frontend/frontend/src/ui/toast.rs b/services/frontend/frontend/src/ui/toast.rs index 9d9b340..a6c5d13 100644 --- a/services/frontend/frontend/src/ui/toast.rs +++ b/services/frontend/frontend/src/ui/toast.rs @@ -1,6 +1,10 @@ // services/frontend-leptos/frontend/src/ui/toast.rs use leptos::prelude::*; use std::sync::{Arc, Mutex}; +use std::fmt; +use crate::{log_info, make_logger}; + +make_logger!(); #[derive(Clone)] pub enum ToastType { @@ -10,6 +14,17 @@ pub enum ToastType { Warning, } +impl fmt::Display for ToastType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ToastType::Info => write!(f, "info"), + ToastType::Success => write!(f, "success"), + ToastType::Error => write!(f, "error"), + ToastType::Warning => write!(f, "warning"), + } + } +} + #[derive(Clone)] pub struct ToastMessage { pub id: u64, @@ -38,6 +53,7 @@ impl ToastContext { } pub fn show(&self, message: &str, toast_type: ToastType) { + log_info!("Toast: {} ({})", message, toast_type); let id = { let mut n = self.next_id.lock().unwrap(); *n += 1; diff --git a/services/frontend/frontend/src/ws/context.rs b/services/frontend/frontend/src/ws/context.rs index 99e7dde..77bf56c 100644 --- a/services/frontend/frontend/src/ws/context.rs +++ b/services/frontend/frontend/src/ws/context.rs @@ -5,6 +5,9 @@ use shared_types::media::MediaState; use shared_types::message::MessageRecord; use shared_types::recording::VoiceRecording; use shared_types::voice::ActiveSpeaker; +use crate::{log_debug, log_error, log_info, log_trace, log_warn, make_logger}; + +make_logger!(); #[derive(Clone)] #[allow(clippy::type_complexity)] @@ -61,6 +64,7 @@ impl WsContext { match event_type.as_str() { "message_created" => { + log_debug!("WS event: message_created"); if let Some(d) = data.and_then(|v| { serde_json::from_value::(v.clone()).ok() }) { @@ -70,6 +74,7 @@ impl WsContext { } } "message_updated" => { + log_debug!("WS event: message_updated"); if let Some(d) = data.and_then(|v| { serde_json::from_value::(v.clone()).ok() }) { @@ -79,6 +84,7 @@ impl WsContext { } } "message_deleted" => { + log_debug!("WS event: message_deleted"); if let Some(d) = data.and_then(|v| v.as_str().map(String::from)) { if let Some(cb) = self.on_message_deleted.borrow().as_ref() { cb(d); @@ -86,6 +92,7 @@ impl WsContext { } } "message_analyzed" => { + log_debug!("WS event: message_analyzed"); if let Some(d) = data.and_then(|v| { serde_json::from_value::(v.clone()).ok() }) { @@ -95,6 +102,7 @@ impl WsContext { } } "voice_active_user" => { + log_debug!("WS event: voice_active_user"); if let Some(d) = data.and_then(|v| { serde_json::from_value::(v.clone()).ok() }) { @@ -104,6 +112,7 @@ impl WsContext { } } "voice_recording_uploaded" => { + log_debug!("WS event: voice_recording_uploaded"); if let Some(d) = data.and_then(|v| { serde_json::from_value::(v.clone()).ok() }) { @@ -114,6 +123,7 @@ impl WsContext { } } "media_state" => { + log_debug!("WS event: media_state"); // Backend sends initial state with "state" key, live updates with "data" let raw = data.or_else(|| parsed.get("state")).cloned(); if let Some(d) = @@ -126,14 +136,13 @@ impl WsContext { } _ => { // Unknown event type — log and ignore - web_sys::console::log_1( - &format!("[WS] unhandled event: {}", event_type).into(), - ); + log_warn!("WS unhandled event type: {}", event_type); } } } } WsEvent::Binary(data) => { + log_debug!("WS event: binary ({} bytes)", data.len()); if let Some(cb) = self.on_binary.borrow().as_ref() { cb(data); } diff --git a/services/frontend/frontend/src/ws/socket.rs b/services/frontend/frontend/src/ws/socket.rs index b670d84..ec60638 100644 --- a/services/frontend/frontend/src/ws/socket.rs +++ b/services/frontend/frontend/src/ws/socket.rs @@ -3,6 +3,9 @@ use leptos::prelude::*; use wasm_bindgen::prelude::*; use wasm_bindgen::JsCast; use web_sys::{CloseEvent, ErrorEvent, MessageEvent, WebSocket}; +use crate::{log_debug, log_error, log_info, log_trace, log_warn, make_logger}; + +make_logger!(); #[derive(Debug, Clone, PartialEq)] pub enum WsStatus { @@ -85,6 +88,7 @@ impl WsHandle { reconnect_attempt: *const std::cell::Cell, ) { let url_owned = url.to_string(); + let url_close = url_owned.clone(); let status1 = set_status; let status2 = set_status; let status3 = set_status; @@ -98,6 +102,7 @@ impl WsHandle { // onopen let onopen_cb = Closure::::new(move |_| { status1.set(WsStatus::Connected); + log_info!("WS connected to {}", url_owned); unsafe { (*reconnect_attempt).set(0) }; }); ws.set_onopen(Some(onopen_cb.as_ref().unchecked_ref())); @@ -107,6 +112,7 @@ impl WsHandle { let event_for_close = event_clone.clone(); let onclose_cb = Closure::::new(move |_| { status2.set(WsStatus::Disconnected); + log_info!("WS disconnected from {}", url_close); unsafe { *(*ws_holder).borrow_mut() = None }; let attempt = unsafe { (*reconnect_attempt).get() }; @@ -114,6 +120,7 @@ impl WsHandle { status2.set(WsStatus::Error( "Max reconnect attempts reached".to_string(), )); + log_error!("WS reconnect max attempts reached for {}", url_close); return; } // Full-jitter exponential backoff: min(1000 * 2^attempt, 30000) * (0.5 + random * 0.5) @@ -122,7 +129,9 @@ impl WsHandle { let delay_ms = (base as f64 * jitter) as u32; unsafe { (*reconnect_attempt).set(attempt + 1) }; - let url_reconnect = url_owned.clone(); + log_info!("WS reconnecting to {} in {}ms (attempt {})", url_close, delay_ms, attempt + 1); + + let url_reconnect = url_close.clone(); let status_rc = status2; let event_rc = event_for_close.clone(); let reconnect_fn = Closure::::new(move || { @@ -148,6 +157,7 @@ impl WsHandle { // onerror let onerror_cb = Closure::::new(move |e: ErrorEvent| { + log_error!("WS error: {}", e.message()); status3.set(WsStatus::Error(e.message())); }); ws.set_onerror(Some(onerror_cb.as_ref().unchecked_ref())); @@ -173,12 +183,12 @@ impl WsHandle { onmsg_cb.forget(); } Err(e) => { - set_status.set(WsStatus::Error( - js_sys::Error::from(e) - .to_string() - .as_string() - .unwrap_or_default(), - )); + let msg = js_sys::Error::from(e) + .to_string() + .as_string() + .unwrap_or_default(); + log_error!("WS connect failed: {}", msg); + set_status.set(WsStatus::Error(msg)); } } }