diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index dcc5c85..f23d9ce 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -30,6 +30,7 @@ # # Required Gitea Variables: # REGISTRY, NAMESPACE, DOMAIN, POSTGRES_HOST +# SEED_USERS (optional) — CSV: "user:pass:role,..." for dev/test seed accounts # # Required Gitea Secrets: # POSTGRES_USER, POSTGRES_PASSWORD @@ -414,6 +415,7 @@ jobs: SW_ADMIN_USER: ${{ secrets.SWITCHBOARD_ADMIN_USERNAME }} SW_ADMIN_PASS: ${{ secrets.SWITCHBOARD_ADMIN_PASSWORD }} SW_ADMIN_EMAIL: ${{ secrets.SWITCHBOARD_ADMIN_EMAIL }} + SEED_USERS: ${{ vars.SEED_USERS }} run: | kubectl create secret generic switchboard-db-credentials \ --namespace=${NAMESPACE} \ @@ -428,6 +430,11 @@ jobs: --from-literal=email="${SW_ADMIN_EMAIL}" \ --dry-run=client -o yaml | kubectl apply -f - + kubectl create secret generic switchboard-seed-users \ + --namespace=${NAMESPACE} \ + --from-literal=users="${SEED_USERS}" \ + --dry-run=client -o yaml | kubectl apply -f - + - name: Render and apply manifests env: ENVIRONMENT: ${{ steps.env.outputs.ENVIRONMENT }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 4db56c3..c76f313 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,35 @@ All notable changes to Chat Switchboard. +## [0.9.2] — 2026-02-23 + +### Added +- **Collapsible code blocks**: Code blocks over 15 lines auto-collapse into + `
` with language and line count summary. Language label shown in + top-left corner of all code blocks. +- **HTML preview**: "Preview" button on HTML code blocks opens a sandboxed + iframe (`allow-scripts`, no `allow-same-origin`). Auto-detected for + untagged blocks via heuristic. +- **Token count estimate**: Live token counter below input area showing + approximate tokens and context usage percentage when model has `max_context`. +- **Context length warning**: Dismissable banner above input at 75% (yellow) + and 90% (red) context usage with guidance to start a new chat. +- **Proxy interception detection**: `_parseJSON()` checks Content-Type before + `.json()` on all API paths including streaming. Typed `proxyBlocked` errors + with proxy page title extraction and actionable splash messages. +- **Environment injection**: `window.__ENV__` wired through entrypoint, k8s, + and index.html for dev/test/production gating. +- **Enhanced diagnostics**: `NET:PROXY` log type, Content-Type capture in + fetch interceptor, environment info in export header and state snapshot. +- **Team admin audit scoping**: New `GET /api/v1/teams/:teamId/audit` and + `/audit/actions` endpoints scoped to team members. Activity Log section + in team manage panel with filter dropdown and pagination. +- **New favicon**: Switchboard panel design with provider-colored jacks. + Animated SVG (rotating plugs), 32px PNG, 256px PNG, and ICO. +- **Seed users** (dev/test only): `SEED_USERS=user:pass:role,...` env var + pre-creates active users on startup. Ignored in production. K8s secret + `switchboard-seed-users` wired in backend manifest. + ## [0.9.1] — 2026-02-23 ### Removed diff --git a/README.md b/README.md index 80243c2..4962c4d 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,7 @@ All configuration via environment variables. See `server/.env.example` for the f | `JWT_SECRET` | (required) | Token signing key | | `SWITCHBOARD_ADMIN_USERNAME` | ` ` | Bootstrap admin username | | `SWITCHBOARD_ADMIN_PASSWORD` | ` ` | Bootstrap admin password | +| `SEED_USERS` | ` ` | Dev/test only: `user:pass:role,user2:pass2:role2` | ## Deployment @@ -90,8 +91,8 @@ docker compose --profile dev up # include Adminer DB UI Build and push both images: ```bash -docker build -f server/Dockerfile -t your-registry/switchboard-api:0.9.1 server/ -docker build -f Dockerfile.frontend -t your-registry/switchboard-fe:0.9.1 . +docker build -f server/Dockerfile -t your-registry/switchboard-api:0.9.2 server/ +docker build -f Dockerfile.frontend -t your-registry/switchboard-fe:0.9.2 . ``` **Backend deployment:** @@ -99,7 +100,7 @@ docker build -f Dockerfile.frontend -t your-registry/switchboard-fe:0.9.1 . ```yaml containers: - name: api - image: your-registry/switchboard-api:0.9.1 + image: your-registry/switchboard-api:0.9.2 ports: - containerPort: 8080 env: @@ -119,7 +120,7 @@ containers: ```yaml containers: - name: frontend - image: your-registry/switchboard-fe:0.9.1 + image: your-registry/switchboard-fe:0.9.2 ports: - containerPort: 80 env: diff --git a/ROADMAP.md b/ROADMAP.md index cecbefb..8bb7d83 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -115,12 +115,13 @@ Polish that doesn't need new infrastructure. Ship fast, stabilize. - [x] HTML preview for generated content (sandboxed iframe toggle) - [x] Token count estimate in input area (before send) - [x] "Conversation is getting long" warning (context % thresholds) +- [x] New switchboard panel favicon (animated SVG + PNG + ICO) **Hardening** - [x] Proxy interception detection (Content-Type checks, actionable error messages) - [x] Environment injection to frontend (`window.__ENV__`) - [x] Enhanced debug diagnostics (NET:PROXY log type, Content-Type capture, env info) -- [ ] Team admin audit scoping (see only their team's audit entries) +- [x] Team admin audit scoping (see only their team's audit entries) --- diff --git a/VERSION b/VERSION index f514a2f..f76f913 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.1 \ No newline at end of file +0.9.2 \ No newline at end of file diff --git a/k8s/backend.yaml b/k8s/backend.yaml index b7f2d00..f2bc441 100644 --- a/k8s/backend.yaml +++ b/k8s/backend.yaml @@ -83,6 +83,12 @@ spec: name: switchboard-admin key: email optional: true + - name: SEED_USERS + valueFrom: + secretKeyRef: + name: switchboard-seed-users + key: users + optional: true resources: requests: memory: "${BE_MEMORY_REQUEST}" diff --git a/server/.env.example b/server/.env.example index 39c7a7d..11087d7 100644 --- a/server/.env.example +++ b/server/.env.example @@ -26,6 +26,13 @@ JWT_ISSUER=chat-switchboard SWITCHBOARD_ADMIN_USERNAME= SWITCHBOARD_ADMIN_PASSWORD= +# ── Seed Users (dev/test only) ────────────── +# Pre-create users on startup. Ignored in production. +# Format: user:pass:role,user2:pass2:role2 (role = admin|user, default: user) +# Idempotent: existing usernames are skipped. +# K8s: create secret "switchboard-seed-users" with key "users" +SEED_USERS= + # ── CORS ───────────────────────────────────── CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:8080 diff --git a/server/config/config.go b/server/config/config.go index a0c7a64..0ad996b 100644 --- a/server/config/config.go +++ b/server/config/config.go @@ -18,6 +18,9 @@ type Config struct { AdminUsername string AdminPassword string AdminEmail string + + // Seed users (dev/test only) — CSV: "user:pass:role,user2:pass2:role2" + SeedUsers string } // Load reads configuration from environment variables. @@ -35,6 +38,7 @@ func Load() *Config { AdminUsername: getEnv("SWITCHBOARD_ADMIN_USERNAME", ""), AdminPassword: getEnv("SWITCHBOARD_ADMIN_PASSWORD", ""), AdminEmail: getEnv("SWITCHBOARD_ADMIN_EMAIL", ""), + SeedUsers: getEnv("SEED_USERS", ""), } } diff --git a/server/database/migrations/002_ci_username.sql b/server/database/migrations/002_ci_username.sql new file mode 100644 index 0000000..4f18dc9 --- /dev/null +++ b/server/database/migrations/002_ci_username.sql @@ -0,0 +1,14 @@ +-- 002_ci_username.sql +-- Case-insensitive unique indexes for username and email. +-- Replaces the default UNIQUE constraint (which is case-sensitive). + +-- Normalize existing rows to lowercase first +UPDATE users SET username = LOWER(username) WHERE username != LOWER(username); +UPDATE users SET email = LOWER(email) WHERE email != LOWER(email); + +-- Drop old case-sensitive unique constraints and add case-insensitive indexes +ALTER TABLE users DROP CONSTRAINT IF EXISTS users_username_key; +ALTER TABLE users DROP CONSTRAINT IF EXISTS users_email_key; + +CREATE UNIQUE INDEX IF NOT EXISTS users_username_ci ON users (LOWER(username)); +CREATE UNIQUE INDEX IF NOT EXISTS users_email_ci ON users (LOWER(email)); diff --git a/server/handlers/admin.go b/server/handlers/admin.go index 3cc982e..88f6703 100644 --- a/server/handlers/admin.go +++ b/server/handlers/admin.go @@ -6,6 +6,7 @@ import ( "log" "net/http" "strconv" + "strings" "github.com/gin-gonic/gin" "golang.org/x/crypto/bcrypt" @@ -71,8 +72,8 @@ func (h *AdminHandler) CreateUser(c *gin.Context) { } user := &models.User{ - Username: req.Username, - Email: req.Email, + Username: strings.ToLower(req.Username), + Email: strings.ToLower(req.Email), PasswordHash: string(hash), Role: role, IsActive: true, @@ -243,7 +244,20 @@ func (h *AdminHandler) ListGlobalConfigs(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list configs"}) return } - c.JSON(http.StatusOK, gin.H{"configs": cfgs}) + + // Redact API keys but expose has_key flag for UI + type configWithKey struct { + models.ProviderConfig + HasKey bool `json:"has_key"` + } + out := make([]configWithKey, len(cfgs)) + for i, cfg := range cfgs { + out[i] = configWithKey{ + ProviderConfig: cfg, + HasKey: cfg.APIKeyEnc != "", + } + } + c.JSON(http.StatusOK, gin.H{"configs": out}) } func (h *AdminHandler) CreateGlobalConfig(c *gin.Context) { @@ -293,7 +307,22 @@ func (h *AdminHandler) CreateGlobalConfig(c *gin.Context) { } h.auditLog(c, "provider.create", "provider_config", cfg.ID, gin.H{"name": cfg.Name, "provider": cfg.Provider}) - c.JSON(http.StatusCreated, cfg) + c.JSON(http.StatusCreated, gin.H{ + "id": cfg.ID, + "scope": cfg.Scope, + "name": cfg.Name, + "provider": cfg.Provider, + "endpoint": cfg.Endpoint, + "model_default": cfg.ModelDefault, + "config": cfg.Config, + "headers": cfg.Headers, + "settings": cfg.Settings, + "is_active": cfg.IsActive, + "is_private": cfg.IsPrivate, + "has_key": cfg.APIKeyEnc != "", + "created_at": cfg.CreatedAt, + "updated_at": cfg.UpdatedAt, + }) } func (h *AdminHandler) UpdateGlobalConfig(c *gin.Context) { diff --git a/server/handlers/auth.go b/server/handlers/auth.go index 41b5ca7..38fbb9e 100644 --- a/server/handlers/auth.go +++ b/server/handlers/auth.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "log" "net/http" + "strings" "time" "github.com/gin-gonic/gin" @@ -77,8 +78,8 @@ func (h *AuthHandler) Register(c *gin.Context) { defaultActive, _ := h.stores.Policies.GetBool(c.Request.Context(), "default_user_active") user := &models.User{ - Username: req.Username, - Email: req.Email, + Username: strings.ToLower(req.Username), + Email: strings.ToLower(req.Email), PasswordHash: string(hash), Role: models.UserRoleUser, IsActive: defaultActive, @@ -269,8 +270,8 @@ func BootstrapAdmin(cfg *config.Config, s store.Stores) { } user := &models.User{ - Username: cfg.AdminUsername, - Email: email, + Username: strings.ToLower(cfg.AdminUsername), + Email: strings.ToLower(email), PasswordHash: string(hash), Role: models.UserRoleAdmin, IsActive: true, @@ -283,6 +284,83 @@ func BootstrapAdmin(cfg *config.Config, s store.Stores) { log.Printf(" ✅ Admin user '%s' created", cfg.AdminUsername) } +// SeedUsers creates or updates users from the SEED_USERS env var. +// Format: "user:pass:role,user2:pass2:role2" where role is "admin" or "user". +// Upsert: existing users get their password and role refreshed on every restart. +// Gated to non-production environments. +func SeedUsers(cfg *config.Config, s store.Stores) { + if cfg.SeedUsers == "" { + log.Printf(" ℹ SEED_USERS not set, skipping") + return + } + + if cfg.Environment == "production" { + log.Printf("⚠ SEED_USERS ignored in production environment") + return + } + + ctx := context.Background() + entries := strings.Split(cfg.SeedUsers, ",") + log.Printf(" 🌱 SEED_USERS: %d entries to process", len(entries)) + + for _, entry := range entries { + entry = strings.TrimSpace(entry) + if entry == "" { + continue + } + + parts := strings.SplitN(entry, ":", 3) + if len(parts) < 2 { + log.Printf("⚠ Seed user skipped (bad format, want user:pass[:role]): %q", entry) + continue + } + + username := strings.ToLower(strings.TrimSpace(parts[0])) + password := strings.TrimSpace(parts[1]) + role := models.UserRoleUser + if len(parts) >= 3 && strings.TrimSpace(parts[2]) == "admin" { + role = models.UserRoleAdmin + } + + if username == "" || password == "" { + log.Printf("⚠ Seed user skipped (empty username or password)") + continue + } + + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost) + if err != nil { + log.Printf("⚠ Seed user '%s' skipped (hash error): %v", username, err) + continue + } + + // Upsert: update password+role if user exists, create if not + existing, _ := s.Users.GetByUsername(ctx, username) + if existing != nil { + s.Users.Update(ctx, existing.ID, map[string]interface{}{ + "password_hash": string(hash), + "role": role, + "is_active": true, + }) + log.Printf(" 🌱 Seed user '%s' updated (role=%s)", username, role) + continue + } + + user := &models.User{ + Username: username, + Email: username + "@switchboard.local", + PasswordHash: string(hash), + Role: role, + IsActive: true, + } + + if err := s.Users.Create(ctx, user); err != nil { + log.Printf("⚠ Seed user '%s' failed: %v", username, err) + continue + } + log.Printf(" 🌱 Seed user '%s' created (role=%s, active=true)", username, role) + } +} + // IsRegistrationEnabled checks the platform policy. func IsRegistrationEnabled(s store.Stores) bool { val, _ := s.Policies.GetBool(context.Background(), "allow_registration") diff --git a/server/handlers/teams.go b/server/handlers/teams.go index 1cba519..45f5324 100644 --- a/server/handlers/teams.go +++ b/server/handlers/teams.go @@ -631,3 +631,125 @@ func enforcePrivateProviderPolicy(userID, configID string) error { } return nil } + +// ── Team Audit Log (scoped to team members) ─ + +// ListTeamAuditLog returns paginated audit entries where the actor is a member +// of the specified team. Team admins see only their team's activity; system +// admins see everything (but the scoping still applies via the same query). +// GET /api/v1/teams/:teamId/audit?page=1&per_page=50&action=...&actor_id=...&resource_type=... +func (h *TeamHandler) ListTeamAuditLog(c *gin.Context) { + teamID := c.Param("teamId") + page, perPage, offset := parsePagination(c) + + // Build filter clauses — always scoped to team members + where := "WHERE al.actor_id IN (SELECT user_id FROM team_members WHERE team_id = $1)" + args := []interface{}{teamID} + argN := 2 + + if action := c.Query("action"); action != "" { + where += " AND al.action = $" + strconv.Itoa(argN) + args = append(args, action) + argN++ + } + if actorID := c.Query("actor_id"); actorID != "" { + where += " AND al.actor_id = $" + strconv.Itoa(argN) + args = append(args, actorID) + argN++ + } + if rt := c.Query("resource_type"); rt != "" { + where += " AND al.resource_type = $" + strconv.Itoa(argN) + args = append(args, rt) + argN++ + } + + // Count + var total int + countArgs := make([]interface{}, len(args)) + copy(countArgs, args) + err := database.DB.QueryRow(`SELECT COUNT(*) FROM audit_log al `+where, countArgs...).Scan(&total) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "count failed"}) + return + } + + // Query with actor name join + query := ` + SELECT al.id, al.actor_id, COALESCE(u.username, '') as actor_name, + al.action, al.resource_type, al.resource_id, + COALESCE(al.metadata::text, '{}'), al.ip_address, al.created_at + FROM audit_log al + LEFT JOIN users u ON al.actor_id = u.id + ` + where + ` + ORDER BY al.created_at DESC + LIMIT $` + strconv.Itoa(argN) + ` OFFSET $` + strconv.Itoa(argN+1) + args = append(args, perPage, offset) + + rows, err := database.DB.Query(query, args...) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"}) + return + } + defer rows.Close() + + type entry struct { + ID string `json:"id"` + ActorID *string `json:"actor_id"` + ActorName *string `json:"actor_name"` + Action string `json:"action"` + ResourceType string `json:"resource_type"` + ResourceID *string `json:"resource_id"` + Metadata string `json:"metadata"` + IPAddress *string `json:"ip_address"` + CreatedAt string `json:"created_at"` + } + + entries := make([]entry, 0) + for rows.Next() { + var e entry + var actorName sql.NullString + if err := rows.Scan(&e.ID, &e.ActorID, &actorName, &e.Action, + &e.ResourceType, &e.ResourceID, &e.Metadata, &e.IPAddress, &e.CreatedAt); err != nil { + continue + } + if actorName.Valid { + e.ActorName = &actorName.String + } + entries = append(entries, e) + } + + c.JSON(http.StatusOK, gin.H{ + "data": entries, + "total": total, + "page": page, + "per_page": perPage, + }) +} + +// ListTeamAuditActions returns distinct action names for audit entries within +// the team scope, for filter dropdowns. +// GET /api/v1/teams/:teamId/audit/actions +func (h *TeamHandler) ListTeamAuditActions(c *gin.Context) { + teamID := c.Param("teamId") + + rows, err := database.DB.Query(` + SELECT DISTINCT al.action + FROM audit_log al + WHERE al.actor_id IN (SELECT user_id FROM team_members WHERE team_id = $1) + ORDER BY al.action ASC + `, teamID) + if err != nil { + c.JSON(http.StatusOK, gin.H{"actions": []string{}}) + return + } + defer rows.Close() + + actions := make([]string, 0) + for rows.Next() { + var a string + if rows.Scan(&a) == nil { + actions = append(actions, a) + } + } + c.JSON(http.StatusOK, gin.H{"actions": actions}) +} diff --git a/server/main.go b/server/main.go index 3ba8992..99232cf 100644 --- a/server/main.go +++ b/server/main.go @@ -38,6 +38,9 @@ func main() { // Bootstrap admin from env (K8s secret) — upserts on every restart handlers.BootstrapAdmin(cfg, stores) + + // Seed additional users from env (dev/test only, skipped in production) + handlers.SeedUsers(cfg, stores) } defer database.Close() @@ -194,6 +197,10 @@ func main() { teamScoped.DELETE("/providers/:id", teams.DeleteTeamProvider) teamScoped.GET("/providers/:id/models", teams.ListTeamProviderModels) + // Team audit log (scoped to team members) + teamScoped.GET("/audit", teams.ListTeamAuditLog) + teamScoped.GET("/audit/actions", teams.ListTeamAuditActions) + // Team personas teamPersonas := handlers.NewPersonaHandler(stores) teamScoped.GET("/presets", teamPersonas.ListTeamPersonas) diff --git a/server/store/postgres/catalog.go b/server/store/postgres/catalog.go index 9bbdfb8..5f3c62e 100644 --- a/server/store/postgres/catalog.go +++ b/server/store/postgres/catalog.go @@ -29,10 +29,7 @@ func (s *CatalogStore) UpsertFromSync(ctx context.Context, providerConfigID stri now := time.Now() for _, e := range entries { capsJSON := ToJSON(e.Capabilities) - var pricingJSON []byte - if e.Pricing != nil { - pricingJSON = ToJSON(e.Pricing) - } + pricingJSON := ToJSON(e.Pricing) // nil → "{}", always valid JSONB var existingID string err := DB.QueryRowContext(ctx, diff --git a/server/store/postgres/user.go b/server/store/postgres/user.go index 63c243e..a22b874 100644 --- a/server/store/postgres/user.go +++ b/server/store/postgres/user.go @@ -28,11 +28,43 @@ func (s *UserStore) GetByID(ctx context.Context, id string) (*models.User, error } func (s *UserStore) GetByUsername(ctx context.Context, username string) (*models.User, error) { - return s.getBy(ctx, "username", username) + var u models.User + var displayName, avatarURL sql.NullString + var settingsJSON []byte + err := DB.QueryRowContext(ctx, ` + SELECT id, username, email, password_hash, display_name, avatar_url, + role, is_active, settings, created_at, updated_at, last_login_at + FROM users WHERE LOWER(username) = LOWER($1)`, username).Scan( + &u.ID, &u.Username, &u.Email, &u.PasswordHash, &displayName, &avatarURL, + &u.Role, &u.IsActive, &settingsJSON, &u.CreatedAt, &u.UpdatedAt, &u.LastLoginAt, + ) + if err != nil { + return nil, err + } + u.DisplayName = NullableString(displayName) + u.AvatarURL = NullableString(avatarURL) + ScanJSON(settingsJSON, &u.Settings) + return &u, nil } func (s *UserStore) GetByEmail(ctx context.Context, email string) (*models.User, error) { - return s.getBy(ctx, "email", email) + var u models.User + var displayName, avatarURL sql.NullString + var settingsJSON []byte + err := DB.QueryRowContext(ctx, ` + SELECT id, username, email, password_hash, display_name, avatar_url, + role, is_active, settings, created_at, updated_at, last_login_at + FROM users WHERE LOWER(email) = LOWER($1)`, email).Scan( + &u.ID, &u.Username, &u.Email, &u.PasswordHash, &displayName, &avatarURL, + &u.Role, &u.IsActive, &settingsJSON, &u.CreatedAt, &u.UpdatedAt, &u.LastLoginAt, + ) + if err != nil { + return nil, err + } + u.DisplayName = NullableString(displayName) + u.AvatarURL = NullableString(avatarURL) + ScanJSON(settingsJSON, &u.Settings) + return &u, nil } func (s *UserStore) GetByLogin(ctx context.Context, login string) (*models.User, error) { @@ -42,7 +74,7 @@ func (s *UserStore) GetByLogin(ctx context.Context, login string) (*models.User, err := DB.QueryRowContext(ctx, ` SELECT id, username, email, password_hash, display_name, avatar_url, role, is_active, settings, created_at, updated_at, last_login_at - FROM users WHERE username = $1 OR email = $1`, login).Scan( + FROM users WHERE LOWER(username) = LOWER($1) OR LOWER(email) = LOWER($1)`, login).Scan( &u.ID, &u.Username, &u.Email, &u.PasswordHash, &displayName, &avatarURL, &u.Role, &u.IsActive, &settingsJSON, &u.CreatedAt, &u.UpdatedAt, &u.LastLoginAt, ) diff --git a/src/css/styles.css b/src/css/styles.css index 48904f3..5d952f8 100644 --- a/src/css/styles.css +++ b/src/css/styles.css @@ -97,12 +97,14 @@ a:hover { text-decoration: underline; } background: none; border: none; color: var(--text); cursor: pointer; font-size: 13px; white-space: nowrap; transition: background var(--transition); width: 100%; + position: relative; } .sb-brand:hover { background: var(--bg-hover); } -.brand-logo { font-size: 18px; flex-shrink: 0; display: inline; } -.brand-collapse { flex-shrink: 0; display: none; color: var(--text-2); } -.sb-brand:hover .brand-logo { display: none; } -.sb-brand:hover .brand-collapse { display: inline; } +.brand-logo { flex-shrink: 0; width: 18px; height: 18px; display: grid; place-items: center; } +.brand-logo-img { width: 18px; height: 18px; object-fit: contain; border-radius: 3px; transition: opacity var(--transition); } +.brand-collapse { flex-shrink: 0; width: 0; overflow: hidden; color: var(--text-2); opacity: 0; transition: opacity var(--transition); position: absolute; left: 10px; } +.sb-brand:hover .brand-logo-img { opacity: 0; } +.sb-brand:hover .brand-collapse { opacity: 1; } .brand-text { font-weight: 600; font-size: 14px; } .sb-btn { @@ -123,6 +125,7 @@ a:hover { text-decoration: underline; } .sidebar.collapsed .sidebar-top { padding: 10px 0 8px; align-items: center; } .sidebar.collapsed .sidebar-bottom { padding: 8px 0; display: flex; flex-direction: column; align-items: center; } .sidebar.collapsed .sb-brand { justify-content: center; padding: 8px 0; gap: 0; } +.sidebar.collapsed .brand-collapse { left: 50%; transform: translateX(-50%); } .sidebar.collapsed .sb-btn { justify-content: center; padding: 8px 0; gap: 0; } .sidebar.collapsed .split-btn-main { justify-content: center; padding: 8px 0; gap: 0; } .sidebar.collapsed .user-btn { justify-content: center; padding: 6px 0; gap: 0; } @@ -666,6 +669,7 @@ a:hover { text-decoration: underline; } justify-content: center; height: 100%; color: var(--text-3); gap: 0.25rem; } .empty-logo { font-size: 3rem; margin-bottom: 0.5rem; } +.empty-logo-img { width: 64px; height: 64px; object-fit: contain; } .empty-state h2 { font-size: 1.25rem; font-weight: 600; color: var(--text-2); } .empty-state p { font-size: 0.85rem; } @@ -811,7 +815,7 @@ button { font-family: var(--font); cursor: pointer; } .hero-logo-img { width: 48px; height: 48px; object-fit: contain; border-radius: 8px; } .hero-wordmark { font-size: 1.6rem; font-weight: 700; letter-spacing: -0.03em; line-height: 1.1; } .hero-wordmark span { color: var(--accent); } -.brand-logo-img { width: 18px; height: 18px; object-fit: contain; border-radius: 3px; vertical-align: middle; } + .hero-headline { font-size: 2.4rem; font-weight: 700; line-height: 1.15; letter-spacing: -0.03em; margin-bottom: 1rem; diff --git a/src/favicon-256.png b/src/favicon-256.png new file mode 100644 index 0000000..ff0595a Binary files /dev/null and b/src/favicon-256.png differ diff --git a/src/favicon-32.png b/src/favicon-32.png index e1ce1b6..ddab172 100644 Binary files a/src/favicon-32.png and b/src/favicon-32.png differ diff --git a/src/favicon.ico b/src/favicon.ico new file mode 100644 index 0000000..975d061 Binary files /dev/null and b/src/favicon.ico differ diff --git a/src/favicon.svg b/src/favicon.svg index 699bb2c..6b64f72 100644 --- a/src/favicon.svg +++ b/src/favicon.svg @@ -1,10 +1,53 @@ - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/index.html b/src/index.html index 539e711..47fb1f7 100644 --- a/src/index.html +++ b/src/index.html @@ -9,10 +9,12 @@ Chat Switchboard - @@ -32,7 +34,7 @@