diff --git a/src/components/room/design-system.ts b/src/components/room/design-system.ts index 6bb1d45..f478324 100644 --- a/src/components/room/design-system.ts +++ b/src/components/room/design-system.ts @@ -1,89 +1,239 @@ /** - * AI Studio design system — room-wide tokens. - * Clean, modern palette. No Discord reference. + * AI Studio design system — palette hooks. + * + * Architecture: + * CSS custom properties (index.css) ← index.css :root / .dark ← index.css @layer semantic + * ↑ read only ← custom mode writes here + * + * In default mode the hook returns the CSS variable values (live, theme-aware). + * In "custom" mode it returns the stored localStorage palette and also writes + * those values to the DOM so they override the CSS layer. */ +import { useEffect, useMemo, useState } from 'react'; import { useTheme } from '@/contexts'; -// ─── Palette ────────────────────────────────────────────────────────────────── +// ─── Preset definitions ─────────────────────────────────────────────────────── -export const PALETTE = { - light: { - // Backgrounds - bg: '#ffffff', - bgSubtle: '#f9f9fa', - bgHover: '#f3f3f5', - bgActive: '#ebebef', - // Borders - border: '#e4e4e8', - borderFocus:'#1c7ded', - borderMuted:'#eeeeef', - // Text - text: '#1f1f1f', - textMuted: '#8a8a8f', - textSubtle: '#b8b8bd', - // Accent (primary action) - accent: '#1c7ded', - accentHover:'#1a73d4', - accentText: '#ffffff', - // Icon - icon: '#8a8a8f', - iconHover: '#5c5c62', - // Surfaces - surface: '#f7f7f8', - surface2: '#eeeeef', - // Status - online: '#22c55e', - away: '#f59e0b', - offline: '#d1d1d6', - // Mention highlight - mentionBg: 'rgba(28,125,237,0.08)', - mentionText:'#1c7ded', - // Message bubbles - msgBg: '#f9f9fb', - msgOwnBg: '#e8f0fe', - // Panel - panelBg: '#f5f5f7', - // Badges - badgeAi: 'bg-blue-50 text-blue-600', - badgeRole: 'bg-gray-100 text-gray-600', - }, - dark: { - bg: '#1a1a1e', - bgSubtle: '#1e1e23', - bgHover: '#222228', - bgActive: '#2a2a30', - border: '#2e2e35', - borderFocus:'#4a9eff', - borderMuted:'#252528', - text: '#ececf1', - textMuted: '#8a8a92', - textSubtle: '#5c5c65', - accent: '#4a9eff', - accentHover:'#6aafff', - accentText: '#ffffff', - icon: '#7a7a84', - iconHover: '#b0b0ba', - surface: '#222228', - surface2: '#2a2a30', - online: '#34d399', - away: '#fbbf24', - offline: '#6b7280', - mentionBg: 'rgba(74,158,255,0.12)', - mentionText:'#4a9eff', - msgBg: '#1e1e23', - msgOwnBg: '#1a2a3a', - panelBg: '#161619', - badgeAi: 'bg-blue-900/40 text-blue-300', - badgeRole: 'bg-gray-800 text-gray-400', - }, -} as const; +export interface PaletteEntry { + bg: string; // page background + bgSubtle: string; // slightly elevated surface + bgHover: string; // hover state + bgActive: string; // active/pressed state + border: string; // default border + borderFocus: string; // focus ring / active border + borderMuted: string; // subtle dividers + text: string; // primary text + textMuted: string; // secondary / metadata + textSubtle: string; // timestamps, hints + accent: string; // brand / action color + accentHover: string; + accentText: string; // text on accent bg + icon: string; // default icon color + iconHover: string; // icon hover color + surface: string; // card / elevated surface + surface2: string; // deeper surface + online: string; // online status dot + away: string; // away / idle dot + offline: string; // offline dot + mentionBg: string; // mention highlight bg (with alpha) + mentionText: string; // mention highlight text + msgBg: string; // received message bubble bg + msgOwnBg: string; // own message bubble bg + panelBg: string; // sidebar / panel background + badgeAi: string; // tailwind classes for AI badge + badgeRole: string; // tailwind classes for role badge +} -export type ThemePalette = typeof PALETTE.light; +export type ThemePresetId = 'default' | 'custom'; + +export interface ThemePreset { + id: ThemePresetId; + label: string; + description: string; + /** Pre-built palette object, or null = read from CSS vars (default mode) */ + palette: PaletteEntry | null; +} + +// ─── Presets ────────────────────────────────────────────────────────────────── + +export const THEME_PRESETS: ThemePreset[] = [ + { + id: 'default', + label: 'Default', + description: 'Linear / Vercel inspired — neutral + single indigo accent', + palette: null, // reads live from CSS vars + }, +]; + +/** Well-known CSS vars that map to PaletteEntry keys */ +const PALETTE_VAR_MAP: Record = { + bg: '--bg', + bgSubtle: '--surface-2', + bgHover: '--surface-3', + bgActive: '--surface-3', + border: '--border', + borderFocus: '--ring', + borderMuted: '--border-2', + text: '--fg', + textMuted: '--fg-muted', + textSubtle: '--fg-subtle', + accent: '--accent', + accentHover: '--accent-hover', + accentText: '--accent-fg', + icon: '--fg-muted', + iconHover: '--fg', + surface: '--surface-2', + surface2: '--surface-3', + online: '--success', + away: '--warning', + offline: '--room-offline', + mentionBg: '--accent-subtle', + mentionText: '--accent', + msgBg: '--surface-2', + msgOwnBg: '--accent-subtle', + panelBg: '--sidebar-bg', + badgeAi: 'bg-accent/10 text-accent font-medium', + badgeRole: 'bg-muted text-muted-foreground font-medium', +}; + +/** Read a CSS custom property value from the DOM, fallback to a default */ +function readCssVar(name: string, fallback: string): string { + if (typeof document === 'undefined') return fallback; + return getComputedStyle(document.documentElement) + .getPropertyValue(name) + .trim() || fallback; +} + +// ─── Custom palette storage ─────────────────────────────────────────────────── + +const CUSTOM_KEY = 'theme-custom-palette'; +const PRESET_KEY = 'theme-preset'; + +export function loadCustomPalette(): PaletteEntry | null { + try { + const raw = localStorage.getItem(CUSTOM_KEY); + if (!raw) return null; + return JSON.parse(raw) as PaletteEntry; + } catch { + return null; + } +} + +export function saveCustomPalette(palette: PaletteEntry) { + localStorage.setItem(CUSTOM_KEY, JSON.stringify(palette)); +} + +export function clearCustomPalette() { + localStorage.removeItem(CUSTOM_KEY); + localStorage.setItem(PRESET_KEY, 'default'); +} + +export function loadActivePresetId(): ThemePresetId { + return (localStorage.getItem(PRESET_KEY) as ThemePresetId) || 'default'; +} + +export function saveActivePresetId(id: ThemePresetId) { + localStorage.setItem(PRESET_KEY, id); + // Notify all useAIPalette hooks to re-read + window.dispatchEvent(new CustomEvent('theme-preset-change', { detail: id })); +} + +/** + * Apply a custom palette to the DOM root so it overrides the CSS layer. + * Only the keys present in PALETTE_VAR_MAP are written. + */ +export function applyPaletteToDOM(palette: PaletteEntry) { + const root = document.documentElement; + for (const [key, cssVar] of Object.entries(PALETTE_VAR_MAP)) { + if (key === 'badgeAi' || key === 'badgeRole') continue; // class strings, skip + root.style.setProperty(cssVar, (palette as unknown as Record)[key]); + } +} + +/** Reset DOM overrides back to the CSS layer (removes custom inline styles) */ +export function resetDOMFromPalette() { + const root = document.documentElement; + for (const cssVar of Object.values(PALETTE_VAR_MAP)) { + if (cssVar === '--badge-ai' || cssVar === '--badge-role') continue; + root.style.removeProperty(cssVar); + } +} // ─── Hook ──────────────────────────────────────────────────────────────────── -export function useAIPalette() { +export function useAIPalette(): PaletteEntry { const { resolvedTheme } = useTheme(); - return resolvedTheme === 'dark' ? PALETTE.dark : PALETTE.light; + const [customPalette, setCustomPalette] = useState( + loadCustomPalette, + ); + const [activePresetId, setActivePresetId] = useState(loadActivePresetId); + + // Re-read from localStorage when the custom palette changes + // (e.g. after ThemeSwitcher saves a new custom palette) + useEffect(() => { + const onPresetChange = () => { + setActivePresetId(loadActivePresetId()); + setCustomPalette(loadCustomPalette()); + }; + window.addEventListener('theme-preset-change', onPresetChange); + return () => window.removeEventListener('theme-preset-change', onPresetChange); + }, []); + + // ── Derive palette ────────────────────────────────────────────────────── + + if (activePresetId === 'custom' && customPalette) { + // Custom mode: return the stored palette (DOM is already updated by the + // ThemeSwitcher, but we also return it here so callers get the right values) + return customPalette; + } + + // Default mode: read live from CSS variables + return useMemo(() => { + // re-compute when light/dark resolvedTheme changes + void resolvedTheme; + + return { + bg: readCssVar('--bg', '#ffffff'), + bgSubtle: readCssVar('--surface-2', '#f9f9fa'), + bgHover: readCssVar('--surface-3', '#f3f3f5'), + bgActive: readCssVar('--surface-3', '#ebebef'), + border: readCssVar('--border', '#e4e4e8'), + borderFocus: readCssVar('--ring', '#1c7ded'), + borderMuted: readCssVar('--border-2', '#eeeeef'), + text: readCssVar('--fg', '#1f1f1f'), + textMuted: readCssVar('--fg-muted', '#8a8a8f'), + textSubtle: readCssVar('--fg-subtle', '#b8b8bd'), + accent: readCssVar('--accent', '#1c7ded'), + accentHover: readCssVar('--accent-hover', '#1a73d4'), + accentText: readCssVar('--accent-fg', '#ffffff'), + icon: readCssVar('--fg-muted', '#8a8a8f'), + iconHover: readCssVar('--fg', '#5c5c62'), + surface: readCssVar('--surface-2', '#f7f7f8'), + surface2: readCssVar('--surface-3', '#eeeeef'), + online: readCssVar('--success', '#22c55e'), + away: readCssVar('--warning', '#f59e0b'), + offline: readCssVar('--room-offline', '#d1d1d6'), + mentionBg: readCssVar('--accent-subtle', 'rgba(28,125,237,0.08)'), + mentionText: readCssVar('--accent', '#1c7ded'), + msgBg: readCssVar('--surface-2', '#f9f9fb'), + msgOwnBg: readCssVar('--accent-subtle', '#e8f0fe'), + panelBg: readCssVar('--sidebar-bg', '#f9f9fa'), + badgeAi: 'bg-accent/10 text-accent font-medium', + badgeRole: 'bg-muted text-muted-foreground font-medium', + }; + }, [resolvedTheme]); +} + +/** Trigger a custom palette: saves to localStorage, applies to DOM, sets preset */ +export function activateCustomPalette(palette: PaletteEntry) { + saveCustomPalette(palette); + saveActivePresetId('custom'); + applyPaletteToDOM(palette); +} + +/** Reset back to the default CSS-layer theme */ +export function deactivateCustomPalette() { + clearCustomPalette(); + resetDOMFromPalette(); } diff --git a/src/index.css b/src/index.css index 43162f8..d19b109 100644 --- a/src/index.css +++ b/src/index.css @@ -4,211 +4,342 @@ @custom-variant dark (&:is(.dark *)); -@theme inline { - --font-heading: var(--font-sans); - --font-sans: 'Geist Variable', sans-serif; - --color-sidebar-ring: var(--sidebar-ring); - --color-sidebar-border: var(--sidebar-border); - --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); - --color-sidebar-accent: var(--sidebar-accent); - --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); - --color-sidebar-primary: var(--sidebar-primary); - --color-sidebar-foreground: var(--sidebar-foreground); - --color-sidebar: var(--sidebar); - --color-chart-5: var(--chart-5); - --color-chart-4: var(--chart-4); - --color-chart-3: var(--chart-3); - --color-chart-2: var(--chart-2); - --color-chart-1: var(--chart-1); - --color-ring: var(--ring); - --color-input: var(--input); - --color-border: var(--border); - --color-destructive: var(--destructive); - --color-accent-foreground: var(--accent-foreground); - --color-accent: var(--accent); - --color-muted-foreground: var(--muted-foreground); - --color-muted: var(--muted); - --color-secondary-foreground: var(--secondary-foreground); - --color-secondary: var(--secondary); - --color-primary-foreground: var(--primary-foreground); - --color-primary: var(--primary); - --color-popover-foreground: var(--popover-foreground); - --color-popover: var(--popover); - --color-card-foreground: var(--card-foreground); - --color-card: var(--card); - --color-foreground: var(--foreground); - --color-background: var(--background); +/* ══════════════════════════════════════════════════════════════════════════════ + DESIGN TOKEN LAYER + Structure: Primitive → Semantic → Component + Philosophy: Linear/Vercel dual-color (neutral monochrome + single indigo accent) + ══════════════════════════════════════════════════════════════════════════════ */ + +/* ── Layer 1: Primitive tokens ─────────────────────────────────────────────── */ +/* These are never used directly in components — always reference via semantic */ + +/* Light primitives */ +@layer primitives { + :root { + /* Neutral scale (achromatic gray ramp) */ + --p-gray-50: oklch(0.995 0 0); + --p-gray-100: oklch(0.985 0 0); + --p-gray-200: oklch(0.967 0 0); + --p-gray-300: oklch(0.91 0 0); + --p-gray-400: oklch(0.70 0 0); + --p-gray-500: oklch(0.55 0 0); + --p-gray-600: oklch(0.40 0 0); + --p-gray-700: oklch(0.30 0 0); + --p-gray-800: oklch(0.20 0 0); + --p-gray-900: oklch(0.135 0 0); + --p-gray-950: oklch(0.11 0 0); + + /* Brand accent (single hue: indigo) */ + --p-accent-50: oklch(0.95 0.05 265); + --p-accent-100: oklch(0.88 0.08 265); + --p-accent-200: oklch(0.78 0.11 265); + --p-accent-300: oklch(0.65 0.14 265); + --p-accent-400: oklch(0.55 0.17 265); + --p-accent-500: oklch(0.42 0.19 265); + --p-accent-600: oklch(0.35 0.20 265); + --p-accent-700: oklch(0.30 0.21 265); + --p-accent-800: oklch(0.22 0.17 265); + --p-accent-900: oklch(0.14 0.12 265); + + /* Status */ + --p-success: oklch(0.55 0.14 160); /* green */ + --p-warning: oklch(0.72 0.14 75); /* yellow */ + --p-error: oklch(0.55 0.22 25); /* red */ + + /* Surface luminance — base for background */ + --p-surface-light: oklch(0.995 0 0); + --p-surface-dark: oklch(0.13 0 0); + + /* Shadows */ + --p-shadow-sm: 0 1px 2px oklch(0 0 0 / 0.04), 0 1px 3px oklch(0 0 0 / 0.06); + --p-shadow-md: 0 4px 6px oklch(0 0 0 / 0.04), 0 2px 4px oklch(0 0 0 / 0.06); + --p-shadow-lg: 0 10px 15px oklch(0 0 0 / 0.05), 0 4px 6px oklch(0 0 0 / 0.06); + --p-shadow-xl: 0 8px 30px oklch(0 0 0 / 0.10), 0 2px 8px oklch(0 0 0 / 0.06); + --p-shadow-focus-light: 0 0 0 3px oklch(0.42 0.19 265 / 15%); + --p-shadow-focus-dark: 0 0 0 3px oklch(0.60 0.17 265 / 25%); + + /* Radius base */ + --p-radius-sm: 0.25rem; + --p-radius-md: 0.375rem; + --p-radius-lg: 0.5rem; + --p-radius-xl: 0.75rem; + } +} + +/* ── Layer 2: Semantic tokens ─────────────────────────────────────────────── */ +/* Maps primitives to roles. Theme-switching happens HERE only. */ + +@layer semantic { + :root { + /* Background / foreground */ + --bg: var(--p-gray-50); + --fg: var(--p-gray-900); + --fg-muted: var(--p-gray-500); + --fg-subtle: var(--p-gray-400); + + /* Surface hierarchy */ + --surface-1: var(--p-gray-50); /* page bg */ + --surface-2: var(--p-gray-100); /* cards */ + --surface-3: var(--p-gray-200); /* hover, input bg */ + + /* Border */ + --border: var(--p-gray-300); + --border-2: var(--p-gray-200); /* subtle dividers */ + + /* Brand accent */ + --accent: var(--p-accent-500); + --accent-hover: var(--p-accent-400); + --accent-subtle: oklch(0.42 0.19 265 / 10%); + --accent-fg: var(--p-gray-50); + + /* Destructive */ + --destructive: var(--p-error); + --destructive-fg: var(--p-gray-50); + + /* Status */ + --success: var(--p-success); + --success-subtle: oklch(0.55 0.14 160 / 12%); + --warning: var(--p-warning); + --warning-subtle: oklch(0.72 0.14 75 / 12%); + --error: var(--p-error); + --error-subtle: oklch(0.55 0.22 25 / 12%); + + /* Ring / focus */ + --ring: var(--accent); + --focus: var(--p-shadow-focus-light); + + /* Chart — monochrome ramp */ + --chart-1: var(--p-gray-600); + --chart-2: var(--p-gray-500); + --chart-3: var(--p-gray-400); + --chart-4: var(--p-gray-300); + --chart-5: var(--p-gray-200); + + /* Radius */ + --radius: var(--p-radius-md); --radius-sm: calc(var(--radius) * 0.6); --radius-md: calc(var(--radius) * 0.8); - --radius-lg: var(--radius); + --radius-lg: calc(var(--radius) * 1.0); --radius-xl: calc(var(--radius) * 1.4); --radius-2xl: calc(var(--radius) * 1.8); --radius-3xl: calc(var(--radius) * 2.2); --radius-4xl: calc(var(--radius) * 2.6); - /* ── Discord layout tokens ─────────────────────────────────────────────── */ - --color-discord-bg: var(--room-bg); - --color-discord-sidebar: var(--room-sidebar); - --color-discord-channel-hover: var(--room-channel-hover); + /* Shadows */ + --shadow-sm: var(--p-shadow-sm); + --shadow-md: var(--p-shadow-md); + --shadow-lg: var(--p-shadow-lg); + --shadow-xl: var(--p-shadow-xl); + + /* Sidebar */ + --sidebar-bg: var(--p-gray-100); + --sidebar-fg: var(--p-gray-900); + --sidebar-border: var(--p-gray-300); + --sidebar-accent: var(--p-gray-200); + --sidebar-accent-fg: var(--p-gray-700); + --sidebar-primary: var(--accent); + --sidebar-primary-fg: var(--p-gray-50); + + /* Room / chat */ + --room-bg: var(--p-gray-50); + --room-sidebar: var(--p-gray-100); + --room-sidebar-fg: var(--p-gray-900); + --room-hover: var(--p-gray-200); + --room-border: var(--p-gray-300); + --room-channel-active: var(--accent-subtle); + --room-text: var(--p-gray-900); + --room-text-secondary: var(--p-gray-600); + --room-text-muted: var(--p-gray-500); + --room-text-subtle: var(--p-gray-400); + --room-accent: var(--accent); + --room-accent-hover: var(--accent-hover); + --room-mention-bg: var(--accent); + --room-mention-fg: var(--p-gray-50); + --room-online: var(--p-success); + --room-away: var(--p-warning); + --room-offline: var(--p-gray-400); + } + + /* Dark theme — only semantic overrides, primitives stay the same */ + .dark { + --bg: var(--p-gray-950); + --fg: var(--p-gray-50); + --fg-muted: var(--p-gray-400); + --fg-subtle: var(--p-gray-600); + + --surface-1: var(--p-gray-950); + --surface-2: var(--p-gray-900); + --surface-3: var(--p-gray-800); + + --border: oklch(1 0 0 / 8%); + --border-2: oklch(1 0 0 / 5%); + + --accent: var(--p-accent-600); + --accent-hover: var(--p-accent-500); + --accent-subtle: oklch(0.60 0.17 265 / 14%); + --accent-fg: var(--p-gray-950); + + --destructive: oklch(0.65 0.18 25); + --destructive-fg: var(--p-gray-950); + + --success: var(--p-success); + --success-subtle: oklch(0.65 0.13 160 / 15%); + --warning: var(--p-warning); + --warning-subtle: oklch(0.68 0.14 75 / 15%); + --error: var(--p-error); + --error-subtle: oklch(0.65 0.18 25 / 15%); + + --ring: var(--accent); + --focus: var(--p-shadow-focus-dark); + + --chart-1: var(--p-gray-200); + --chart-2: var(--p-gray-300); + --chart-3: var(--p-gray-400); + --chart-4: var(--p-gray-500); + --chart-5: var(--p-gray-600); + + --shadow-sm: 0 1px 2px oklch(0 0 0 / 0.30), 0 1px 3px oklch(0 0 0 / 0.40); + --shadow-md: 0 4px 6px oklch(0 0 0 / 0.30), 0 2px 4px oklch(0 0 0 / 0.40); + --shadow-lg: 0 10px 15px oklch(0 0 0 / 0.35), 0 4px 6px oklch(0 0 0 / 0.40); + --shadow-xl: 0 8px 30px oklch(0 0 0 / 0.50), 0 2px 8px oklch(0 0 0 / 0.40); + + --sidebar-bg: var(--p-gray-950); + --sidebar-fg: var(--p-gray-50); + --sidebar-border: oklch(1 0 0 / 8%); + --sidebar-accent: var(--p-gray-800); + --sidebar-accent-fg: var(--p-gray-200); + --sidebar-primary: var(--accent); + --sidebar-primary-fg: var(--p-gray-950); + + --room-bg: var(--p-gray-950); + --room-sidebar: var(--p-gray-900); + --room-sidebar-fg: var(--p-gray-50); + --room-hover: var(--p-gray-800); + --room-border: oklch(1 0 0 / 8%); + --room-channel-active: var(--accent-subtle); + --room-text: var(--p-gray-50); + --room-text-secondary: var(--p-gray-200); + --room-text-muted: oklch(1 0 0 / 55%); + --room-text-subtle: oklch(1 0 0 / 38%); + --room-accent: var(--accent); + --room-accent-hover: var(--accent-hover); + --room-mention-bg: var(--accent); + --room-mention-fg: var(--p-gray-950); + --room-online: var(--p-success); + --room-away: var(--p-warning); + --room-offline: oklch(1 0 0 / 30%); + } +} + +/* ── Layer 3: Tailwind @theme inline bridge ──────────────────────────────── */ +/* Maps semantic tokens → Tailwind utility classes */ + +@theme inline { + --font-heading: var(--font-sans); + --font-sans: 'Geist Variable', sans-serif; + + --color-background: var(--bg); + --color-foreground: var(--fg); + --color-card: var(--surface-2); + --color-card-foreground: var(--fg); + --color-popover: var(--surface-2); + --color-popover-foreground: var(--fg); + --color-primary: var(--accent); + --color-primary-foreground: var(--accent-fg); + --color-secondary: var(--surface-3); + --color-secondary-foreground: var(--fg); + --color-muted: var(--surface-3); + --color-muted-foreground: var(--fg-muted); + --color-accent: var(--surface-3); + --color-accent-foreground: var(--fg); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-fg); + --color-border: var(--border); + --color-input: var(--border); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + + --radius-sm: var(--radius-sm); + --radius-md: var(--radius-md); + --radius-lg: var(--radius-lg); + --radius-xl: var(--radius-xl); + --radius-2xl: var(--radius-2xl); + --radius-3xl: var(--radius-3xl); + --radius-4xl: var(--radius-4xl); + + /* Sidebar */ + --color-sidebar: var(--sidebar-bg); + --color-sidebar-foreground: var(--sidebar-fg); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-fg); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-fg); + --color-sidebar-ring: var(--ring); + + /* Room (discord-layout) — maps to semantic tokens */ + --color-discord-bg: var(--room-bg); + --color-discord-sidebar: var(--room-sidebar); + --color-discord-fg: var(--room-sidebar-fg); + --color-discord-hover: var(--room-hover); + --color-discord-border: var(--room-border); --color-discord-channel-active: var(--room-channel-active); - --color-discord-mention-badge: var(--room-mention-badge); - --color-discord-blurple: var(--room-accent); - --color-discord-blurple-hover: var(--room-accent-hover); - --color-discord-green: var(--room-online); - --color-discord-red: oklch(0.63 0.21 25); - --color-discord-yellow: oklch(0.75 0.17 80); - --color-discord-online: var(--room-online); - --color-discord-offline: var(--room-offline); - --color-discord-idle: var(--room-away); - --color-discord-mention-text: var(--room-mention-text); - --color-discord-text: var(--room-text); + --color-discord-text: var(--room-text); --color-discord-text-secondary: var(--room-text-secondary); - --color-discord-text-muted: var(--room-text-muted); - --color-discord-text-subtle: var(--room-text-subtle); - --color-discord-border: var(--room-border); - --color-discord-hover: var(--room-hover); - --color-discord-placeholder: var(--room-text-muted); + --color-discord-text-muted: var(--room-text-muted); + --color-discord-text-subtle: var(--room-text-subtle); + --color-discord-accent: var(--room-accent); + --color-discord-accent-hover: var(--room-accent-hover); + --color-discord-mention-bg: var(--room-mention-bg); + --color-discord-mention-fg: var(--room-mention-fg); + --color-discord-online: var(--room-online); + --color-discord-away: var(--room-away); + --color-discord-offline: var(--room-offline); + --color-discord-success: var(--success); + --color-discord-warning: var(--warning); + --color-discord-error: var(--error); } -:root { - --background: oklch(1 0 0); - --foreground: oklch(0.145 0 0); - --card: oklch(1 0 0); - --card-foreground: oklch(0.145 0 0); - --popover: oklch(1 0 0); - --popover-foreground: oklch(0.145 0 0); - --primary: oklch(0.488 0.243 264.376); - --primary-foreground: oklch(0.985 0 0); - --secondary: oklch(0.97 0 0); - --secondary-foreground: oklch(0.205 0 0); - --muted: oklch(0.97 0 0); - --muted-foreground: oklch(0.556 0 0); - --accent: oklch(0.97 0 0); - --accent-foreground: oklch(0.205 0 0); - --destructive: oklch(0.577 0.245 27.325); - --border: oklch(0.922 0 0); - --input: oklch(0.922 0 0); - --ring: oklch(0.488 0.243 264.376); - --chart-1: oklch(0.87 0 0); - --chart-2: oklch(0.556 0 0); - --chart-3: oklch(0.439 0 0); - --chart-4: oklch(0.371 0 0); - --chart-5: oklch(0.269 0 0); - --radius: 0.5rem; - --sidebar: oklch(0.985 0 0); - --sidebar-foreground: oklch(0.145 0 0); - --sidebar-primary: oklch(0.488 0.243 264.376); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.97 0 0); - --sidebar-accent-foreground: oklch(0.205 0 0); - --sidebar-border: oklch(0.922 0 0); - --sidebar-ring: oklch(0.488 0.243 264.376); - - /* AI Studio room palette — light */ - --room-bg: oklch(0.995 0 0); - --room-sidebar: oklch(0.99 0 0); - --room-channel-hover: oklch(0.97 0 0); - --room-channel-active: oklch(0.55 0.18 253 / 8%); - --room-mention-badge: oklch(0.55 0.18 253); - --room-accent: oklch(0.55 0.18 253); - --room-accent-hover: oklch(0.52 0.19 253); - --room-online: oklch(0.63 0.19 158); - --room-offline: oklch(0.62 0 0 / 35%); - --room-away: oklch(0.75 0.17 80); - --room-text: oklch(0.145 0 0); - --room-text-secondary: oklch(0.25 0 0); - --room-text-muted: oklch(0.50 0 0); - --room-text-subtle: oklch(0.68 0 0); - --room-border: oklch(0.91 0 0); - --room-hover: oklch(0.97 0 0); -} - -.dark { - --background: oklch(0.145 0 0); - --foreground: oklch(0.985 0 0); - --card: oklch(0.18 0 0); - --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.18 0 0); - --popover-foreground: oklch(0.985 0 0); - --primary: oklch(0.488 0.243 264.376); - --primary-foreground: oklch(0.985 0 0); - --secondary: oklch(0.269 0 0); - --secondary-foreground: oklch(0.985 0 0); - --muted: oklch(0.22 0 0); - --muted-foreground: oklch(0.65 0 0); - --accent: oklch(0.269 0 0); - --accent-foreground: oklch(0.985 0 0); - --destructive: oklch(0.704 0.191 22.216); - --border: oklch(1 0 0 / 8%); - --input: oklch(1 0 0 / 10%); - --ring: oklch(0.488 0.243 264.376); - --chart-1: oklch(0.87 0 0); - --chart-2: oklch(0.556 0 0); - --chart-3: oklch(0.439 0 0); - --chart-4: oklch(0.371 0 0); - --chart-5: oklch(0.269 0 0); - --sidebar: oklch(0.13 0 0); - --sidebar-foreground: oklch(0.985 0 0); - --sidebar-primary: oklch(0.488 0.243 264.376); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.22 0 0); - --sidebar-accent-foreground: oklch(0.985 0 0); - --sidebar-border: oklch(1 0 0 / 8%); - --sidebar-ring: oklch(0.488 0.243 264.376); - - /* Discord dark theme */ - --discord-bg: oklch(0.145 0 0); - --discord-sidebar: oklch(0.13 0 0); - --discord-channel-hover: oklch(0.2 0 0); - /* AI Studio room palette — dark */ - --room-bg: oklch(0.11 0 0); - --room-sidebar: oklch(0.10 0 0); - --room-channel-hover: oklch(0.16 0 0); - --room-channel-active: oklch(0.58 0.18 253 / 12%); - --room-mention-badge: oklch(0.58 0.18 253); - --room-accent: oklch(0.58 0.18 253); - --room-accent-hover: oklch(0.65 0.20 253); - --room-online: oklch(0.65 0.17 158); - --room-offline: oklch(0.50 0 0 / 35%); - --room-away: oklch(0.72 0.16 80); - --room-text: oklch(0.985 0 0); - --room-text-secondary: oklch(0.985 0 0 / 80%); - --room-text-muted: oklch(0.985 0 0 / 58%); - --room-text-subtle: oklch(0.985 0 0 / 40%); - --room-border: oklch(0 0 0 / 18%); - --room-hover: oklch(0.16 0 0); -} +/* ── Layer 4: Base styles ──────────────────────────────────────────────────── */ @layer base { * { @apply border-border outline-ring/50; - } + } body { - @apply bg-background text-foreground; - } + @apply bg-background text-foreground antialiased; + } html { @apply font-sans; - } + } } /* Placeholder support for contenteditable MentionInput */ [contenteditable][data-placeholder]:empty::before { content: attr(data-placeholder); - color: var(--muted-foreground); + color: var(--fg-muted); pointer-events: none; } -/* ── Discord layout ──────────────────────────────────────────────────────── */ +/* ══════════════════════════════════════════════════════════════════════════════ + COMPONENT STYLES — all reference semantic tokens via CSS vars or Tailwind + ══════════════════════════════════════════════════════════════════════════════ */ + +/* ── Discord layout ─────────────────────────────────────────────────────────── */ + .discord-layout { display: flex; height: 100%; width: 100%; overflow: hidden; background: var(--room-bg); - color: var(--foreground); + color: var(--room-text); } -/* Server sidebar (left icon strip) */ .discord-server-sidebar { display: flex; flex-direction: column; @@ -232,8 +363,8 @@ align-items: center; justify-content: center; cursor: pointer; - background: var(--room-channel-hover); - color: var(--foreground); + background: var(--room-hover); + color: var(--room-sidebar-fg); transition: border-radius 200ms ease, background 200ms ease; font-weight: 700; font-size: 14px; @@ -242,16 +373,11 @@ user-select: none; } -.discord-server-icon:hover { - border-radius: 16px; - background: var(--room-accent); - color: var(--primary-foreground); -} - +.discord-server-icon:hover, .discord-server-icon.active { border-radius: 16px; background: var(--room-accent); - color: var(--primary-foreground); + color: var(--room-mention-fg); } .discord-server-icon .home-icon { @@ -259,11 +385,10 @@ height: 28px; } -/* Channel sidebar */ .discord-channel-sidebar { display: flex; flex-direction: column; - width: 260px; + width: 240px; background: var(--room-sidebar); border-right: 1px solid var(--room-border); flex-shrink: 0; @@ -276,14 +401,13 @@ height: 48px; padding: 0 16px; border-bottom: 1px solid var(--room-border); - box-shadow: 0 1px 0 var(--room-border); flex-shrink: 0; } .discord-channel-header-title { font-weight: 600; font-size: 15px; - color: var(--foreground); + color: var(--room-sidebar-fg); flex: 1; } @@ -294,20 +418,14 @@ padding: 8px 8px 8px 0; } -.discord-channel-list::-webkit-scrollbar { - width: 4px; -} -.discord-channel-list::-webkit-scrollbar-track { - background: transparent; -} +.discord-channel-list::-webkit-scrollbar { width: 4px; } +.discord-channel-list::-webkit-scrollbar-track { background: transparent; } .discord-channel-list::-webkit-scrollbar-thumb { background: var(--room-border); border-radius: 4px; } -.discord-channel-category { - margin-bottom: 4px; -} +.discord-channel-category { margin-bottom: 4px; } .discord-channel-category-header { display: flex; @@ -326,17 +444,9 @@ transition: color 150ms; } -.discord-channel-category-header:hover { - color: var(--foreground); -} - -.discord-channel-category-header svg { - transition: transform 150ms; -} - -.discord-channel-category-header.collapsed svg { - transform: rotate(-90deg); -} +.discord-channel-category-header:hover { color: var(--room-sidebar-fg); } +.discord-channel-category-header svg { transition: transform 150ms; } +.discord-channel-category-header.collapsed svg { transform: rotate(-90deg); } .discord-channel-item { display: flex; @@ -360,7 +470,7 @@ .discord-channel-item.active { background: var(--room-channel-active); - color: var(--foreground); + color: var(--room-sidebar-fg); } .discord-channel-item.active::before { @@ -398,8 +508,8 @@ height: 18px; padding: 0 5px; border-radius: 9px; - background: var(--room-mention-badge); - color: var(--primary-foreground); + background: var(--room-mention-bg); + color: var(--room-mention-fg); font-size: 11px; font-weight: 700; line-height: 1; @@ -421,12 +531,9 @@ text-align: left; width: 100%; } +.discord-add-channel-btn:hover { color: var(--room-text); } -.discord-add-channel-btn:hover { - color: var(--room-text); -} - -/* Member list sidebar */ +/* Member sidebar */ .discord-member-sidebar { display: flex; flex-direction: column; @@ -470,15 +577,9 @@ transition: background 100ms; user-select: none; } +.discord-member-item:hover { background: var(--room-hover); } -.discord-member-item:hover { - background: var(--room-hover); -} - -.discord-member-avatar-wrap { - position: relative; - flex-shrink: 0; -} +.discord-member-avatar-wrap { position: relative; flex-shrink: 0; } .discord-member-status-dot { position: absolute; @@ -490,17 +591,12 @@ border: 2px solid var(--room-sidebar); } -.discord-member-status-dot.online { - background: var(--room-online); -} -.discord-member-status-dot.offline { - background: var(--room-offline); -} -.discord-member-status-dot.idle { - background: var(--room-away); -} +.discord-member-status-dot.online { background: var(--room-online); } +.discord-member-status-dot.offline { background: var(--room-offline); } +.discord-member-status-dot.idle { background: var(--room-away); } + +/* ── Message list ──────────────────────────────────────────────────────────── */ -/* ── Discord message bubbles ─────────────────────────────────────────────── */ .discord-message-list { flex: 1; overflow-y: auto; @@ -508,12 +604,8 @@ padding: 0 16px 8px; } -.discord-message-list::-webkit-scrollbar { - width: 8px; -} -.discord-message-list::-webkit-scrollbar-track { - background: transparent; -} +.discord-message-list::-webkit-scrollbar { width: 8px; } +.discord-message-list::-webkit-scrollbar-track { background: transparent; } .discord-message-list::-webkit-scrollbar-thumb { background: var(--room-border); border-radius: 4px; @@ -521,10 +613,7 @@ background-clip: padding-box; } -.discord-message-group { - display: flex; - flex-direction: column; -} +.discord-message-group { display: flex; flex-direction: column; } .discord-message-row { display: flex; @@ -536,9 +625,7 @@ position: relative; } -.discord-message-row:hover .discord-message-actions { - opacity: 1; -} +.discord-message-row:hover .discord-message-actions { opacity: 1; } .discord-message-avatar { width: 40px; @@ -549,15 +636,8 @@ margin-top: -2px; } -.discord-message-body { - flex: 1; - min-width: 0; -} - -.discord-message-avatar-spacer { - width: 40px; - flex-shrink: 0; -} +.discord-message-body { flex: 1; min-width: 0; } +.discord-message-avatar-spacer { width: 40px; flex-shrink: 0; } .discord-message-header { display: flex; @@ -569,14 +649,11 @@ .discord-message-author { font-size: 15px; font-weight: 600; - color: var(--foreground); + color: var(--room-text); line-height: 1.2; cursor: pointer; } - -.discord-message-author:hover { - text-decoration: underline; -} +.discord-message-author:hover { text-decoration: underline; } .discord-message-time { font-size: 11px; @@ -593,8 +670,8 @@ } .discord-message-content .mention { - background: oklch(0.488 0.243 264.376 / 25%); - color: var(--room-mention-text); + background: var(--accent-subtle); + color: var(--accent); padding: 0 4px; border-radius: 3px; font-weight: 500; @@ -607,13 +684,13 @@ display: flex; align-items: center; gap: 2px; - background: var(--card); + background: var(--surface-2); border: 1px solid var(--border); border-radius: 4px; padding: 2px; opacity: 0; transition: opacity 150ms; - box-shadow: 0 1px 4px var(--room-border); + box-shadow: var(--shadow-md); } .discord-msg-action-btn { @@ -629,7 +706,6 @@ background: none; border: none; } - .discord-msg-action-btn:hover { background: var(--room-hover); color: var(--room-text); @@ -668,10 +744,7 @@ color: var(--room-text-subtle); } -.discord-typing-dots { - display: flex; - gap: 3px; -} +.discord-typing-dots { display: flex; gap: 3px; } .discord-typing-dots span { width: 6px; @@ -686,10 +759,10 @@ @keyframes typing-bounce { 0%, 60%, 100% { transform: translateY(0); } - 30% { transform: translateY(-4px); } + 30% { transform: translateY(-4px); } } -/* Status pill in header */ +/* WebSocket status */ .discord-ws-status { display: inline-flex; align-items: center; @@ -704,19 +777,19 @@ border-radius: 50%; } -.discord-ws-dot.connected { background: var(--room-online); } -.discord-ws-dot.connecting { +.discord-ws-dot.connected { background: var(--room-online); } +.discord-ws-dot.connecting { background: var(--room-away); animation: pulse 1.5s infinite; } -.discord-ws-dot.disconnected { background: oklch(0.63 0.21 25); } +.discord-ws-dot.disconnected { background: var(--error); } @keyframes pulse { 0%, 100% { opacity: 1; } - 50% { opacity: 0.4; } + 50% { opacity: 0.4; } } -/* Chat input area */ +/* Chat input */ .discord-chat-input-area { display: flex; flex-direction: column; @@ -754,11 +827,7 @@ overflow-y: auto; font-family: inherit; } - -.discord-input-field::placeholder { - color: var(--room-text-muted); -} - +.discord-input-field::placeholder { color: var(--room-text-muted); } .discord-input-field::-webkit-scrollbar { width: 0; } .discord-send-btn { @@ -769,17 +838,16 @@ height: 36px; border-radius: 6px; background: var(--room-accent); - color: var(--primary-foreground); + color: var(--room-mention-fg); cursor: pointer; border: none; transition: background 150ms; flex-shrink: 0; } - -.discord-send-btn:hover { background: var(--room-accent-hover); } +.discord-send-btn:hover { background: var(--room-accent-hover); } .discord-send-btn:disabled { opacity: 0.5; cursor: default; } -/* Reply preview in input */ +/* Reply preview */ .discord-reply-preview { display: flex; align-items: center; @@ -793,11 +861,7 @@ color: var(--room-text-muted); } -.discord-reply-preview-author { - font-weight: 600; - color: var(--room-text-secondary); -} - +.discord-reply-preview-author { font-weight: 600; color: var(--room-text-secondary); } .discord-reply-preview-text { flex: 1; overflow: hidden; @@ -805,7 +869,7 @@ white-space: nowrap; } -/* Streaming message cursor */ +/* Streaming cursor */ .discord-streaming-cursor { display: inline-block; width: 2px; @@ -818,14 +882,14 @@ @keyframes cursor-blink { 0%, 100% { opacity: 1; } - 50% { opacity: 0; } + 50% { opacity: 0; } } -/* Role color dot */ +/* Role dot */ .discord-role-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex-shrink: 0; -} \ No newline at end of file +}