diff --git a/.gitignore b/.gitignore index b2c1de9a2..425092add 100644 --- a/.gitignore +++ b/.gitignore @@ -69,3 +69,9 @@ USER.md # local tooling .serena/ + +# AI Powers (external project resources) +.ai-powers/ + +# UI Redesign workspace +.redesign/ diff --git a/ui/index.html b/ui/index.html index 910cb59f8..64677ef2c 100644 --- a/ui/index.html +++ b/ui/index.html @@ -5,7 +5,13 @@ Clawdbot Control + + + + + + diff --git a/ui/src/styles.css b/ui/src/styles.css index 16b327f3a..5601f3510 100644 --- a/ui/src/styles.css +++ b/ui/src/styles.css @@ -1,4 +1,5 @@ @import "./styles/base.css"; +@import "./styles/color-themes.css"; @import "./styles/layout.css"; @import "./styles/layout.mobile.css"; @import "./styles/components.css"; diff --git a/ui/src/styles/base.css b/ui/src/styles/base.css index 7baab70a2..641ab6a17 100644 --- a/ui/src/styles/base.css +++ b/ui/src/styles/base.css @@ -1,192 +1,350 @@ -@import url("https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"); +/** + * Clawdbot UI - Base Styles + * Design System: Tangerine Theme + * Font: Poppins + * Modes: Light & Dark + * Target: WCAG AAA compliance + */ -:root { - /* Background - Warmer dark with depth */ - --bg: #12141a; - --bg-accent: #14161d; - --bg-elevated: #1a1d25; - --bg-hover: #262a35; - --bg-muted: #262a35; - - /* Card / Surface - More contrast between levels */ - --card: #181b22; +:root, +:root[data-color-theme="tangerine"] { + /* ============================================ + COLOR SYSTEM - DARK MODE (Default) + ============================================ */ + + /* Background Colors - Muted Dark Theme */ + --color-bg-primary: #1A1816; + --color-bg-secondary: #242220; + --color-bg-tertiary: #2E2C28; + --color-bg-elevated: #3A3834; + + /* Legacy aliases for compatibility */ + --bg: var(--color-bg-primary); + --bg-accent: var(--color-bg-secondary); + --bg-elevated: var(--color-bg-elevated); + --bg-hover: var(--color-bg-tertiary); + --bg-muted: var(--color-bg-tertiary); + --bg-content: var(--color-bg-secondary); + + /* Card / Surface */ + --card: var(--color-bg-secondary); --card-foreground: #f4f4f5; - --card-highlight: rgba(255, 255, 255, 0.05); - --popover: #181b22; + --card-highlight: rgba(255, 159, 64, 0.03); + --popover: var(--color-bg-secondary); --popover-foreground: #f4f4f5; - + /* Panel */ - --panel: #12141a; - --panel-strong: #1a1d25; - --panel-hover: #262a35; - --chrome: rgba(18, 20, 26, 0.95); - --chrome-strong: rgba(18, 20, 26, 0.98); - - /* Text - Slightly warmer */ - --text: #e4e4e7; - --text-strong: #fafafa; - --chat-text: #e4e4e7; - --muted: #71717a; - --muted-strong: #52525b; - --muted-foreground: #71717a; - - /* Border - Subtle but defined */ - --border: #27272a; - --border-strong: #3f3f46; - --border-hover: #52525b; - --input: #27272a; - --ring: #ff5c5c; - - /* Accent - Punchy signature red */ - --accent: #ff5c5c; - --accent-hover: #ff7070; - --accent-muted: #ff5c5c; - --accent-subtle: rgba(255, 92, 92, 0.15); - --accent-foreground: #fafafa; - --accent-glow: rgba(255, 92, 92, 0.25); - --primary: #ff5c5c; + --panel: var(--color-bg-primary); + --panel-strong: var(--color-bg-elevated); + --panel-hover: var(--color-bg-tertiary); + --chrome: rgba(26, 24, 22, 0.95); + --chrome-strong: rgba(26, 24, 22, 0.98); + + /* Text Colors - High Contrast */ + --color-text-primary: #FFFFFF; + --color-text-secondary: #E0E0E0; + --color-text-tertiary: #B0B0B0; + --color-text-disabled: #666666; + --color-text-inverse: #1A1A1A; + + /* Legacy aliases */ + --text: var(--color-text-secondary); + --text-strong: var(--color-text-primary); + --chat-text: var(--color-text-secondary); + --muted: var(--color-text-tertiary); + --muted-strong: #71717a; + --muted-foreground: var(--color-text-tertiary); + + /* Accent Colors - Tangerine Theme */ + --color-accent-primary: #FF9F40; + --color-accent-primary-hover: #FF8C2E; + --color-accent-primary-active: #FF7A1C; + --color-accent-secondary: #FFB366; + + /* Legacy aliases */ + --accent: var(--color-accent-primary); + --accent-hover: var(--color-accent-primary-hover); + --accent-muted: var(--color-accent-primary); + --accent-subtle: rgba(255, 159, 64, 0.15); + --accent-foreground: var(--color-text-inverse); + --accent-glow: rgba(255, 159, 64, 0.25); + --primary: var(--color-accent-primary); --primary-foreground: #ffffff; - - /* Secondary - Teal accent for variety */ - --secondary: #1e2028; + + /* Secondary Accent - Teal */ + --secondary: var(--color-bg-secondary); --secondary-foreground: #f4f4f5; --accent-2: #14b8a6; --accent-2-muted: rgba(20, 184, 166, 0.7); --accent-2-subtle: rgba(20, 184, 166, 0.15); - - /* Semantic - More saturated */ - --ok: #22c55e; - --ok-muted: rgba(34, 197, 94, 0.75); - --ok-subtle: rgba(34, 197, 94, 0.12); - --destructive: #ef4444; + + /* Semantic Colors - Dark Mode */ + --color-accent-success: #4CAF50; + --color-accent-success-hover: #45A049; + --color-accent-error: #FF5252; + --color-accent-error-hover: #FF3838; + --color-accent-warning: #FFB300; + --color-accent-warning-hover: #FFA000; + --color-accent-info: #29B6F6; + --color-accent-info-hover: #039BE5; + + /* Legacy aliases */ + --ok: var(--color-accent-success); + --ok-muted: rgba(76, 175, 80, 0.75); + --ok-subtle: rgba(76, 175, 80, 0.12); + --destructive: var(--color-accent-error); --destructive-foreground: #fafafa; - --warn: #f59e0b; - --warn-muted: rgba(245, 158, 11, 0.75); - --warn-subtle: rgba(245, 158, 11, 0.12); - --danger: #ef4444; - --danger-muted: rgba(239, 68, 68, 0.75); - --danger-subtle: rgba(239, 68, 68, 0.12); - --info: #3b82f6; - - /* Focus - With glow */ - --focus: rgba(255, 92, 92, 0.25); + --warn: var(--color-accent-warning); + --warn-muted: rgba(255, 179, 0, 0.75); + --warn-subtle: rgba(255, 179, 0, 0.12); + --danger: var(--color-accent-error); + --danger-muted: rgba(255, 82, 82, 0.75); + --danger-subtle: rgba(255, 82, 82, 0.12); + --info: var(--color-accent-info); + + /* Border Colors - Dark Mode */ + --color-border-subtle: rgba(255, 159, 64, 0.1); + --color-border-default: rgba(255, 159, 64, 0.2); + --color-border-strong: rgba(255, 159, 64, 0.3); + + /* Legacy aliases */ + --border: #27272a; + --border-strong: #3f3f46; + --border-hover: #52525b; + --input: #27272a; + --ring: var(--color-accent-primary); + + /* Focus Colors */ + --color-focus-ring: var(--color-accent-primary); + --color-focus-ring-error: var(--color-accent-error); + --focus: rgba(255, 159, 64, 0.25); --focus-ring: 0 0 0 2px var(--bg), 0 0 0 4px var(--ring); --focus-glow: 0 0 0 2px var(--bg), 0 0 0 4px var(--ring), 0 0 20px var(--accent-glow); - + + /* Glassmorphism - Dark Mode */ + --color-glass-bg: rgba(255, 159, 64, 0.05); + --color-glass-border: rgba(255, 159, 64, 0.15); + --glass-blur: 20px; + + /* Chat Bubbles - Dark Mode */ + --color-chat-user: var(--color-accent-primary); + --color-chat-assistant: var(--color-bg-tertiary); + --color-chat-system: var(--color-bg-elevated); + /* Grid */ --grid-line: rgba(255, 255, 255, 0.04); - + /* Theme transition */ --theme-switch-x: 50%; --theme-switch-y: 50%; - - /* Typography - Space Grotesk for personality */ - --mono: "JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, monospace; - --font-body: "Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; - --font-display: "Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; - - /* Shadows - Richer with subtle color */ - --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2); - --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.25), 0 0 0 1px rgba(255, 255, 255, 0.03); - --shadow-lg: 0 12px 28px rgba(0, 0, 0, 0.35), 0 0 0 1px rgba(255, 255, 255, 0.03); - --shadow-xl: 0 24px 48px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.03); - --shadow-glow: 0 0 30px var(--accent-glow); - - /* Radii - Slightly larger for friendlier feel */ + + /* ============================================ + TYPOGRAPHY + ============================================ */ + + /* Font Families */ + --font-family-base: 'Poppins', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + --font-family-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, monospace; + + /* Legacy aliases */ + --mono: var(--font-family-mono); + --font-body: var(--font-family-base); + --font-display: var(--font-family-base); + + /* Font Sizes */ + --font-size-xs: 0.75rem; + --font-size-sm: 0.875rem; + --font-size-base: 1rem; + --font-size-md: 1.125rem; + --font-size-lg: 1.25rem; + --font-size-xl: 1.5rem; + --font-size-2xl: 1.875rem; + --font-size-3xl: 2.25rem; + + /* Font Weights */ + --font-weight-light: 300; + --font-weight-normal: 400; + --font-weight-medium: 500; + --font-weight-semibold: 600; + --font-weight-bold: 700; + + /* Spacing */ + --spacing-0: 0; + --spacing-1: 0.25rem; + --spacing-2: 0.5rem; + --spacing-3: 0.75rem; + --spacing-4: 1rem; + --spacing-5: 1.25rem; + --spacing-6: 1.5rem; + --spacing-8: 2rem; + --spacing-10: 2.5rem; + --spacing-12: 3rem; + + /* Touch Targets */ + --size-touch-min: 44px; + + /* Border Radius */ --radius-sm: 6px; --radius-md: 8px; --radius-lg: 12px; --radius-xl: 16px; --radius-full: 9999px; --radius: 8px; - - /* Transitions - Snappy but smooth */ - --ease-out: cubic-bezier(0.16, 1, 0.3, 1); - --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); - --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1); + + /* Shadows */ + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.25), 0 0 0 1px rgba(255, 255, 255, 0.03); + --shadow-lg: 0 12px 28px rgba(0, 0, 0, 0.35), 0 0 0 1px rgba(255, 255, 255, 0.03); + --shadow-xl: 0 24px 48px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.03); + --shadow-glow: 0 0 30px var(--accent-glow); + --shadow-focus: 0 0 0 3px var(--color-focus-ring); + + /* Transitions */ --duration-fast: 120ms; --duration-normal: 200ms; --duration-slow: 350ms; - + --ease-out: cubic-bezier(0.16, 1, 0.3, 1); + --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); + --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1); + color-scheme: dark; } -/* Light theme - Clean with subtle warmth */ -:root[data-theme="light"] { - --bg: #fafafa; - --bg-accent: #f5f5f5; - --bg-elevated: #ffffff; - --bg-hover: #f0f0f0; - --bg-muted: #f0f0f0; - --bg-content: #f5f5f5; +/* ============================================ + LIGHT MODE THEME + ============================================ */ - --card: #ffffff; +:root[data-theme="light"], +:root[data-color-theme="tangerine"][data-theme="light"] { + /* Background Colors - Light Mode (Muted) */ + --color-bg-primary: #FAFAF9; + --color-bg-secondary: #F5F4F2; + --color-bg-tertiary: #EFEEE9; + --color-bg-elevated: #FFFFFF; + + /* Legacy aliases */ + --bg: var(--color-bg-primary); + --bg-accent: var(--color-bg-secondary); + --bg-elevated: var(--color-bg-elevated); + --bg-hover: var(--color-bg-tertiary); + --bg-muted: var(--color-bg-tertiary); + --bg-content: var(--color-bg-secondary); + + --card: var(--color-bg-elevated); --card-foreground: #18181b; - --card-highlight: rgba(0, 0, 0, 0.03); - --popover: #ffffff; + --card-highlight: rgba(255, 140, 66, 0.03); + --popover: var(--color-bg-elevated); --popover-foreground: #18181b; - - --panel: #fafafa; - --panel-strong: #f5f5f5; - --panel-hover: #ebebeb; - --chrome: rgba(250, 250, 250, 0.95); - --chrome-strong: rgba(250, 250, 250, 0.98); - + + --panel: var(--color-bg-primary); + --panel-strong: var(--color-bg-secondary); + --panel-hover: var(--color-bg-tertiary); + --chrome: rgba(250, 250, 249, 0.95); + --chrome-strong: rgba(250, 250, 249, 0.98); + + /* Text Colors - Light Mode */ + --color-text-primary: #1A1410; + --color-text-secondary: #4A3F35; + --color-text-tertiary: #6A5A4A; + --color-text-disabled: #9A8A7A; + --color-text-inverse: #FFFFFF; + + /* Legacy aliases */ --text: #3f3f46; - --text-strong: #18181b; + --text-strong: var(--color-text-primary); --chat-text: #3f3f46; --muted: #71717a; --muted-strong: #52525b; --muted-foreground: #71717a; - - --border: #e4e4e7; - --border-strong: #d4d4d8; - --border-hover: #a1a1aa; - --input: #e4e4e7; - - --accent: #dc2626; - --accent-hover: #ef4444; - --accent-muted: #dc2626; - --accent-subtle: rgba(220, 38, 38, 0.12); + + /* Accent Colors - Light Mode */ + --color-accent-primary: #FF8C42; + --color-accent-primary-hover: #FF7A2E; + --color-accent-primary-active: #E5681A; + --color-accent-secondary: #FFA366; + + /* Legacy aliases */ + --accent: var(--color-accent-primary); + --accent-hover: var(--color-accent-primary-hover); + --accent-muted: var(--color-accent-primary); + --accent-subtle: rgba(255, 140, 66, 0.12); --accent-foreground: #ffffff; - --accent-glow: rgba(220, 38, 38, 0.15); - --primary: #dc2626; + --accent-glow: rgba(255, 140, 66, 0.15); + --primary: var(--color-accent-primary); --primary-foreground: #ffffff; - - --secondary: #f4f4f5; + + --secondary: var(--color-bg-secondary); --secondary-foreground: #3f3f46; --accent-2: #0d9488; --accent-2-muted: rgba(13, 148, 136, 0.75); --accent-2-subtle: rgba(13, 148, 136, 0.12); - - --ok: #16a34a; - --ok-muted: rgba(22, 163, 74, 0.75); - --ok-subtle: rgba(22, 163, 74, 0.1); - --destructive: #dc2626; + + /* Semantic Colors - Light Mode */ + --color-accent-success: #2E7D32; + --color-accent-success-hover: #1B5E20; + --color-accent-error: #D32F2F; + --color-accent-error-hover: #C62828; + --color-accent-warning: #F57C00; + --color-accent-warning-hover: #E65100; + --color-accent-info: #0288D1; + --color-accent-info-hover: #01579B; + + /* Legacy aliases */ + --ok: var(--color-accent-success); + --ok-muted: rgba(46, 125, 50, 0.75); + --ok-subtle: rgba(46, 125, 50, 0.1); + --destructive: var(--color-accent-error); --destructive-foreground: #fafafa; - --warn: #d97706; - --warn-muted: rgba(217, 119, 6, 0.75); - --warn-subtle: rgba(217, 119, 6, 0.1); - --danger: #dc2626; - --danger-muted: rgba(220, 38, 38, 0.75); - --danger-subtle: rgba(220, 38, 38, 0.1); - --info: #2563eb; - - --focus: rgba(220, 38, 38, 0.2); + --warn: var(--color-accent-warning); + --warn-muted: rgba(245, 124, 0, 0.75); + --warn-subtle: rgba(245, 124, 0, 0.1); + --danger: var(--color-accent-error); + --danger-muted: rgba(211, 47, 47, 0.75); + --danger-subtle: rgba(211, 47, 47, 0.1); + --info: var(--color-accent-info); + + /* Border Colors - Light Mode */ + --color-border-subtle: rgba(255, 140, 66, 0.15); + --color-border-default: rgba(255, 140, 66, 0.25); + --color-border-strong: rgba(255, 140, 66, 0.4); + + /* Legacy aliases */ + --border: #e4e4e7; + --border-strong: #d4d4d8; + --border-hover: #a1a1aa; + --input: #e4e4e7; + + /* Focus Colors - Light Mode */ + --color-focus-ring: var(--color-accent-primary); + --color-focus-ring-error: var(--color-accent-error); + --focus: rgba(255, 140, 66, 0.2); --focus-glow: 0 0 0 2px var(--bg), 0 0 0 4px var(--ring), 0 0 16px var(--accent-glow); - + + /* Glassmorphism - Light Mode */ + --color-glass-bg: rgba(255, 255, 255, 0.7); + --color-glass-border: rgba(255, 140, 66, 0.2); + + /* Chat Bubbles - Light Mode */ + --color-chat-user: var(--color-accent-primary); + --color-chat-assistant: var(--color-bg-secondary); + --color-chat-system: var(--color-bg-tertiary); + --grid-line: rgba(0, 0, 0, 0.05); - + /* Light shadows */ --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.06); --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08), 0 0 0 1px rgba(0, 0, 0, 0.04); --shadow-lg: 0 12px 28px rgba(0, 0, 0, 0.12), 0 0 0 1px rgba(0, 0, 0, 0.04); --shadow-xl: 0 24px 48px rgba(0, 0, 0, 0.15), 0 0 0 1px rgba(0, 0, 0, 0.04); --shadow-glow: 0 0 24px var(--accent-glow); - + color-scheme: light; } +/* ============================================ + BASE STYLES + ============================================ */ + * { box-sizing: border-box; } @@ -287,7 +445,7 @@ select { background: var(--border-strong); } -/* Animations - Polished with spring feel */ +/* Animations */ @keyframes rise { from { opacity: 0; @@ -350,14 +508,14 @@ select { @keyframes glow-pulse { 0%, 100% { - box-shadow: 0 0 0 rgba(255, 92, 92, 0); + box-shadow: 0 0 0 rgba(255, 159, 64, 0); } 50% { box-shadow: 0 0 20px var(--accent-glow); } } -/* Stagger animation delays for grouped elements */ +/* Stagger animation delays */ .stagger-1 { animation-delay: 0ms; } .stagger-2 { animation-delay: 50ms; } .stagger-3 { animation-delay: 100ms; } @@ -370,3 +528,26 @@ select { outline: none; box-shadow: var(--focus-ring); } + +/* Accessibility */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border-width: 0; +} diff --git a/ui/src/styles/color-themes.css b/ui/src/styles/color-themes.css new file mode 100644 index 000000000..34c10ecc8 --- /dev/null +++ b/ui/src/styles/color-themes.css @@ -0,0 +1,134 @@ +/* =========================================== + COLOR THEMES + Each theme has dark and light variants + =========================================== */ + +/* Default: Tangerine (already in base.css) */ +:root[data-color-theme="tangerine"] { + /* Already defined in base.css */ +} + +/* Ocean Theme - Blue tones */ +:root[data-color-theme="ocean"] { + --color-accent-primary: #0EA5E9; + --color-accent-primary-hover: #0284C7; + --color-accent-primary-active: #0369A1; + + --accent: var(--color-accent-primary); + --accent-hover: var(--color-accent-primary-hover); + --accent-subtle: rgba(14, 165, 233, 0.15); + --accent-glow: rgba(14, 165, 233, 0.25); + --primary: var(--color-accent-primary); + --ring: var(--color-accent-primary); +} + +:root[data-color-theme="ocean"][data-theme="light"] { + --color-accent-primary: #0284C7; + --color-accent-primary-hover: #0369A1; + --color-accent-primary-active: #075985; + + --accent: var(--color-accent-primary); + --accent-hover: var(--color-accent-primary-hover); + --accent-subtle: rgba(2, 132, 199, 0.12); + --accent-glow: rgba(2, 132, 199, 0.15); +} + +/* Forest Theme - Green tones */ +:root[data-color-theme="forest"] { + --color-accent-primary: #10B981; + --color-accent-primary-hover: #059669; + --color-accent-primary-active: #047857; + + --accent: var(--color-accent-primary); + --accent-hover: var(--color-accent-primary-hover); + --accent-subtle: rgba(16, 185, 129, 0.15); + --accent-glow: rgba(16, 185, 129, 0.25); + --primary: var(--color-accent-primary); + --ring: var(--color-accent-primary); +} + +:root[data-color-theme="forest"][data-theme="light"] { + --color-accent-primary: #059669; + --color-accent-primary-hover: #047857; + --color-accent-primary-active: #065F46; + + --accent: var(--color-accent-primary); + --accent-hover: var(--color-accent-primary-hover); + --accent-subtle: rgba(5, 150, 105, 0.12); + --accent-glow: rgba(5, 150, 105, 0.15); +} + +/* Sunset Theme - Pink/Rose tones */ +:root[data-color-theme="sunset"] { + --color-accent-primary: #F43F5E; + --color-accent-primary-hover: #E11D48; + --color-accent-primary-active: #BE123C; + + --accent: var(--color-accent-primary); + --accent-hover: var(--color-accent-primary-hover); + --accent-subtle: rgba(244, 63, 94, 0.15); + --accent-glow: rgba(244, 63, 94, 0.25); + --primary: var(--color-accent-primary); + --ring: var(--color-accent-primary); +} + +:root[data-color-theme="sunset"][data-theme="light"] { + --color-accent-primary: #E11D48; + --color-accent-primary-hover: #BE123C; + --color-accent-primary-active: #9F1239; + + --accent: var(--color-accent-primary); + --accent-hover: var(--color-accent-primary-hover); + --accent-subtle: rgba(225, 29, 72, 0.12); + --accent-glow: rgba(225, 29, 72, 0.15); +} + +/* Lavender Theme - Purple tones */ +:root[data-color-theme="lavender"] { + --color-accent-primary: #A78BFA; + --color-accent-primary-hover: #8B5CF6; + --color-accent-primary-active: #7C3AED; + + --accent: var(--color-accent-primary); + --accent-hover: var(--color-accent-primary-hover); + --accent-subtle: rgba(167, 139, 250, 0.15); + --accent-glow: rgba(167, 139, 250, 0.25); + --primary: var(--color-accent-primary); + --ring: var(--color-accent-primary); +} + +:root[data-color-theme="lavender"][data-theme="light"] { + --color-accent-primary: #8B5CF6; + --color-accent-primary-hover: #7C3AED; + --color-accent-primary-active: #6D28D9; + + --accent: var(--color-accent-primary); + --accent-hover: var(--color-accent-primary-hover); + --accent-subtle: rgba(139, 92, 246, 0.12); + --accent-glow: rgba(139, 92, 246, 0.15); +} + +/* Slate Theme - Gray/Neutral tones */ +:root[data-color-theme="slate"] { + --color-accent-primary: #64748B; + --color-accent-primary-hover: #475569; + --color-accent-primary-active: #334155; + + --accent: var(--color-accent-primary); + --accent-hover: var(--color-accent-primary-hover); + --accent-subtle: rgba(100, 116, 139, 0.15); + --accent-glow: rgba(100, 116, 139, 0.25); + --primary: var(--color-accent-primary); + --ring: var(--color-accent-primary); +} + +:root[data-color-theme="slate"][data-theme="light"] { + --color-accent-primary: #475569; + --color-accent-primary-hover: #334155; + --color-accent-primary-active: #1E293B; + + --accent: var(--color-accent-primary); + --accent-hover: var(--color-accent-primary-hover); + --accent-subtle: rgba(71, 85, 105, 0.12); + --accent-glow: rgba(71, 85, 105, 0.15); +} diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index a78e0ef0a..6e735b968 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -36,6 +36,41 @@ line-height: 1.5; } +/* Channel card layout - content grows, buttons at bottom */ +.card--channel { + display: flex; + flex-direction: column; + min-height: 400px; +} + +.card--channel .card-content { + flex: 1; + display: flex; + flex-direction: column; + gap: 16px; +} + +.card--channel .card-footer { + margin-top: auto; + padding-top: 16px; + border-top: 1px solid var(--border); +} + +.card--channel .card-errors { + margin-top: auto; + padding-top: 12px; +} + +.card--channel .card-actions { + margin-top: 12px; +} + +/* Config section spacing within channel cards */ +.card--channel .channel-config-section { + margin-top: auto; + padding-top: 20px; +} + /* =========================================== Stats - Bold values, subtle labels =========================================== */ @@ -774,6 +809,120 @@ text-decoration: underline; } +/* =========================================== + Log Level Filters - Custom styled checkboxes + =========================================== */ + +.log-level-filter { + display: inline-flex; + align-items: center; + gap: 8px; + cursor: pointer; + user-select: none; + padding: 6px 12px; + border-radius: var(--radius-md); + border: 1px solid var(--border); + background: var(--secondary); + transition: all 0.2s ease; +} + +.log-level-filter:hover { + border-color: var(--border-strong); + background: var(--bg-hover); +} + +.log-level-filter__checkbox { + width: 18px; + height: 18px; + border: 1px solid var(--input-border, #3E3B38); + border-radius: var(--radius-sm, 4px); + background: var(--input-bg, #1A1816); + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + position: relative; +} + +.log-level-filter__checkbox::after { + content: "✓"; + font-size: 14px; + font-weight: 700; + opacity: 0; + transform: scale(0.5); + transition: all 0.2s ease; +} + +.log-level-filter input:checked ~ .log-level-filter__checkbox::after { + opacity: 1; + transform: scale(1); +} + +.log-level-filter__label { + font-size: 13px; + font-weight: 500; + text-transform: capitalize; +} + +/* Log level specific colors */ +.log-level-filter--trace .log-level-filter__checkbox, +.log-level-filter--debug .log-level-filter__checkbox { + border-color: #71717A; +} + +.log-level-filter--trace input:checked ~ .log-level-filter__checkbox, +.log-level-filter--debug input:checked ~ .log-level-filter__checkbox { + background: #71717A; + border-color: #71717A; +} + +.log-level-filter--trace input:checked ~ .log-level-filter__checkbox::after, +.log-level-filter--debug input:checked ~ .log-level-filter__checkbox::after { + color: white; +} + +.log-level-filter--info .log-level-filter__checkbox { + border-color: #3B82F6; +} + +.log-level-filter--info input:checked ~ .log-level-filter__checkbox { + background: #3B82F6; + border-color: #3B82F6; +} + +.log-level-filter--info input:checked ~ .log-level-filter__checkbox::after { + color: white; +} + +.log-level-filter--warn .log-level-filter__checkbox { + border-color: #F59E0B; +} + +.log-level-filter--warn input:checked ~ .log-level-filter__checkbox { + background: #F59E0B; + border-color: #F59E0B; +} + +.log-level-filter--warn input:checked ~ .log-level-filter__checkbox::after { + color: white; +} + +.log-level-filter--error .log-level-filter__checkbox, +.log-level-filter--fatal .log-level-filter__checkbox { + border-color: #EF4444; +} + +.log-level-filter--error input:checked ~ .log-level-filter__checkbox, +.log-level-filter--fatal input:checked ~ .log-level-filter__checkbox { + background: #EF4444; + border-color: #EF4444; +} + +.log-level-filter--error input:checked ~ .log-level-filter__checkbox::after, +.log-level-filter--fatal input:checked ~ .log-level-filter__checkbox::after { + color: white; +} + /* =========================================== Log Stream =========================================== */ @@ -1035,8 +1184,8 @@ } :root[data-theme="light"] .chat-line.user .chat-bubble { - border-color: rgba(234, 88, 12, 0.2); - background: rgba(251, 146, 60, 0.12); + border-color: rgba(255, 140, 66, 0.25); + background: rgba(255, 140, 66, 0.12); } .chat-line.assistant .chat-bubble { @@ -1291,6 +1440,95 @@ background: var(--bg); } +/* =========================================== + Light Mode - Custom Components + =========================================== */ + +/* Log level filters */ +:root[data-theme="light"] .log-level-filter { + background: white; + border-color: var(--border); +} + +:root[data-theme="light"] .log-level-filter:hover { + background: var(--bg-hover); +} + +:root[data-theme="light"] .log-level-filter__checkbox { + background: white; + border-color: var(--input-border); +} + +/* Dropdown menu */ +:root[data-theme="light"] .dropdown-menu { + background: white; + border-color: var(--border); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); +} + +:root[data-theme="light"] .dropdown-menu__item:hover { + background: var(--bg-hover); +} + +/* UI Components */ +:root[data-theme="light"] ui-input input, +:root[data-theme="light"] ui-textarea textarea { + background: white; + border-color: var(--input-border); +} + +:root[data-theme="light"] ui-input input:focus, +:root[data-theme="light"] ui-textarea textarea:focus { + background: white; + border-color: var(--accent); +} + +:root[data-theme="light"] ui-checkbox .checkbox { + background: white; + border-color: var(--input-border); +} + +:root[data-theme="light"] ui-button.variant-secondary { + background: white; + border-color: var(--border); +} + +:root[data-theme="light"] ui-button.variant-secondary:hover { + background: var(--bg-hover); +} + +:root[data-theme="light"] ui-button.variant-outline { + background: transparent; + border-color: var(--border); +} + +:root[data-theme="light"] ui-button.variant-outline:hover { + background: var(--bg-hover); +} + +:root[data-theme="light"] ui-button.variant-ghost:hover { + background: var(--bg-hover); +} + +/* Card components */ +:root[data-theme="light"] ui-card .card { + background: white; + border-color: var(--border); +} + +:root[data-theme="light"] ui-card .card:hover { + border-color: var(--border-strong); +} + +:root[data-theme="light"] ui-card .card-header { + border-color: var(--border); +} + +:root[data-theme="light"] ui-card .card-footer { + background: var(--bg-hover); + border-color: var(--border); +} + .chat-stamp { font-size: 11px; color: var(--muted); diff --git a/ui/src/styles/config.css b/ui/src/styles/config.css index 7d96ac13f..d023abe67 100644 --- a/ui/src/styles/config.css +++ b/ui/src/styles/config.css @@ -613,36 +613,38 @@ .cfg-fields { display: grid; - gap: 22px; + gap: 16px; } .cfg-field { display: grid; - gap: 8px; + gap: 6px; } .cfg-field--error { - padding: 14px; + padding: 12px 14px; border-radius: var(--radius-md); - background: var(--danger-subtle); - border: 1px solid rgba(239, 68, 68, 0.3); + background: rgba(239, 68, 68, 0.08); + border: 1px solid rgba(239, 68, 68, 0.25); } .cfg-field__label { font-size: 13px; - font-weight: 600; + font-weight: 500; color: var(--text); + letter-spacing: -0.01em; } .cfg-field__help { font-size: 12px; color: var(--muted); - line-height: 1.45; + line-height: 1.5; } .cfg-field__error { font-size: 12px; color: var(--danger); + font-weight: 500; } /* Text Input */ @@ -651,66 +653,7 @@ gap: 10px; } -.cfg-input { - flex: 1; - padding: 11px 14px; - border: 1px solid var(--border-strong); - border-radius: var(--radius-md); - background: var(--bg-accent); - font-size: 14px; - outline: none; - transition: - border-color var(--duration-fast) ease, - box-shadow var(--duration-fast) ease, - background var(--duration-fast) ease; -} - -.cfg-input::placeholder { - color: var(--muted); - opacity: 0.7; -} - -.cfg-input:focus { - border-color: var(--accent); - box-shadow: var(--focus-ring); - background: var(--bg-hover); -} - -:root[data-theme="light"] .cfg-input { - background: white; -} - -:root[data-theme="light"] .cfg-input:focus { - background: white; -} - -.cfg-input--sm { - padding: 9px 12px; - font-size: 13px; -} - -.cfg-input__reset { - padding: 10px 14px; - border: 1px solid var(--border); - border-radius: var(--radius-md); - background: var(--bg-elevated); - color: var(--muted); - font-size: 14px; - cursor: pointer; - transition: - background var(--duration-fast) ease, - color var(--duration-fast) ease; -} - -.cfg-input__reset:hover:not(:disabled) { - background: var(--bg-hover); - color: var(--text); -} - -.cfg-input__reset:disabled { - opacity: 0.5; - cursor: not-allowed; -} +/* Textarea */ /* Textarea */ .cfg-textarea { @@ -743,135 +686,11 @@ font-size: 12px; } -/* Number Input */ -.cfg-number { - display: inline-flex; - border: 1px solid var(--border-strong); - border-radius: var(--radius-md); - overflow: hidden; - background: var(--bg-accent); -} -:root[data-theme="light"] .cfg-number { - background: white; -} -.cfg-number__btn { - width: 44px; - border: none; - background: var(--bg-elevated); - color: var(--text); - font-size: 18px; - font-weight: 300; - cursor: pointer; - transition: background var(--duration-fast) ease; -} -.cfg-number__btn:hover:not(:disabled) { - background: var(--bg-hover); -} -.cfg-number__btn:disabled { - opacity: 0.4; - cursor: not-allowed; -} -:root[data-theme="light"] .cfg-number__btn { - background: var(--bg-hover); -} - -:root[data-theme="light"] .cfg-number__btn:hover:not(:disabled) { - background: var(--border); -} - -.cfg-number__input { - width: 85px; - padding: 11px; - border: none; - border-left: 1px solid var(--border); - border-right: 1px solid var(--border); - background: transparent; - font-size: 14px; - text-align: center; - outline: none; - -moz-appearance: textfield; -} - -.cfg-number__input::-webkit-outer-spin-button, -.cfg-number__input::-webkit-inner-spin-button { - -webkit-appearance: none; - margin: 0; -} - -/* Select */ -.cfg-select { - padding: 11px 40px 11px 14px; - border: 1px solid var(--border-strong); - border-radius: var(--radius-md); - background-color: var(--bg-accent); - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%23888' 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 12px center; - font-size: 14px; - cursor: pointer; - outline: none; - appearance: none; - transition: - border-color var(--duration-fast) ease, - box-shadow var(--duration-fast) ease; -} - -.cfg-select:focus { - border-color: var(--accent); - box-shadow: var(--focus-ring); -} - -:root[data-theme="light"] .cfg-select { - background-color: white; -} - -/* Segmented Control */ -.cfg-segmented { - display: inline-flex; - padding: 4px; - border: 1px solid var(--border); - border-radius: var(--radius-md); - background: var(--bg-accent); -} - -:root[data-theme="light"] .cfg-segmented { - background: var(--bg-hover); -} - -.cfg-segmented__btn { - padding: 9px 18px; - border: none; - border-radius: var(--radius-sm); - background: transparent; - color: var(--muted); - font-size: 13px; - font-weight: 500; - cursor: pointer; - transition: - background var(--duration-fast) ease, - color var(--duration-fast) ease, - box-shadow var(--duration-fast) ease; -} - -.cfg-segmented__btn:hover:not(:disabled):not(.active) { - color: var(--text); -} - -.cfg-segmented__btn.active { - background: var(--accent); - color: white; - box-shadow: var(--shadow-sm); -} - -.cfg-segmented__btn:disabled { - opacity: 0.5; - cursor: not-allowed; -} /* Toggle Row */ .cfg-toggle-row { @@ -879,28 +698,31 @@ align-items: center; justify-content: space-between; gap: 18px; - padding: 16px 18px; + padding: 14px 16px; border: 1px solid var(--border); - border-radius: var(--radius-lg); - background: var(--bg-accent); + border-radius: var(--radius-md); + background: var(--card); cursor: pointer; transition: background var(--duration-fast) ease, - border-color var(--duration-fast) ease; + border-color var(--duration-fast) ease, + box-shadow var(--duration-fast) ease; + box-shadow: inset 0 1px 0 var(--card-highlight); } .cfg-toggle-row:hover:not(.disabled) { background: var(--bg-hover); border-color: var(--border-strong); + box-shadow: var(--shadow-sm), inset 0 1px 0 var(--card-highlight); } .cfg-toggle-row.disabled { - opacity: 0.55; + opacity: 0.5; cursor: not-allowed; } :root[data-theme="light"] .cfg-toggle-row { - background: white; + background: var(--card); } :root[data-theme="light"] .cfg-toggle-row:hover:not(.disabled) { @@ -914,77 +736,21 @@ .cfg-toggle-row__label { display: block; - font-size: 14px; + font-size: 13px; font-weight: 500; color: var(--text); + letter-spacing: -0.01em; } .cfg-toggle-row__help { display: block; - margin-top: 3px; + margin-top: 4px; font-size: 12px; color: var(--muted); - line-height: 1.45; + line-height: 1.5; } -/* Toggle Switch */ -.cfg-toggle { - position: relative; - flex-shrink: 0; -} -.cfg-toggle input { - position: absolute; - opacity: 0; - width: 0; - height: 0; -} - -.cfg-toggle__track { - display: block; - width: 50px; - height: 28px; - background: var(--bg-elevated); - border: 1px solid var(--border-strong); - border-radius: var(--radius-full); - position: relative; - transition: - background var(--duration-normal) ease, - border-color var(--duration-normal) ease; -} - -:root[data-theme="light"] .cfg-toggle__track { - background: var(--border); -} - -.cfg-toggle__track::after { - content: ""; - position: absolute; - top: 3px; - left: 3px; - width: 20px; - height: 20px; - background: var(--text); - border-radius: var(--radius-full); - box-shadow: var(--shadow-sm); - transition: - transform var(--duration-normal) var(--ease-out), - background var(--duration-normal) ease; -} - -.cfg-toggle input:checked + .cfg-toggle__track { - background: var(--ok-subtle); - border-color: rgba(34, 197, 94, 0.4); -} - -.cfg-toggle input:checked + .cfg-toggle__track::after { - transform: translateX(22px); - background: var(--ok); -} - -.cfg-toggle input:focus + .cfg-toggle__track { - box-shadow: var(--focus-ring); -} /* Object (collapsible) */ .cfg-object { @@ -1090,39 +856,7 @@ background: white; } -.cfg-array__add { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 7px 14px; - border: 1px solid var(--border); - border-radius: var(--radius-md); - background: var(--bg-elevated); - color: var(--text); - font-size: 12px; - font-weight: 500; - cursor: pointer; - transition: background var(--duration-fast) ease; -} -.cfg-array__add:hover:not(:disabled) { - background: var(--bg-hover); -} - -.cfg-array__add:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -.cfg-array__add-icon { - width: 14px; - height: 14px; -} - -.cfg-array__add-icon svg { - width: 100%; - height: 100%; -} .cfg-array__help { padding: 12px 18px; @@ -1169,36 +903,7 @@ letter-spacing: 0.05em; } -.cfg-array__item-remove { - width: 30px; - height: 30px; - display: flex; - align-items: center; - justify-content: center; - border: none; - border-radius: var(--radius-md); - background: transparent; - color: var(--muted); - cursor: pointer; - transition: - background var(--duration-fast) ease, - color var(--duration-fast) ease; -} -.cfg-array__item-remove svg { - width: 16px; - height: 16px; -} - -.cfg-array__item-remove:hover:not(:disabled) { - background: var(--danger-subtle); - color: var(--danger); -} - -.cfg-array__item-remove:disabled { - opacity: 0.4; - cursor: not-allowed; -} .cfg-array__item-content { padding: 18px; @@ -1231,35 +936,6 @@ color: var(--muted); } -.cfg-map__add { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 7px 14px; - border: 1px solid var(--border); - border-radius: var(--radius-md); - background: var(--bg-elevated); - color: var(--text); - font-size: 12px; - font-weight: 500; - cursor: pointer; - transition: background var(--duration-fast) ease; -} - -.cfg-map__add:hover:not(:disabled) { - background: var(--bg-hover); -} - -.cfg-map__add-icon { - width: 14px; - height: 14px; -} - -.cfg-map__add-icon svg { - width: 100%; - height: 100%; -} - .cfg-map__empty { padding: 28px 18px; text-align: center; @@ -1288,32 +964,6 @@ min-width: 0; } -.cfg-map__item-remove { - width: 34px; - height: 34px; - display: flex; - align-items: center; - justify-content: center; - border: none; - border-radius: var(--radius-md); - background: transparent; - color: var(--muted); - cursor: pointer; - transition: - background var(--duration-fast) ease, - color var(--duration-fast) ease; -} - -.cfg-map__item-remove svg { - width: 16px; - height: 16px; -} - -.cfg-map__item-remove:hover:not(:disabled) { - background: var(--danger-subtle); - color: var(--danger); -} - /* Pill variants */ .pill--sm { padding: 5px 12px; @@ -1337,6 +987,8 @@ @media (max-width: 768px) { .config-layout { grid-template-columns: 1fr; + height: auto; + margin: -8px; } .config-sidebar { @@ -1345,22 +997,53 @@ } .config-sidebar__header { - padding: 14px 16px; + padding: 12px 14px; } + .config-sidebar__title { + font-size: 13px; + } + + /* Search - better mobile sizing */ + .config-search { + padding: 10px 12px; + } + + .config-search__input { + padding: 10px 36px 10px 40px; + font-size: 16px; /* Prevents iOS zoom */ + } + + .config-search__icon { + left: 24px; + } + + .config-search__clear { + right: 18px; + width: 24px; + height: 24px; + } + + /* Horizontal scrolling nav */ .config-nav { display: flex; flex-wrap: nowrap; gap: 6px; - padding: 10px 14px; + padding: 10px 12px; overflow-x: auto; -webkit-overflow-scrolling: touch; + scrollbar-width: none; + } + + .config-nav::-webkit-scrollbar { + display: none; } .config-nav__item { flex: 0 0 auto; - padding: 9px 14px; + padding: 10px 14px; white-space: nowrap; + min-height: 44px; /* Touch target */ } .config-nav__label { @@ -1368,12 +1051,25 @@ } .config-sidebar__footer { - display: none; + padding: 10px 12px; } + .config-mode-toggle { + padding: 3px; + } + + .config-mode-toggle__btn { + padding: 10px 16px; + font-size: 13px; + min-height: 40px; /* Touch target */ + } + + /* Actions bar - stack on mobile */ .config-actions { - flex-wrap: wrap; - padding: 14px 16px; + flex-direction: column; + align-items: stretch; + gap: 10px; + padding: 12px 14px; } .config-actions__left, @@ -1382,30 +1078,122 @@ justify-content: center; } + .config-actions__right { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 8px; + } + + .config-changes-badge { + padding: 8px 14px; + font-size: 13px; + text-align: center; + } + + /* Section hero - compact */ .config-section-hero { - padding: 14px 16px; + padding: 12px 14px; } + .config-section-hero__icon { + width: 26px; + height: 26px; + } + + .config-section-hero__title { + font-size: 15px; + } + + .config-section-hero__desc { + font-size: 12px; + } + + /* Subnav - horizontal scroll */ .config-subnav { - padding: 10px 16px 12px; + padding: 10px 14px 12px; + scrollbar-width: none; } + .config-subnav::-webkit-scrollbar { + display: none; + } + + .config-subnav__item { + padding: 9px 14px; + font-size: 13px; + min-height: 38px; /* Touch target */ + } + + /* Content area */ .config-content { - padding: 18px; + padding: 14px; + } + + /* Section cards */ + .config-section-card { + border-radius: var(--radius-md); } .config-section-card__header { - padding: 16px 18px; - } - - .config-section-card__content { - padding: 18px; - } - - .cfg-toggle-row { padding: 14px 16px; } + .config-section-card__icon { + width: 28px; + height: 28px; + } + + .config-section-card__title { + font-size: 15px; + } + + .config-section-card__desc { + font-size: 12px; + } + + .config-section-card__content { + padding: 16px; + } + + /* Form fields - better mobile spacing */ + .cfg-fields { + gap: 14px; + } + + .cfg-field__label { + font-size: 14px; + } + + .cfg-field__help { + font-size: 13px; + } + + /* Toggle rows - larger touch targets */ + .cfg-toggle-row { + padding: 14px; + min-height: 60px; + } + + .cfg-toggle-row__label { + font-size: 14px; + } + + .cfg-toggle-row__help { + font-size: 13px; + } + + /* Textarea - better mobile sizing */ + .cfg-textarea { + padding: 12px; + font-size: 14px; + } + + .config-raw-field textarea { + min-height: 400px; + font-size: 12px; + } + + /* Map items - stack on mobile */ .cfg-map__item { grid-template-columns: 1fr; gap: 10px; @@ -1414,34 +1202,142 @@ .cfg-map__item-remove { justify-self: end; } + + /* Object/Array - better mobile spacing */ + .cfg-object__header, + .cfg-array__header { + padding: 12px 14px; + } + + .cfg-object__content, + .cfg-array__item-content { + padding: 14px; + } + + /* Diff panel */ + .config-diff { + margin: 12px 14px 0; + } + + .config-diff__summary { + padding: 12px 14px; + font-size: 12px; + } + + .config-diff__content { + padding: 0 14px 14px; + } + + .config-diff__item { + padding: 10px 12px; + font-size: 11px; + flex-direction: column; + align-items: flex-start; + gap: 8px; + } + + .config-diff__path { + font-size: 11px; + } + + .config-diff__values { + gap: 8px; + } } @media (max-width: 480px) { + .config-layout { + margin: -4px; + } + + .config-sidebar__header { + padding: 10px 12px; + } + + .config-search { + padding: 8px 10px; + } + + .config-nav { + padding: 8px 10px; + } + + /* Icon-only nav on small screens */ .config-nav__icon { - width: 26px; - height: 26px; - font-size: 17px; + width: 24px; + height: 24px; } .config-nav__label { display: none; } + .config-nav__item { + padding: 10px; + min-width: 44px; + justify-content: center; + } + + .config-actions { + padding: 10px 12px; + } + + .config-actions__right { + grid-template-columns: 1fr 1fr; + gap: 6px; + } + + .config-section-hero { + padding: 10px 12px; + } + + .config-section-hero__icon { + width: 24px; + height: 24px; + } + + .config-section-hero__title { + font-size: 14px; + } + + .config-section-hero__desc { + font-size: 11px; + } + + .config-subnav { + padding: 8px 12px 10px; + } + + .config-content { + padding: 12px; + } + + .config-section-card__header { + padding: 12px 14px; + } + .config-section-card__icon { - width: 30px; - height: 30px; + width: 26px; + height: 26px; } .config-section-card__title { - font-size: 16px; + font-size: 14px; } - .cfg-segmented { - flex-wrap: wrap; + .config-section-card__content { + padding: 14px; } - .cfg-segmented__btn { - flex: 1 0 auto; - min-width: 70px; + .cfg-toggle-row { + padding: 12px; } -} + + .config-diff { + margin: 10px 12px 0; + } + + .config-raw-field textarea { + min-height: 350px; + } +} \ No newline at end of file diff --git a/ui/src/styles/layout.css b/ui/src/styles/layout.css index c2a5c6fe3..8bbde6e82 100644 --- a/ui/src/styles/layout.css +++ b/ui/src/styles/layout.css @@ -164,6 +164,33 @@ box-sizing: border-box; } +/* Health badge - matches theme toggle size */ +.health-badge { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 12px; + border-radius: var(--radius-full); + border: 1px solid var(--border); + background: var(--secondary); + height: 32px; + font-size: 12px; + font-weight: 500; +} + +.health-badge__status { + font-family: var(--mono); + font-size: 11px; +} + +.topbar-status ui-badge { + font-size: 12px; +} + +.topbar-status ui-badge .statusDot { + margin-right: 2px; +} + .topbar-status .pill .mono { display: flex; align-items: center; @@ -197,6 +224,7 @@ overflow-x: hidden; padding: 16px 12px; background: var(--bg); + border-right: 1px solid var(--border); scrollbar-width: none; /* Firefox */ transition: width var(--shell-focus-duration) var(--shell-focus-ease), @@ -206,7 +234,8 @@ } .nav::-webkit-scrollbar { - display: none; /* Chrome/Safari */ + display: none; + /* Chrome/Safari */ } .shell--chat-focus .nav { @@ -277,12 +306,16 @@ /* Nav groups */ .nav-group { margin-bottom: 20px; + padding-bottom: 20px; + border-bottom: 1px solid var(--border); display: grid; gap: 2px; } .nav-group:last-child { margin-bottom: 0; + padding-bottom: 0; + border-bottom: none; } .nav-group__items { @@ -417,7 +450,7 @@ .content { grid-area: content; - padding: 12px 16px 32px; + padding: 32px 16px 32px; display: flex; flex-direction: column; gap: 24px; @@ -442,14 +475,15 @@ justify-content: space-between; gap: 16px; padding: 4px 8px; - overflow: hidden; + margin-bottom: 24px; + overflow: visible; transform-origin: top center; transition: opacity var(--shell-focus-duration) var(--shell-focus-ease), transform var(--shell-focus-duration) var(--shell-focus-ease), max-height var(--shell-focus-duration) var(--shell-focus-ease), padding var(--shell-focus-duration) var(--shell-focus-ease); - max-height: 80px; + min-height: 80px; } .shell--chat-focus .content-header { @@ -489,7 +523,7 @@ gap: 16px; } -.content--chat .content-header > div:first-child { +.content--chat .content-header>div:first-child { text-align: left; } @@ -606,4 +640,4 @@ .list-item { grid-template-columns: 1fr; } -} +} \ No newline at end of file diff --git a/ui/src/styles/layout.mobile.css b/ui/src/styles/layout.mobile.css index 450a83608..4187cc6c4 100644 --- a/ui/src/styles/layout.mobile.css +++ b/ui/src/styles/layout.mobile.css @@ -61,6 +61,11 @@ align-items: center; } + /* Hide nav toggle button on mobile (sidebar is always visible/stacked) */ + .nav-collapse-toggle { + display: none; + } + .brand { flex: 1; min-width: 0; @@ -80,6 +85,43 @@ flex-wrap: nowrap; } + /* Health badge - compact for mobile */ + .health-badge { + padding: 3px 8px; + height: 28px; + font-size: 11px; + gap: 4px; + } + + .health-badge span:nth-child(2) { + display: none; /* Hide "Health" text on mobile */ + } + + .health-badge__status { + font-size: 10px; + } + + .health-badge .statusDot { + width: 6px; + height: 6px; + } + + /* Theme controls - compact */ + .theme-toggle { + --theme-item: 24px; + --theme-gap: 2px; + --theme-pad: 3px; + } + + .theme-icon { + width: 12px; + height: 12px; + } + + theme-selector { + display: inline-block; + } + .topbar-status .pill { padding: 4px 8px; font-size: 11px; @@ -214,9 +256,13 @@ width: 100%; } + /* Chat thread - ensure proper scrolling on mobile */ .chat-thread { margin-top: 8px; - padding: 12px 8px; + padding: 12px 8px 80px 8px; /* Extra bottom padding for compose area */ + flex: 1; + overflow-y: auto; + -webkit-overflow-scrolling: touch; } .chat-msg { @@ -228,15 +274,47 @@ border-radius: var(--radius-md); } + /* Chat compose - fixed at bottom on mobile */ .chat-compose { + position: fixed !important; + bottom: 0; + left: 0; + right: 0; + margin: 0; + padding: 12px 8px; + padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px)); + background: var(--bg); + border-top: 1px solid var(--border); gap: 8px; + z-index: 50; + box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.1); + } + + :root[data-theme="light"] .chat-compose { + background: var(--bg-content); + box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.05); + } + + .shell--chat .chat-compose { + position: fixed !important; } .chat-compose__field textarea { - min-height: 60px; + min-height: 44px; + max-height: 120px; padding: 8px 10px; border-radius: var(--radius-md); - font-size: 14px; + font-size: 16px; /* Prevents zoom on iOS */ + resize: none; + } + + .chat-compose__actions { + margin-top: 0; + } + + /* Ensure content area accounts for fixed compose */ + .shell--chat .content { + padding-bottom: 0; } /* Log stream */ @@ -315,6 +393,35 @@ font-size: 13px; } + /* Health badge - ultra compact */ + .health-badge { + padding: 2px 6px; + height: 26px; + font-size: 10px; + gap: 3px; + } + + .health-badge__status { + font-size: 9px; + } + + .health-badge .statusDot { + width: 5px; + height: 5px; + } + + /* Theme controls - ultra compact */ + .theme-toggle { + --theme-item: 22px; + --theme-gap: 2px; + --theme-pad: 2px; + } + + .theme-icon { + width: 11px; + height: 11px; + } + .nav { padding: 6px 8px; } @@ -345,10 +452,16 @@ padding: 8px 10px; } + .chat-compose { + padding: 10px 6px; + padding-bottom: calc(10px + env(safe-area-inset-bottom, 0px)); + } + .chat-compose__field textarea { - min-height: 52px; + min-height: 40px; + max-height: 100px; padding: 8px 10px; - font-size: 13px; + font-size: 16px; /* Prevents zoom on iOS */ } .btn { @@ -372,3 +485,10 @@ height: 11px; } } + +/* Ensure chat content has proper spacing for fixed compose */ +@media (max-width: 600px) { + .shell--chat .content { + padding-bottom: 80px; /* Space for fixed compose area */ + } +} diff --git a/ui/src/ui/app-lifecycle.ts b/ui/src/ui/app-lifecycle.ts index 71af9d202..79f4c9e82 100644 --- a/ui/src/ui/app-lifecycle.ts +++ b/ui/src/ui/app-lifecycle.ts @@ -8,6 +8,7 @@ import { syncTabWithLocation, syncThemeWithSettings, } from "./app-settings"; +import { applyColorTheme } from "./theme"; import { observeTopbar, scheduleChatScroll, scheduleLogsScroll } from "./app-scroll"; import { startLogsPolling, @@ -31,6 +32,7 @@ type LifecycleHost = { logsEntries: unknown[]; popStateHandler: () => void; topbarObserver: ResizeObserver | null; + settings: { colorTheme: string }; }; export function handleConnected(host: LifecycleHost) { @@ -42,6 +44,10 @@ export function handleConnected(host: LifecycleHost) { syncThemeWithSettings( host as unknown as Parameters[0], ); + // Apply saved color theme + if (host.settings?.colorTheme) { + applyColorTheme(host.settings.colorTheme as any); + } attachThemeListener( host as unknown as Parameters[0], ); diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index db29bd7ec..3c7d3f772 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -78,6 +78,10 @@ import { saveExecApprovals, updateExecApprovalsFormValue, } from "./controllers/exec-approvals"; +import "./components/badge"; +import "./components/button"; +import "./components/checkbox"; +import "./components/theme-selector"; import { loadCronRuns, toggleCronJob, runCronJob, removeCronJob, addCronJob } from "./controllers/cron"; import { loadDebug, callDebugMethod } from "./controllers/debug"; import { loadLogs } from "./controllers/logs"; @@ -118,10 +122,10 @@ export function renderApp(state: AppViewState) { + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ui-button": UiButton; + } +} diff --git a/ui/src/ui/components/card.ts b/ui/src/ui/components/card.ts new file mode 100644 index 000000000..279982ae4 --- /dev/null +++ b/ui/src/ui/components/card.ts @@ -0,0 +1,110 @@ + +import { LitElement, html, css } from "lit"; +import { customElement } from "lit/decorators.js"; + +@customElement("ui-card") +export class UiCard extends LitElement { + static styles = css` + :host { + display: block; + } + + .card { + border: 1px solid var(--border, #3E3B38); + background: var(--card, #1A1816); + border-radius: var(--radius-lg, 12px); + box-shadow: var(--shadow-sm, 0 1px 2px rgba(0,0,0,0.1)); + overflow: hidden; + transition: all 0.2s ease; + } + + .card:hover { + border-color: var(--border-strong, #57534E); + box-shadow: var(--shadow-md, 0 4px 6px -1px rgba(0,0,0,0.1)); + } + + .card-header { + padding: 16px 20px; + border-bottom: 1px solid var(--border, #3E3B38); + display: flex; + align-items: center; + justify-content: space-between; + } + + .card-header:not(:has(*)) { + display: none; + } + + .card-title { + font-size: 16px; + font-weight: 600; + color: var(--foreground, #EDE9E6); + margin: 0; + } + + .card-desc { + font-size: 13px; + color: var(--muted-foreground, #A8A29D); + margin-top: 4px; + } + + .card-content { + padding: 20px; + } + + .card-footer { + padding: 16px 20px; + border-top: 1px solid var(--border, #3E3B38); + background: var(--bg-muted, #1F1D1B); + display: flex; + align-items: center; + } + + /* Light mode styles */ + :host-context([data-theme="light"]) .card { + background: var(--card, #FFFFFF); + border-color: var(--border, #e4e4e7); + } + + :host-context([data-theme="light"]) .card:hover { + border-color: var(--border-strong, #d4d4d8); + } + + :host-context([data-theme="light"]) .card-header { + border-bottom-color: var(--border, #e4e4e7); + } + + :host-context([data-theme="light"]) .card-title { + color: var(--foreground, #1A1410); + } + + :host-context([data-theme="light"]) .card-desc { + color: var(--muted-foreground, #71717a); + } + + :host-context([data-theme="light"]) .card-footer { + border-top-color: var(--border, #e4e4e7); + background: var(--bg-muted, #EFEEE9); + } + `; + + render() { + return html` +
+
+ +
+
+ +
+ +
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ui-card": UiCard; + } +} diff --git a/ui/src/ui/components/checkbox.ts b/ui/src/ui/components/checkbox.ts new file mode 100644 index 000000000..7f7d236a4 --- /dev/null +++ b/ui/src/ui/components/checkbox.ts @@ -0,0 +1,106 @@ + +import { LitElement, html, css } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import "./icon"; + +@customElement("ui-checkbox") +export class UiCheckbox extends LitElement { + @property({ type: Boolean }) checked = false; + @property({ type: Boolean }) disabled = false; + @property({ type: String }) label = ""; + + static styles = css` + :host { + display: inline-flex; + vertical-align: middle; + } + + .wrapper { + display: inline-flex; + align-items: center; + gap: 10px; + cursor: pointer; + user-select: none; + } + + .wrapper.disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .checkbox { + width: 18px; + height: 18px; + border: 1px solid var(--input-border, #3E3B38); + border-radius: var(--radius-sm, 4px); + background: var(--input-bg, #1A1816); + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + color: white; + } + + .wrapper:hover .checkbox { + border-color: var(--accent, #FF9F40); + background: rgba(255, 159, 64, 0.1); + } + + .wrapper.checked .checkbox { + background: var(--accent, #FF9F40); + border-color: var(--accent, #FF9F40); + } + + /* Light mode */ + :host-context([data-theme="light"]) .checkbox { + background: white; + } + + :host-context([data-theme="light"]) .wrapper:hover .checkbox { + background: rgba(255, 159, 64, 0.1); + } + + :host-context([data-theme="light"]) .wrapper.checked .checkbox { + background: var(--accent, #FF9F40); + } + + .label { + font-size: 14px; + color: var(--foreground, #EDE9E6); + } + + :host-context([data-theme="light"]) .label { + color: var(--foreground, #1A1410); + } + `; + + toggle() { + if (this.disabled) return; + this.checked = !this.checked; + this.dispatchEvent(new CustomEvent("change", { + detail: { checked: this.checked }, + bubbles: true, + composed: true + })); + } + + render() { + return html` +
+
+ ${this.checked ? html`` : null} +
+ ${this.label ? html`${this.label}` : null} +
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ui-checkbox": UiCheckbox; + } +} diff --git a/ui/src/ui/components/dropdown-menu.ts b/ui/src/ui/components/dropdown-menu.ts new file mode 100644 index 000000000..53097f939 --- /dev/null +++ b/ui/src/ui/components/dropdown-menu.ts @@ -0,0 +1,200 @@ +import { LitElement, html, css } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; +import "./icon"; + +export interface DropdownMenuItem { + label: string; + value: string; + variant?: "default" | "danger"; + disabled?: boolean; +} + +@customElement("ui-dropdown-menu") +export class UiDropdownMenu extends LitElement { + @property({ type: Array }) items: DropdownMenuItem[] = []; + @property({ type: Boolean }) disabled = false; + @state() private open = false; + + static styles = css` + :host { + position: relative; + display: inline-block; + } + + .trigger { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + border: 1px solid var(--border, #3E3B38); + background: var(--bg-elevated, #1F1D1B); + padding: 9px 12px; + border-radius: var(--radius-md, 8px); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; + color: var(--text, #EDE9E6); + } + + .trigger:hover:not(:disabled) { + background: var(--bg-hover, #2A2826); + border-color: var(--border-strong, #57534E); + transform: translateY(-1px); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); + } + + .trigger:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .menu { + position: absolute; + top: calc(100% + 4px); + right: 0; + min-width: 160px; + background: var(--card, #1A1816); + border: 1px solid var(--border, #3E3B38); + border-radius: var(--radius-md, 8px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + padding: 4px; + z-index: 100; + opacity: 0; + transform: translateY(-4px); + pointer-events: none; + transition: all 0.15s ease; + } + + .menu.open { + opacity: 1; + transform: translateY(0); + pointer-events: auto; + } + + .menu-item { + display: flex; + align-items: center; + padding: 8px 12px; + border-radius: var(--radius-sm, 6px); + font-size: 13px; + cursor: pointer; + transition: background 0.1s ease; + color: var(--text, #EDE9E6); + border: none; + background: transparent; + width: 100%; + text-align: left; + } + + .menu-item:hover:not(:disabled) { + background: var(--bg-hover, #2A2826); + } + + .menu-item:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .menu-item.danger { + color: var(--danger, #EF4444); + } + + .menu-item.danger:hover:not(:disabled) { + background: rgba(239, 68, 68, 0.1); + } + + /* Light mode */ + :host-context([data-theme="light"]) .trigger { + background: white; + color: var(--foreground); + } + + :host-context([data-theme="light"]) .trigger:hover:not(:disabled) { + background: var(--bg-hover); + } + + :host-context([data-theme="light"]) .menu { + background: white; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + } + + :host-context([data-theme="light"]) .menu-item:hover:not(:disabled) { + background: var(--bg-hover); + } + `; + + private handleClickOutside = (e: MouseEvent) => { + if (!this.contains(e.target as Node)) { + this.open = false; + } + }; + + connectedCallback() { + super.connectedCallback(); + document.addEventListener("click", this.handleClickOutside); + } + + disconnectedCallback() { + super.disconnectedCallback(); + document.removeEventListener("click", this.handleClickOutside); + } + + private toggleMenu(e: Event) { + e.stopPropagation(); + if (!this.disabled) { + this.open = !this.open; + } + } + + private handleItemClick(item: DropdownMenuItem, e: Event) { + e.stopPropagation(); + if (!item.disabled) { + this.open = false; + this.dispatchEvent( + new CustomEvent("select", { + detail: { value: item.value }, + bubbles: true, + composed: true, + }) + ); + } + } + + render() { + return html` + + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ui-dropdown-menu": UiDropdownMenu; + } +} diff --git a/ui/src/ui/components/icon.ts b/ui/src/ui/components/icon.ts new file mode 100644 index 000000000..1879160e5 --- /dev/null +++ b/ui/src/ui/components/icon.ts @@ -0,0 +1,51 @@ + +import { LitElement, html, css, type TemplateResult } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { icons, type IconName } from "../icons"; + +@customElement("ui-icon") +export class UiIcon extends LitElement { + @property({ type: String }) name?: IconName; + @property({ type: Number }) size = 16; + @property({ type: String }) color = "currentColor"; + + static styles = css` + :host { + display: inline-flex; + align-items: center; + justify-content: center; + vertical-align: middle; + } + svg { + width: 100%; + height: 100%; + stroke: currentColor; + fill: none; + stroke-width: 1.5px; + stroke-linecap: round; + stroke-linejoin: round; + } + `; + + render() { + const iconTemplate = this.name ? icons[this.name] : undefined; + if (!iconTemplate) return html``; + + return html` + + ${iconTemplate} + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ui-icon": UiIcon; + } +} diff --git a/ui/src/ui/components/index.ts b/ui/src/ui/components/index.ts new file mode 100644 index 000000000..3bdaa0130 --- /dev/null +++ b/ui/src/ui/components/index.ts @@ -0,0 +1,14 @@ + +export * from "./icon"; +export * from "./button"; +export * from "./badge"; +export * from "./card"; +export * from "./input"; +export * from "./select"; +export * from "./switch"; +export * from "./tooltip"; +export * from "./segmented-control"; +export * from "./checkbox"; +export * from "./resizable-divider"; +export * from "./textarea"; +export * from "./theme-selector"; diff --git a/ui/src/ui/components/input.ts b/ui/src/ui/components/input.ts new file mode 100644 index 000000000..ea5f81af2 --- /dev/null +++ b/ui/src/ui/components/input.ts @@ -0,0 +1,148 @@ + +import { LitElement, html, css } from "lit"; +import { customElement, property, query } from "lit/decorators.js"; +import { classMap } from "lit/directives/class-map.js"; +import "./icon"; + +@customElement("ui-input") +export class UiInput extends LitElement { + @property({ type: String }) type = "text"; + @property({ type: String }) placeholder = ""; + @property({ type: String }) value = ""; + @property({ type: String }) label = ""; + @property({ type: String }) error = ""; + @property({ type: Boolean }) disabled = false; + @property({ type: String }) icon?: string; + + @query("input") inputElement!: HTMLInputElement; + + static styles = css` + :host { + display: block; + width: 100%; + } + + .input-wrapper { + display: flex; + flex-direction: column; + gap: 6px; + } + + .label { + font-size: 13px; + font-weight: 500; + color: var(--muted-foreground, #A8A29D); + } + + .input-container { + position: relative; + display: flex; + align-items: center; + } + + input { + width: 100%; + height: 36px; + padding: 0 12px; + padding-left: var(--input-padding-left, 12px); + background: var(--input-bg, #1A1816); + border: 1px solid var(--input-border, #3E3B38); + border-radius: var(--radius-md, 6px); + color: var(--foreground, #EDE9E6); + font-family: inherit; + font-size: 14px; + outline: none; + transition: all 0.2s ease; + } + + input:focus { + border-color: var(--accent, #FF9F40); + box-shadow: 0 0 0 2px var(--accent-subtle, rgba(255, 159, 64, 0.1)); + } + + input:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + /* Light mode */ + :host-context([data-theme="light"]) input { + background: white; + color: var(--foreground); + } + + :host-context([data-theme="light"]) input:focus { + background: white; + } + + .has-icon input { + padding-left: 36px; + } + + .icon { + position: absolute; + left: 10px; + color: var(--muted-foreground, #A8A29D); + pointer-events: none; + display: flex; + align-items: center; + } + + .error-msg { + font-size: 12px; + color: var(--danger, #EF4444); + } + + .input-error input { + border-color: var(--danger, #EF4444); + } + `; + + handleInput(e: Event) { + const target = e.target as HTMLInputElement; + this.value = target.value; + this.dispatchEvent(new CustomEvent("input", { + detail: { value: this.value }, + bubbles: true, + composed: true + })); + } + + handleChange(e: Event) { + const target = e.target as HTMLInputElement; + this.value = target.value; + this.dispatchEvent(new CustomEvent("change", { + detail: { value: this.value }, + bubbles: true, + composed: true + })); + } + + render() { + return html` +
+ ${this.label ? html`` : null} + +
+ ${this.icon ? html`` : null} + +
+ + ${this.error ? html`${this.error}` : null} +
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ui-input": UiInput; + } +} diff --git a/ui/src/ui/components/resizable-divider.ts b/ui/src/ui/components/resizable-divider.ts index a14cca35a..feeb4ef8b 100644 --- a/ui/src/ui/components/resizable-divider.ts +++ b/ui/src/ui/components/resizable-divider.ts @@ -41,6 +41,19 @@ export class ResizableDivider extends LitElement { :host(.dragging) { background: var(--accent, #007bff); } + + /* Light mode styles */ + :host-context([data-theme="light"]) { + background: var(--border, #e4e4e7); + } + + :host-context([data-theme="light"]):hover { + background: var(--accent, #FF8C42); + } + + :host-context([data-theme="light"]).dragging { + background: var(--accent, #FF8C42); + } `; render() { diff --git a/ui/src/ui/components/segmented-control.ts b/ui/src/ui/components/segmented-control.ts new file mode 100644 index 000000000..03ca7c3c8 --- /dev/null +++ b/ui/src/ui/components/segmented-control.ts @@ -0,0 +1,98 @@ + +import { LitElement, html, css } from "lit"; +import { customElement, property } from "lit/decorators.js"; + +export interface SegmentonedOption { + label: string; + value: string; +} + +@customElement("ui-segmented-control") +export class UiSegmentedControl extends LitElement { + @property({ type: Array }) options: SegmentonedOption[] = []; + @property({ type: String }) value = ""; + + static styles = css` + :host { + display: inline-flex; + background: var(--input-bg, #2A2723); + padding: 3px; // slightly smaller padding for tighter look + border-radius: var(--radius-md, 8px); + border: 1px solid var(--border, #3E3B38); + } + + .segment { + padding: 6px 14px; + font-size: 13px; + font-weight: 500; + color: var(--muted-foreground, #A8A29D); + border-radius: var(--radius-sm, 6px); + cursor: pointer; + transition: all 0.2s ease; + user-select: none; + text-align: center; + flex: 1; + } + + .segment:hover { + color: var(--foreground, #EDE9E6); + background: rgba(255, 255, 255, 0.03); + } + + .segment.active { + background: var(--card, #1A1816); + color: var(--foreground, #EDE9E6); + box-shadow: 0 1px 2px rgba(0,0,0,0.2); + } + + /* Light mode styles */ + :host-context([data-theme="light"]) { + background: var(--input-bg, #F5F4F2); + border-color: var(--border, #e4e4e7); + } + + :host-context([data-theme="light"]) .segment { + color: var(--muted-foreground, #71717a); + } + + :host-context([data-theme="light"]) .segment:hover { + color: var(--foreground, #1A1410); + background: rgba(0, 0, 0, 0.03); + } + + :host-context([data-theme="light"]) .segment.active { + background: var(--card, #FFFFFF); + color: var(--foreground, #1A1410); + box-shadow: 0 1px 2px rgba(0,0,0,0.06); + } + `; + + select(val: string) { + if (this.value === val) return; + this.value = val; + this.dispatchEvent(new CustomEvent("change", { + detail: { value: this.value }, + bubbles: true, + composed: true + })); + } + + render() { + return html` + ${this.options.map(opt => html` +
this.select(opt.value)} + > + ${opt.label} +
+ `)} + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ui-segmented-control": UiSegmentedControl; + } +} diff --git a/ui/src/ui/components/select.ts b/ui/src/ui/components/select.ts new file mode 100644 index 000000000..4a09e9c85 --- /dev/null +++ b/ui/src/ui/components/select.ts @@ -0,0 +1,124 @@ + +import { LitElement, html, css } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import "./icon"; + +export interface SelectOption { + label: string; + value: string; +} + +@customElement("ui-select") +export class UiSelect extends LitElement { + @property({ type: String }) label = ""; + @property({ type: String }) value = ""; + @property({ type: Array }) options: SelectOption[] = []; + @property({ type: Boolean }) disabled = false; + + static styles = css` + :host { + display: block; + width: 100%; + } + + .wrapper { + display: flex; + flex-direction: column; + gap: 6px; + } + + .label { + font-size: 13px; + font-weight: 500; + color: var(--muted-foreground, #A8A29D); + } + + .select-container { + position: relative; + display: flex; + align-items: center; + } + + select { + width: 100%; + height: 36px; + padding: 0 12px; + padding-right: 36px; + background: var(--input-bg, #1A1816); + border: 1px solid var(--input-border, #3E3B38); + border-radius: var(--radius-md, 6px); + color: var(--foreground, #EDE9E6); + font-family: inherit; + font-size: 14px; + outline: none; + appearance: none; + cursor: pointer; + transition: all 0.2s ease; + } + + select:focus { + border-color: var(--accent, #FF9F40); + box-shadow: 0 0 0 2px var(--accent-subtle, rgba(255, 159, 64, 0.1)); + } + + select:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + /* Light mode */ + :host-context([data-theme="light"]) select { + background: white; + color: var(--foreground); + } + + :host-context([data-theme="light"]) select:focus { + background: white; + } + + .chevron { + position: absolute; + right: 12px; + pointer-events: none; + color: var(--muted-foreground, #A8A29D); + display: flex; + align-items: center; + } + `; + + handleChange(e: Event) { + const target = e.target as HTMLSelectElement; + this.value = target.value; + this.dispatchEvent(new CustomEvent("change", { + detail: { value: this.value }, + bubbles: true, + composed: true + })); + } + + render() { + return html` +
+ ${this.label ? html`` : null} +
+ + +
+
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ui-select": UiSelect; + } +} diff --git a/ui/src/ui/components/switch.ts b/ui/src/ui/components/switch.ts new file mode 100644 index 000000000..b6e5619a0 --- /dev/null +++ b/ui/src/ui/components/switch.ts @@ -0,0 +1,100 @@ + +import { LitElement, html, css } from "lit"; +import { customElement, property } from "lit/decorators.js"; + +@customElement("ui-switch") +export class UiSwitch extends LitElement { + @property({ type: Boolean }) checked = false; + @property({ type: Boolean }) disabled = false; + + static styles = css` + :host { + display: inline-block; + vertical-align: middle; + } + + input { + display: none; + } + + .track { + width: 36px; + height: 20px; + background: var(--input-bg, #2A2723); + border: 1px solid var(--input-border, #3E3B38); + border-radius: 999px; + position: relative; + cursor: pointer; + transition: all 0.2s ease; + } + + .track.checked { + background: var(--accent, #FF9F40); + border-color: var(--accent, #FF9F40); + } + + .thumb { + width: 16px; + height: 16px; + background: #A8A29D; + border-radius: 50%; + position: absolute; + top: 1px; + left: 1px; + transition: transform 0.2s cubic-bezier(0.4, 0.0, 0.2, 1); + box-shadow: 0 1px 2px rgba(0,0,0,0.2); + } + + .track.checked .thumb { + background: white; + transform: translateX(16px); + } + + .track.disabled { + opacity: 0.5; + cursor: not-allowed; + } + + /* Light mode styles */ + :host-context([data-theme="light"]) .track { + background: var(--input-bg, #F5F4F2); + border-color: var(--input-border, #e4e4e7); + } + + :host-context([data-theme="light"]) .track.checked { + background: var(--accent, #FF8C42); + border-color: var(--accent, #FF8C42); + } + + :host-context([data-theme="light"]) .thumb { + background: #71717a; + } + `; + + toggle() { + if (this.disabled) return; + this.checked = !this.checked; + this.dispatchEvent(new CustomEvent("change", { + detail: { checked: this.checked }, + bubbles: true, + composed: true + })); + } + + render() { + return html` +
+
+
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ui-switch": UiSwitch; + } +} diff --git a/ui/src/ui/components/textarea.ts b/ui/src/ui/components/textarea.ts new file mode 100644 index 000000000..38d109a57 --- /dev/null +++ b/ui/src/ui/components/textarea.ts @@ -0,0 +1,124 @@ + +import { LitElement, html, css } from "lit"; +import { customElement, property, query } from "lit/decorators.js"; + +@customElement("ui-textarea") +export class UiTextarea extends LitElement { + @property({ type: String }) placeholder = ""; + @property({ type: String }) value = ""; + @property({ type: String }) label = ""; + @property({ type: String }) error = ""; + @property({ type: Boolean }) disabled = false; + @property({ type: Number }) rows = 4; + + @query("textarea") textareaElement!: HTMLTextAreaElement; + + static styles = css` + :host { + display: block; + width: 100%; + } + + .textarea-wrapper { + display: flex; + flex-direction: column; + gap: 6px; + } + + .label { + font-size: 13px; + font-weight: 500; + color: var(--muted-foreground, #A8A29D); + } + + textarea { + width: 100%; + padding: 12px 14px; + background: var(--input-bg, #1A1816); + border: 1px solid var(--input-border, #3E3B38); + border-radius: var(--radius-md, 8px); + color: var(--foreground, #EDE9E6); + font-family: var(--font-mono, "JetBrains Mono", monospace); + font-size: 13px; + line-height: 1.55; + outline: none; + transition: all 0.2s ease; + resize: vertical; + min-height: 80px; + } + + textarea:focus { + border-color: var(--accent, #FF9F40); + box-shadow: 0 0 0 2px var(--accent-subtle, rgba(255, 159, 64, 0.1)); + } + + textarea:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + /* Light mode */ + :host-context([data-theme="light"]) textarea { + background: white; + color: var(--foreground); + } + + :host-context([data-theme="light"]) textarea:focus { + background: white; + } + + .error-msg { + font-size: 12px; + color: var(--danger, #EF4444); + } + + .textarea-error textarea { + border-color: var(--danger, #EF4444); + } + `; + + handleInput(e: Event) { + const target = e.target as HTMLTextAreaElement; + this.value = target.value; + this.dispatchEvent(new CustomEvent("input", { + detail: { value: this.value }, + bubbles: true, + composed: true + })); + } + + handleChange(e: Event) { + const target = e.target as HTMLTextAreaElement; + this.value = target.value; + this.dispatchEvent(new CustomEvent("change", { + detail: { value: this.value }, + bubbles: true, + composed: true + })); + } + + render() { + return html` +
+ ${this.label ? html`` : null} + + + + ${this.error ? html`${this.error}` : null} +
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ui-textarea": UiTextarea; + } +} diff --git a/ui/src/ui/components/theme-selector.ts b/ui/src/ui/components/theme-selector.ts new file mode 100644 index 000000000..2f3e78bda --- /dev/null +++ b/ui/src/ui/components/theme-selector.ts @@ -0,0 +1,213 @@ +import { LitElement, html, css } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; +import { COLOR_THEMES, type ColorTheme } from "../theme"; +import "./icon"; + +@customElement("theme-selector") +export class ThemeSelector extends LitElement { + @property({ type: String }) colorTheme: ColorTheme = "tangerine"; + @state() private isOpen = false; + + static styles = css` + :host { + position: relative; + display: inline-block; + } + + .trigger { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + border: 1px solid var(--border); + border-radius: var(--radius-full); + background: var(--secondary); + color: var(--muted); + cursor: pointer; + transition: all 0.2s ease; + } + + .trigger:hover { + color: var(--text); + background: var(--bg-hover); + border-color: var(--border-strong); + } + + .dropdown { + position: absolute; + top: calc(100% + 8px); + right: 0; + min-width: 180px; + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius-md); + box-shadow: var(--shadow-lg); + padding: 6px; + z-index: 100; + opacity: 0; + transform: translateY(-4px); + pointer-events: none; + transition: all 0.2s ease; + } + + .dropdown.open { + opacity: 1; + transform: translateY(0); + pointer-events: all; + } + + .dropdown-item { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + border-radius: var(--radius-sm); + cursor: pointer; + transition: background 0.15s ease; + border: none; + background: transparent; + width: 100%; + text-align: left; + color: var(--text); + font-size: 13px; + font-weight: 500; + } + + .dropdown-item:hover { + background: var(--bg-hover); + } + + .dropdown-item.active { + background: var(--accent-subtle); + color: var(--accent); + } + + .color-preview { + width: 16px; + height: 16px; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + flex-shrink: 0; + } + + /* Light mode */ + :host-context([data-theme="light"]) .trigger { + background: white; + border-color: var(--border); + } + + :host-context([data-theme="light"]) .trigger:hover { + background: var(--bg-hover); + } + + :host-context([data-theme="light"]) .dropdown { + background: white; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + } + + :host-context([data-theme="light"]) .dropdown-item:hover { + background: var(--bg-hover); + } + + /* Mobile optimization */ + @media (max-width: 600px) { + .trigger { + width: 26px; + height: 26px; + } + + .dropdown { + min-width: 160px; + padding: 4px; + } + + .dropdown-item { + padding: 6px 8px; + font-size: 12px; + } + + .color-preview { + width: 14px; + height: 14px; + } + } + + @media (max-width: 400px) { + .trigger { + width: 24px; + height: 24px; + } + + .dropdown { + min-width: 140px; + } + + .dropdown-item { + padding: 5px 7px; + font-size: 11px; + gap: 8px; + } + + .color-preview { + width: 12px; + height: 12px; + } + } + `; + + connectedCallback() { + super.connectedCallback(); + document.addEventListener("click", this.handleOutsideClick); + } + + disconnectedCallback() { + super.disconnectedCallback(); + document.removeEventListener("click", this.handleOutsideClick); + } + + private handleOutsideClick = (e: MouseEvent) => { + if (!this.contains(e.target as Node)) { + this.isOpen = false; + } + }; + + private toggleDropdown(e: Event) { + e.stopPropagation(); + this.isOpen = !this.isOpen; + } + + private selectTheme(theme: ColorTheme) { + this.dispatchEvent(new CustomEvent("change", { + detail: { colorTheme: theme }, + bubbles: true, + composed: true + })); + this.isOpen = false; + } + + render() { + return html` + + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "theme-selector": ThemeSelector; + } +} diff --git a/ui/src/ui/components/tooltip.ts b/ui/src/ui/components/tooltip.ts new file mode 100644 index 000000000..d45d3aa2c --- /dev/null +++ b/ui/src/ui/components/tooltip.ts @@ -0,0 +1,75 @@ + +import { LitElement, html, css } from "lit"; +import { customElement, property } from "lit/decorators.js"; + +@customElement("ui-tooltip") +export class UiTooltip extends LitElement { + @property({ type: String }) content = ""; + + static styles = css` + :host { + position: relative; + display: inline-block; + } + + .tooltip { + position: absolute; + bottom: 100%; + left: 50%; + transform: translateX(-50%) translateY(8px) scale(0.95); + background: var(--tooltip-bg, #1A1816); + color: var(--tooltip-fg, #EDE9E6); + padding: 6px 10px; + border-radius: 6px; + font-size: 12px; + white-space: nowrap; + opacity: 0; + pointer-events: none; + transition: all 0.15s ease; + border: 1px solid var(--border, #3E3B38); + box-shadow: var(--shadow-md, 0 4px 6px -1px rgba(0,0,0,0.1)); + z-index: 50; + } + + /* Arrow */ + .tooltip::after { + content: ""; + position: absolute; + top: 100%; + left: 50%; + transform: translateX(-50%); + border-width: 4px; + border-style: solid; + border-color: var(--tooltip-bg, #1A1816) transparent transparent transparent; + } + + :host(:hover) .tooltip { + opacity: 1; + transform: translateX(-50%) translateY(-8px) scale(1); + } + + /* Light mode styles */ + :host-context([data-theme="light"]) .tooltip { + background: var(--tooltip-bg, #FFFFFF); + color: var(--tooltip-fg, #1A1410); + border-color: var(--border, #e4e4e7); + } + + :host-context([data-theme="light"]) .tooltip::after { + border-color: var(--tooltip-bg, #FFFFFF) transparent transparent transparent; + } + `; + + render() { + return html` + +
${this.content}
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ui-tooltip": UiTooltip; + } +} diff --git a/ui/src/ui/controllers/devices.ts b/ui/src/ui/controllers/devices.ts index f08d7afb6..810d1d625 100644 --- a/ui/src/ui/controllers/devices.ts +++ b/ui/src/ui/controllers/devices.ts @@ -75,8 +75,6 @@ export async function approveDevicePairing(state: DevicesState, requestId: strin export async function rejectDevicePairing(state: DevicesState, requestId: string) { if (!state.client || !state.connected) return; - const confirmed = window.confirm("Reject this device pairing request?"); - if (!confirmed) return; try { await state.client.request("device.pair.reject", { requestId }); await loadDevices(state); @@ -118,10 +116,6 @@ export async function revokeDeviceToken( params: { deviceId: string; role: string }, ) { if (!state.client || !state.connected) return; - const confirmed = window.confirm( - `Revoke token for ${params.deviceId} (${params.role})?`, - ); - if (!confirmed) return; try { await state.client.request("device.token.revoke", params); const identity = await loadOrCreateDeviceIdentity(); diff --git a/ui/src/ui/controllers/sessions.ts b/ui/src/ui/controllers/sessions.ts index 5c5077037..282b54736 100644 --- a/ui/src/ui/controllers/sessions.ts +++ b/ui/src/ui/controllers/sessions.ts @@ -66,10 +66,6 @@ export async function patchSession( export async function deleteSession(state: SessionsState, key: string) { if (!state.client || !state.connected) return; if (state.sessionsLoading) return; - const confirmed = window.confirm( - `Delete session "${key}"?\n\nDeletes the session entry and archives its transcript.`, - ); - if (!confirmed) return; state.sessionsLoading = true; state.sessionsError = null; try { diff --git a/ui/src/ui/icons.ts b/ui/src/ui/icons.ts index eaf8f0e27..7818463f0 100644 --- a/ui/src/ui/icons.ts +++ b/ui/src/ui/icons.ts @@ -19,6 +19,7 @@ export const icons = { // UI icons menu: html``, + moreVertical: html``, x: html``, check: html``, copy: html``, diff --git a/ui/src/ui/storage.ts b/ui/src/ui/storage.ts index fc410c225..4ab72482f 100644 --- a/ui/src/ui/storage.ts +++ b/ui/src/ui/storage.ts @@ -1,6 +1,6 @@ const KEY = "clawdbot.control.settings.v1"; -import type { ThemeMode } from "./theme"; +import type { ThemeMode, ColorTheme } from "./theme"; export type UiSettings = { gatewayUrl: string; @@ -8,6 +8,7 @@ export type UiSettings = { sessionKey: string; lastActiveSessionKey: string; theme: ThemeMode; + colorTheme: ColorTheme; chatFocusMode: boolean; chatShowThinking: boolean; splitRatio: number; // Sidebar split ratio (0.4 to 0.7, default 0.6) @@ -27,6 +28,7 @@ export function loadSettings(): UiSettings { sessionKey: "main", lastActiveSessionKey: "main", theme: "system", + colorTheme: "tangerine", chatFocusMode: false, chatShowThinking: true, splitRatio: 0.6, @@ -61,6 +63,15 @@ export function loadSettings(): UiSettings { parsed.theme === "system" ? parsed.theme : defaults.theme, + colorTheme: + parsed.colorTheme === "tangerine" || + parsed.colorTheme === "ocean" || + parsed.colorTheme === "forest" || + parsed.colorTheme === "sunset" || + parsed.colorTheme === "lavender" || + parsed.colorTheme === "slate" + ? parsed.colorTheme + : defaults.colorTheme, chatFocusMode: typeof parsed.chatFocusMode === "boolean" ? parsed.chatFocusMode diff --git a/ui/src/ui/theme.ts b/ui/src/ui/theme.ts index 47bd5670b..8bf18d89c 100644 --- a/ui/src/ui/theme.ts +++ b/ui/src/ui/theme.ts @@ -1,5 +1,9 @@ export type ThemeMode = "system" | "light" | "dark"; export type ResolvedTheme = "light" | "dark"; +export type ColorTheme = "tangerine" | "ocean" | "forest" | "sunset" | "lavender" | "slate"; + +const STORAGE_KEY = "clawdbot-theme"; +const COLOR_THEME_KEY = "clawdbot-color-theme"; export function getSystemTheme(): ResolvedTheme { if (typeof window === "undefined" || typeof window.matchMedia !== "function") { @@ -14,3 +18,107 @@ export function resolveTheme(mode: ThemeMode): ResolvedTheme { if (mode === "system") return getSystemTheme(); return mode; } + +export function getSavedTheme(): ThemeMode { + if (typeof window === "undefined" || typeof localStorage === "undefined") { + return "system"; + } + const saved = localStorage.getItem(STORAGE_KEY); + if (saved === "light" || saved === "dark" || saved === "system") { + return saved; + } + return "system"; +} + +export function saveTheme(mode: ThemeMode): void { + if (typeof window === "undefined" || typeof localStorage === "undefined") { + return; + } + localStorage.setItem(STORAGE_KEY, mode); +} + +export function getSavedColorTheme(): ColorTheme { + if (typeof window === "undefined" || typeof localStorage === "undefined") { + return "tangerine"; + } + const saved = localStorage.getItem(COLOR_THEME_KEY); + if (saved === "tangerine" || saved === "ocean" || saved === "forest" || + saved === "sunset" || saved === "lavender" || saved === "slate") { + return saved; + } + return "tangerine"; +} + +export function saveColorTheme(colorTheme: ColorTheme): void { + if (typeof window === "undefined" || typeof localStorage === "undefined") { + return; + } + localStorage.setItem(COLOR_THEME_KEY, colorTheme); +} + +export function applyColorTheme(colorTheme: ColorTheme): void { + if (typeof document === "undefined") return; + const root = document.documentElement; + root.setAttribute("data-color-theme", colorTheme); +} + +export function applyTheme(mode: ThemeMode): void { + if (typeof document === "undefined") return; + + const root = document.documentElement; + const resolved = resolveTheme(mode); + + if (mode === "system") { + root.removeAttribute("data-theme"); + } else { + root.setAttribute("data-theme", mode); + } + + // Update meta theme-color for mobile browsers + updateMetaThemeColor(resolved); +} + +function updateMetaThemeColor(theme: ResolvedTheme): void { + if (typeof document === "undefined") return; + + let metaThemeColor = document.querySelector('meta[name="theme-color"]'); + + if (!metaThemeColor) { + metaThemeColor = document.createElement("meta"); + metaThemeColor.setAttribute("name", "theme-color"); + document.head.appendChild(metaThemeColor); + } + + // Muted backgrounds from design system + const color = theme === "light" ? "#FAFAF9" : "#1A1816"; + metaThemeColor.setAttribute("content", color); +} + +export function initTheme(): ThemeMode { + const saved = getSavedTheme(); + const colorTheme = getSavedColorTheme(); + applyTheme(saved); + applyColorTheme(colorTheme); + + // Watch for system theme changes + if (typeof window !== "undefined" && typeof window.matchMedia === "function") { + const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); + mediaQuery.addEventListener("change", () => { + const current = getSavedTheme(); + if (current === "system") { + applyTheme("system"); + } + }); + } + + return saved; +} + +export const COLOR_THEMES: Record = { + tangerine: { name: "Tangerine", preview: "#FF9F40" }, + ocean: { name: "Ocean", preview: "#0EA5E9" }, + forest: { name: "Forest", preview: "#10B981" }, + sunset: { name: "Sunset", preview: "#F43F5E" }, + lavender: { name: "Lavender", preview: "#A78BFA" }, + slate: { name: "Slate", preview: "#64748B" }, +}; diff --git a/ui/src/ui/views/channels.config.ts b/ui/src/ui/views/channels.config.ts index 3c4d2c7df..eace5a243 100644 --- a/ui/src/ui/views/channels.config.ts +++ b/ui/src/ui/views/channels.config.ts @@ -102,7 +102,10 @@ export function renderChannelConfigSection(params: { const { channelId, props } = params; const disabled = props.configSaving || props.configSchemaLoading; return html` -
+
+
+ Configuration +
${props.configSchemaLoading ? html`
Loading config schema…
` : renderChannelConfigForm({ @@ -113,22 +116,6 @@ export function renderChannelConfigSection(params: { disabled, onPatch: props.onConfigPatch, })} -
- - -
`; } diff --git a/ui/src/ui/views/channels.discord.ts b/ui/src/ui/views/channels.discord.ts index 07890e969..b567fbe76 100644 --- a/ui/src/ui/views/channels.discord.ts +++ b/ui/src/ui/views/channels.discord.ts @@ -11,51 +11,71 @@ export function renderDiscordCard(params: { accountCountLabel: unknown; }) { const { props, discord, accountCountLabel } = params; + const configDisabled = props.configSaving || props.configSchemaLoading; return html` -
-
Discord
-
Bot status and channel configuration.
- ${accountCountLabel} +
+
+
Discord
+
Bot status and channel configuration.
+ ${accountCountLabel} -
-
- Configured - ${discord?.configured ? "Yes" : "No"} -
-
- Running - ${discord?.running ? "Yes" : "No"} -
-
- Last start - ${discord?.lastStartAt ? formatAgo(discord.lastStartAt) : "n/a"} -
-
- Last probe - ${discord?.lastProbeAt ? formatAgo(discord.lastProbeAt) : "n/a"} +
+
+ Configured + ${discord?.configured ? "Yes" : "No"} +
+
+ Running + ${discord?.running ? "Yes" : "No"} +
+
+ Last start + ${discord?.lastStartAt ? formatAgo(discord.lastStartAt) : "n/a"} +
+
+ Last probe + ${discord?.lastProbeAt ? formatAgo(discord.lastProbeAt) : "n/a"} +
+ + ${discord?.probe + ? html`
+ Probe ${discord.probe.ok ? "ok" : "failed"} · + ${discord.probe.status ?? ""} ${discord.probe.error ?? ""} +
` + : nothing} + + ${renderChannelConfigSection({ channelId: "discord", props })}
- ${discord?.lastError - ? html`
- ${discord.lastError} -
` - : nothing} + `; diff --git a/ui/src/ui/views/channels.googlechat.ts b/ui/src/ui/views/channels.googlechat.ts index a014ac89e..1f463685e 100644 --- a/ui/src/ui/views/channels.googlechat.ts +++ b/ui/src/ui/views/channels.googlechat.ts @@ -11,63 +11,83 @@ export function renderGoogleChatCard(params: { accountCountLabel: unknown; }) { const { props, googleChat, accountCountLabel } = params; + const configDisabled = props.configSaving || props.configSchemaLoading; return html` -
-
Google Chat
-
Chat API webhook status and channel configuration.
- ${accountCountLabel} +
+
+
Google Chat
+
Chat API webhook status and channel configuration.
+ ${accountCountLabel} -
-
- Configured - ${googleChat ? (googleChat.configured ? "Yes" : "No") : "n/a"} -
-
- Running - ${googleChat ? (googleChat.running ? "Yes" : "No") : "n/a"} -
-
- Credential - ${googleChat?.credentialSource ?? "n/a"} -
-
- Audience - - ${googleChat?.audienceType - ? `${googleChat.audienceType}${googleChat.audience ? ` · ${googleChat.audience}` : ""}` - : "n/a"} - -
-
- Last start - ${googleChat?.lastStartAt ? formatAgo(googleChat.lastStartAt) : "n/a"} -
-
- Last probe - ${googleChat?.lastProbeAt ? formatAgo(googleChat.lastProbeAt) : "n/a"} +
+
+ Configured + ${googleChat ? (googleChat.configured ? "Yes" : "No") : "n/a"} +
+
+ Running + ${googleChat ? (googleChat.running ? "Yes" : "No") : "n/a"} +
+
+ Credential + ${googleChat?.credentialSource ?? "n/a"} +
+
+ Audience + + ${googleChat?.audienceType + ? `${googleChat.audienceType}${googleChat.audience ? ` · ${googleChat.audience}` : ""}` + : "n/a"} + +
+
+ Last start + ${googleChat?.lastStartAt ? formatAgo(googleChat.lastStartAt) : "n/a"} +
+
+ Last probe + ${googleChat?.lastProbeAt ? formatAgo(googleChat.lastProbeAt) : "n/a"} +
+ + ${googleChat?.probe + ? html`
+ Probe ${googleChat.probe.ok ? "ok" : "failed"} · + ${googleChat.probe.status ?? ""} ${googleChat.probe.error ?? ""} +
` + : nothing} + + ${renderChannelConfigSection({ channelId: "googlechat", props })}
- ${googleChat?.lastError - ? html`
- ${googleChat.lastError} -
` - : nothing} + `; diff --git a/ui/src/ui/views/channels.imessage.ts b/ui/src/ui/views/channels.imessage.ts index 85fd90d03..7a88eca6c 100644 --- a/ui/src/ui/views/channels.imessage.ts +++ b/ui/src/ui/views/channels.imessage.ts @@ -11,51 +11,71 @@ export function renderIMessageCard(params: { accountCountLabel: unknown; }) { const { props, imessage, accountCountLabel } = params; + const configDisabled = props.configSaving || props.configSchemaLoading; return html` -
-
iMessage
-
macOS bridge status and channel configuration.
- ${accountCountLabel} +
+
+
iMessage
+
macOS bridge status and channel configuration.
+ ${accountCountLabel} -
-
- Configured - ${imessage?.configured ? "Yes" : "No"} -
-
- Running - ${imessage?.running ? "Yes" : "No"} -
-
- Last start - ${imessage?.lastStartAt ? formatAgo(imessage.lastStartAt) : "n/a"} -
-
- Last probe - ${imessage?.lastProbeAt ? formatAgo(imessage.lastProbeAt) : "n/a"} +
+
+ Configured + ${imessage?.configured ? "Yes" : "No"} +
+
+ Running + ${imessage?.running ? "Yes" : "No"} +
+
+ Last start + ${imessage?.lastStartAt ? formatAgo(imessage.lastStartAt) : "n/a"} +
+
+ Last probe + ${imessage?.lastProbeAt ? formatAgo(imessage.lastProbeAt) : "n/a"} +
+ + ${imessage?.probe + ? html`
+ Probe ${imessage.probe.ok ? "ok" : "failed"} · + ${imessage.probe.error ?? ""} +
` + : nothing} + + ${renderChannelConfigSection({ channelId: "imessage", props })}
- ${imessage?.lastError - ? html`
- ${imessage.lastError} -
` - : nothing} + `; diff --git a/ui/src/ui/views/channels.nostr-profile-form.ts b/ui/src/ui/views/channels.nostr-profile-form.ts index 8565d8ef9..2df508bee 100644 --- a/ui/src/ui/views/channels.nostr-profile-form.ts +++ b/ui/src/ui/views/channels.nostr-profile-form.ts @@ -235,36 +235,33 @@ export function renderNostrProfileForm(params: { : nothing}
- + - + - + - +
${isDirty diff --git a/ui/src/ui/views/channels.nostr.ts b/ui/src/ui/views/channels.nostr.ts index 05152d80b..757925441 100644 --- a/ui/src/ui/views/channels.nostr.ts +++ b/ui/src/ui/views/channels.nostr.ts @@ -50,6 +50,7 @@ export function renderNostrCard(params: { const summaryLastError = nostr?.lastError ?? primaryAccount?.lastError ?? null; const hasMultipleAccounts = nostrAccounts.length > 1; const showingForm = profileFormState !== null && profileFormState !== undefined; + const configDisabled = props.configSaving || props.configSchemaLoading; const renderAccountCard = (account: ChannelAccountSnapshot) => { const publicKey = (account as { publicKey?: string }).publicKey; @@ -120,13 +121,12 @@ export function renderNostrCard(params: {
Profile
${summaryConfigured ? html` - + ` : nothing}
@@ -167,50 +167,69 @@ export function renderNostrCard(params: { }; return html` -
-
Nostr
-
Decentralized DMs via Nostr relays (NIP-04).
- ${accountCountLabel} +
+
+
Nostr
+
Decentralized DMs via Nostr relays (NIP-04).
+ ${accountCountLabel} - ${hasMultipleAccounts - ? html` - - ` - : html` -
-
- Configured - ${summaryConfigured ? "Yes" : "No"} + ${hasMultipleAccounts + ? html` + -
- Running - ${summaryRunning ? "Yes" : "No"} + ` + : html` +
+
+ Configured + ${summaryConfigured ? "Yes" : "No"} +
+
+ Running + ${summaryRunning ? "Yes" : "No"} +
+
+ Public Key + ${truncatePubkey(summaryPublicKey)} +
+
+ Last start + ${summaryLastStartAt ? formatAgo(summaryLastStartAt) : "n/a"} +
-
- Public Key - ${truncatePubkey(summaryPublicKey)} -
-
- Last start - ${summaryLastStartAt ? formatAgo(summaryLastStartAt) : "n/a"} -
-
- `} + `} - ${summaryLastError - ? html`
${summaryLastError}
` - : nothing} + ${renderProfileSection()} - ${renderProfileSection()} + ${renderChannelConfigSection({ channelId: "nostr", props })} +
- ${renderChannelConfigSection({ channelId: "nostr", props })} + `; diff --git a/ui/src/ui/views/channels.signal.ts b/ui/src/ui/views/channels.signal.ts index 9d4f6c147..9657aa36b 100644 --- a/ui/src/ui/views/channels.signal.ts +++ b/ui/src/ui/views/channels.signal.ts @@ -11,55 +11,75 @@ export function renderSignalCard(params: { accountCountLabel: unknown; }) { const { props, signal, accountCountLabel } = params; + const configDisabled = props.configSaving || props.configSchemaLoading; return html` -
-
Signal
-
signal-cli status and channel configuration.
- ${accountCountLabel} +
+
+
Signal
+
signal-cli status and channel configuration.
+ ${accountCountLabel} -
-
- Configured - ${signal?.configured ? "Yes" : "No"} -
-
- Running - ${signal?.running ? "Yes" : "No"} -
-
- Base URL - ${signal?.baseUrl ?? "n/a"} -
-
- Last start - ${signal?.lastStartAt ? formatAgo(signal.lastStartAt) : "n/a"} -
-
- Last probe - ${signal?.lastProbeAt ? formatAgo(signal.lastProbeAt) : "n/a"} +
+
+ Configured + ${signal?.configured ? "Yes" : "No"} +
+
+ Running + ${signal?.running ? "Yes" : "No"} +
+
+ Base URL + ${signal?.baseUrl ?? "n/a"} +
+
+ Last start + ${signal?.lastStartAt ? formatAgo(signal.lastStartAt) : "n/a"} +
+
+ Last probe + ${signal?.lastProbeAt ? formatAgo(signal.lastProbeAt) : "n/a"} +
+ + ${signal?.probe + ? html`
+ Probe ${signal.probe.ok ? "ok" : "failed"} · + ${signal.probe.status ?? ""} ${signal.probe.error ?? ""} +
` + : nothing} + + ${renderChannelConfigSection({ channelId: "signal", props })}
- ${signal?.lastError - ? html`
- ${signal.lastError} -
` - : nothing} + `; diff --git a/ui/src/ui/views/channels.slack.ts b/ui/src/ui/views/channels.slack.ts index eb93ac4c3..b0740a948 100644 --- a/ui/src/ui/views/channels.slack.ts +++ b/ui/src/ui/views/channels.slack.ts @@ -11,51 +11,71 @@ export function renderSlackCard(params: { accountCountLabel: unknown; }) { const { props, slack, accountCountLabel } = params; + const configDisabled = props.configSaving || props.configSchemaLoading; return html` -
-
Slack
-
Socket mode status and channel configuration.
- ${accountCountLabel} +
+
+
Slack
+
Socket mode status and channel configuration.
+ ${accountCountLabel} -
-
- Configured - ${slack?.configured ? "Yes" : "No"} -
-
- Running - ${slack?.running ? "Yes" : "No"} -
-
- Last start - ${slack?.lastStartAt ? formatAgo(slack.lastStartAt) : "n/a"} -
-
- Last probe - ${slack?.lastProbeAt ? formatAgo(slack.lastProbeAt) : "n/a"} +
+
+ Configured + ${slack?.configured ? "Yes" : "No"} +
+
+ Running + ${slack?.running ? "Yes" : "No"} +
+
+ Last start + ${slack?.lastStartAt ? formatAgo(slack.lastStartAt) : "n/a"} +
+
+ Last probe + ${slack?.lastProbeAt ? formatAgo(slack.lastProbeAt) : "n/a"} +
+ + ${slack?.probe + ? html`
+ Probe ${slack.probe.ok ? "ok" : "failed"} · + ${slack.probe.status ?? ""} ${slack.probe.error ?? ""} +
` + : nothing} + + ${renderChannelConfigSection({ channelId: "slack", props })}
- ${slack?.lastError - ? html`
- ${slack.lastError} -
` - : nothing} + `; diff --git a/ui/src/ui/views/channels.telegram.ts b/ui/src/ui/views/channels.telegram.ts index 498d98f87..2b91cee62 100644 --- a/ui/src/ui/views/channels.telegram.ts +++ b/ui/src/ui/views/channels.telegram.ts @@ -13,6 +13,7 @@ export function renderTelegramCard(params: { }) { const { props, telegram, telegramAccounts, accountCountLabel } = params; const hasMultipleAccounts = telegramAccounts.length > 1; + const configDisabled = props.configSaving || props.configSchemaLoading; const renderAccountCard = (account: ChannelAccountSnapshot) => { const probe = account.probe as { bot?: { username?: string } } | undefined; @@ -52,61 +53,80 @@ export function renderTelegramCard(params: { }; return html` -
-
Telegram
-
Bot status and channel configuration.
- ${accountCountLabel} +
+
+
Telegram
+
Bot status and channel configuration.
+ ${accountCountLabel} - ${hasMultipleAccounts - ? html` - - ` - : html` -
-
- Configured - ${telegram?.configured ? "Yes" : "No"} + ${hasMultipleAccounts + ? html` + -
- Running - ${telegram?.running ? "Yes" : "No"} + ` + : html` +
+
+ Configured + ${telegram?.configured ? "Yes" : "No"} +
+
+ Running + ${telegram?.running ? "Yes" : "No"} +
+
+ Mode + ${telegram?.mode ?? "n/a"} +
+
+ Last start + ${telegram?.lastStartAt ? formatAgo(telegram.lastStartAt) : "n/a"} +
+
+ Last probe + ${telegram?.lastProbeAt ? formatAgo(telegram.lastProbeAt) : "n/a"} +
-
- Mode - ${telegram?.mode ?? "n/a"} -
-
- Last start - ${telegram?.lastStartAt ? formatAgo(telegram.lastStartAt) : "n/a"} -
-
- Last probe - ${telegram?.lastProbeAt ? formatAgo(telegram.lastProbeAt) : "n/a"} -
-
- `} + `} - ${telegram?.lastError - ? html`
- ${telegram.lastError} -
` - : nothing} + ${telegram?.probe + ? html`
+ Probe ${telegram.probe.ok ? "ok" : "failed"} · + ${telegram.probe.status ?? ""} ${telegram.probe.error ?? ""} +
` + : nothing} - ${telegram?.probe - ? html`
- Probe ${telegram.probe.ok ? "ok" : "failed"} · - ${telegram.probe.status ?? ""} ${telegram.probe.error ?? ""} -
` - : nothing} + ${renderChannelConfigSection({ channelId: "telegram", props })} +
- ${renderChannelConfigSection({ channelId: "telegram", props })} + `; diff --git a/ui/src/ui/views/channels.whatsapp.ts b/ui/src/ui/views/channels.whatsapp.ts index eae3be695..e3043d809 100644 --- a/ui/src/ui/views/channels.whatsapp.ts +++ b/ui/src/ui/views/channels.whatsapp.ts @@ -5,6 +5,7 @@ import type { WhatsAppStatus } from "../types"; import type { ChannelsProps } from "./channels.types"; import { renderChannelConfigSection } from "./channels.config"; import { formatDuration } from "./channels.shared"; +import "../components/dropdown-menu"; export function renderWhatsAppCard(params: { props: ChannelsProps; @@ -13,106 +14,132 @@ export function renderWhatsAppCard(params: { }) { const { props, whatsapp, accountCountLabel } = params; + const menuItems = [ + { label: "Relink", value: "relink", disabled: props.whatsappBusy }, + { label: "Wait for scan", value: "wait", disabled: props.whatsappBusy }, + { label: "Logout", value: "logout", variant: "danger" as const, disabled: props.whatsappBusy }, + ]; + + const handleMenuSelect = (e: CustomEvent) => { + const { value } = e.detail; + switch (value) { + case "relink": + props.onWhatsAppStart(true); + break; + case "wait": + props.onWhatsAppWait(); + break; + case "logout": + props.onWhatsAppLogout(); + break; + } + }; + + const configDisabled = props.configSaving || props.configSchemaLoading; + return html` -
-
WhatsApp
-
Link WhatsApp Web and monitor connection health.
- ${accountCountLabel} +
+
+
WhatsApp
+
Link WhatsApp Web and monitor connection health.
+ ${accountCountLabel} -
-
- Configured - ${whatsapp?.configured ? "Yes" : "No"} -
-
- Linked - ${whatsapp?.linked ? "Yes" : "No"} -
-
- Running - ${whatsapp?.running ? "Yes" : "No"} -
-
- Connected - ${whatsapp?.connected ? "Yes" : "No"} -
-
- Last connect - - ${whatsapp?.lastConnectedAt - ? formatAgo(whatsapp.lastConnectedAt) - : "n/a"} - -
-
- Last message - - ${whatsapp?.lastMessageAt ? formatAgo(whatsapp.lastMessageAt) : "n/a"} - -
-
- Auth age - - ${whatsapp?.authAgeMs != null - ? formatDuration(whatsapp.authAgeMs) - : "n/a"} - +
+
+ Configured + ${whatsapp?.configured ? "Yes" : "No"} +
+
+ Linked + ${whatsapp?.linked ? "Yes" : "No"} +
+
+ Running + ${whatsapp?.running ? "Yes" : "No"} +
+
+ Connected + ${whatsapp?.connected ? "Yes" : "No"} +
+
+ Last connect + + ${whatsapp?.lastConnectedAt + ? formatAgo(whatsapp.lastConnectedAt) + : "n/a"} + +
+
+ Last message + + ${whatsapp?.lastMessageAt ? formatAgo(whatsapp.lastMessageAt) : "n/a"} + +
+
+ Auth age + + ${whatsapp?.authAgeMs != null + ? formatDuration(whatsapp.authAgeMs) + : "n/a"} + +
+ + ${props.whatsappMessage + ? html`
+ ${props.whatsappMessage} +
` + : nothing} + + ${props.whatsappQrDataUrl + ? html`
+ WhatsApp QR +
` + : nothing} + + ${renderChannelConfigSection({ channelId: "whatsapp", props })}
- ${whatsapp?.lastError - ? html`
- ${whatsapp.lastError} -
` - : nothing} + `; } diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts index dd61ca0ec..dde5eba47 100644 --- a/ui/src/ui/views/chat.ts +++ b/ui/src/ui/views/chat.ts @@ -15,6 +15,7 @@ import { } from "../chat/grouped-render"; import { renderMarkdownSidebar } from "./markdown-sidebar"; import "../components/resizable-divider"; +import "../components/button"; export type CompactionIndicatorStatus = { active: boolean; @@ -218,14 +219,15 @@ export function renderChat(props: ChatProps) { (item) => html`
${item.text}
- +
`, )} @@ -254,20 +256,20 @@ export function renderChat(props: ChatProps) { >
- - +
diff --git a/ui/src/ui/views/config-form.node.ts b/ui/src/ui/views/config-form.node.ts index 9d121d7f1..43faca842 100644 --- a/ui/src/ui/views/config-form.node.ts +++ b/ui/src/ui/views/config-form.node.ts @@ -27,13 +27,18 @@ function jsonValue(value: unknown): string { } // SVG Icons as template literals -const icons = { - chevronDown: html``, - plus: html``, - minus: html``, - trash: html``, - edit: html``, -}; +import "../components"; + +// Helper to wrap generic patch with component events +function handlePatch( + path: Array, + onPatch: (path: Array, value: unknown) => void +) { + return (e: CustomEvent) => { + const { value, checked } = e.detail; + onPatch(path, checked ?? value); + }; +} export function renderNode(params: { schema: JsonSchema; @@ -83,22 +88,21 @@ export function renderNode(params: { if (allLiterals && literals.length > 0 && literals.length <= 5) { // Use segmented control for small sets const resolvedValue = value ?? schema.default; + const options = literals.map((lit) => ({ label: String(lit), value: String(lit) })); + return html`
${showLabel ? html`` : nothing} ${help ? html`
${help}
` : nothing} -
- ${literals.map((lit, idx) => html` - - `)} -
+ { + // Find original literal type + const selected = literals.find(l => String(l) === e.detail.value); + onPatch(path, selected); + }} + >
`; } @@ -142,22 +146,18 @@ export function renderNode(params: { const options = schema.enum; if (options.length <= 5) { const resolvedValue = value ?? schema.default; + const enumOptions = options.map((opt) => ({ label: String(opt), value: String(opt) })); return html`
${showLabel ? html`` : nothing} ${help ? html`
${help}
` : nothing} -
- ${options.map((opt) => html` - - `)} -
+ { + onPatch(path, e.detail.value); + }} + >
`; } @@ -178,21 +178,17 @@ export function renderNode(params: { if (type === "boolean") { const displayValue = typeof value === "boolean" ? value : typeof schema.default === "boolean" ? schema.default : false; return html` -
@@ -297,29 +293,16 @@ function renderNumberInput(params: { ${showLabel ? html`` : nothing} ${help ? html`
${help}
` : nothing}
- - { - const raw = (e.target as HTMLInputElement).value; - const parsed = raw === "" ? undefined : Number(raw); - onPatch(path, parsed); - }} - /> - + @input=${(e: CustomEvent) => { + const raw = e.detail.value; + const parsed = raw === "" ? undefined : Number(raw); + onPatch(path, parsed); + }} + >
`; @@ -350,20 +333,18 @@ function renderSelect(params: {
${showLabel ? html`` : nothing} ${help ? html`
${help}
` : nothing} - + @change=${(e: CustomEvent) => { + const val = e.detail.value; + onPatch(path, val === unset ? undefined : options[Number(val)]); + }} + >
`; } @@ -408,26 +389,26 @@ function renderObject(params: { return html`
${sorted.map(([propKey, node]) => - renderNode({ - schema: node, - value: obj[propKey], - path: [...path, propKey], - hints, - unsupported, - disabled, - onPatch, - }) - )} + renderNode({ + schema: node, + value: obj[propKey], + path: [...path, propKey], + hints, + unsupported, + disabled, + onPatch, + }) + )} ${allowExtra ? renderMapField({ - schema: additional as JsonSchema, - value: obj, - path, - hints, - unsupported, - disabled, - reservedKeys: reserved, - onPatch, - }) : nothing} + schema: additional as JsonSchema, + value: obj, + path, + hints, + unsupported, + disabled, + reservedKeys: reserved, + onPatch, + }) : nothing}
`; } @@ -437,31 +418,31 @@ function renderObject(params: {
${label} - ${icons.chevronDown} + ${help ? html`
${help}
` : nothing}
${sorted.map(([propKey, node]) => - renderNode({ - schema: node, - value: obj[propKey], - path: [...path, propKey], - hints, - unsupported, - disabled, - onPatch, - }) - )} + renderNode({ + schema: node, + value: obj[propKey], + path: [...path, propKey], + hints, + unsupported, + disabled, + onPatch, + }) + )} ${allowExtra ? renderMapField({ - schema: additional as JsonSchema, - value: obj, - path, - hints, - unsupported, - disabled, - reservedKeys: reserved, - onPatch, - }) : nothing} + schema: additional as JsonSchema, + value: obj, + path, + hints, + unsupported, + disabled, + reservedKeys: reserved, + onPatch, + }) : nothing}
`; @@ -499,19 +480,17 @@ function renderArray(params: {
${showLabel ? html`${label}` : nothing} - ${arr.length} item${arr.length !== 1 ? 's' : ''} - + const next = [...arr, defaultValue(itemsSchema)]; + onPatch(path, next); + }} + >Add
${help ? html`
${help}
` : nothing} @@ -525,31 +504,30 @@ function renderArray(params: {
#${idx + 1} - + const next = [...arr]; + next.splice(idx, 1); + onPatch(path, next); + }} + >
${renderNode({ - schema: itemsSchema, - value: item, - path: [...path, idx], - hints, - unsupported, - disabled, - showLabel: false, - onPatch, - })} + schema: itemsSchema, + value: item, + path: [...path, idx], + hints, + unsupported, + disabled, + showLabel: false, + onPatch, + })}
`)} @@ -577,25 +555,23 @@ function renderMapField(params: {
Custom entries - + const next = { ...(value ?? {}) }; + let index = 1; + let key = `custom-${index}`; + while (key in next) { + index += 1; + key = `custom-${index}`; + } + next[key] = anySchema ? {} : defaultValue(schema); + onPatch(path, next); + }} + >Add Entry
${entries.length === 0 ? html` @@ -603,79 +579,75 @@ function renderMapField(params: { ` : html`
${entries.map(([key, entryValue]) => { - const valuePath = [...path, key]; - const fallback = jsonValue(entryValue); - return html` + const valuePath = [...path, key]; + const fallback = jsonValue(entryValue); + return html`
- { - const nextKey = (e.target as HTMLInputElement).value.trim(); - if (!nextKey || nextKey === key) return; - const next = { ...(value ?? {}) }; - if (nextKey in next) return; - next[nextKey] = next[key]; - delete next[key]; - onPatch(path, next); - }} - /> + @change=${(e: CustomEvent) => { + const nextKey = e.detail.value.trim(); + if (!nextKey || nextKey === key) return; + const next = { ...(value ?? {}) }; + if (nextKey in next) return; + next[nextKey] = next[key]; + delete next[key]; + onPatch(path, next); + }} + >
${anySchema - ? html` - + @change=${(e: CustomEvent) => { + const raw = e.detail.value.trim(); + if (!raw) { + onPatch(valuePath, undefined); + return; + } + try { + onPatch(valuePath, JSON.parse(raw)); + } catch { + // target.value = fallback; // ui-textarea handles its own value + } + }} + > ` - : renderNode({ - schema, - value: entryValue, - path: valuePath, - hints, - unsupported, - disabled, - showLabel: false, - onPatch, - })} + : renderNode({ + schema, + value: entryValue, + path: valuePath, + hints, + unsupported, + disabled, + showLabel: false, + onPatch, + })}
- + const next = { ...(value ?? {}) }; + delete next[key]; + onPatch(path, next); + }} + >
`; - })} + })}
`}
diff --git a/ui/src/ui/views/config.ts b/ui/src/ui/views/config.ts index ff6f57f32..1d4c87baf 100644 --- a/ui/src/ui/views/config.ts +++ b/ui/src/ui/views/config.ts @@ -7,6 +7,10 @@ import { schemaType, type JsonSchema, } from "./config-form.shared"; +import "../components/button"; +import "../components/badge"; +import "../components/input"; +import "../components/textarea"; export type ConfigProps = { raw: string; @@ -261,17 +265,12 @@ export function renderConfig(props: ConfigProps) {
- - - - +
@@ -451,14 +450,15 @@ export function renderConfig(props: ConfigProps) { : nothing} ` : html` - + .rows=${25} + @input=${(e: CustomEvent) => + props.onRawChange(e.detail.value)} + > +
`}
diff --git a/ui/src/ui/views/cron.ts b/ui/src/ui/views/cron.ts index d25e3eb45..e4aabfabe 100644 --- a/ui/src/ui/views/cron.ts +++ b/ui/src/ui/views/cron.ts @@ -64,10 +64,10 @@ export function renderCron(props: CronProps) {
Enabled
${props.status - ? props.status.enabled - ? "Yes" - : "No" - : "n/a"} + ? props.status.enabled + ? "Yes" + : "No" + : "n/a"}
@@ -96,7 +96,7 @@ export function renderCron(props: CronProps) { - props.onFormChange({ name: (e.target as HTMLInputElement).value })} + props.onFormChange({ name: (e.target as HTMLInputElement).value })} /> - + @change=${(e: CustomEvent) => + props.onFormChange({ enabled: e.detail.checked })} + > +
${props.form.payloadKind === "agentTurn" - ? html` + ? html`
+ @@ -238,28 +237,28 @@ export function renderCron(props: CronProps) { - props.onFormChange({ - timeoutSeconds: (e.target as HTMLInputElement).value, - })} + props.onFormChange({ + timeoutSeconds: (e.target as HTMLInputElement).value, + })} /> ${props.form.sessionTarget === "isolated" - ? html` + ? html` ` - : nothing} + : nothing}
` - : nothing} + : nothing}
@@ -392,9 +391,9 @@ function renderJob(job: CronJob, props: CronProps) { class="btn" ?disabled=${props.busy} @click=${(event: Event) => { - event.stopPropagation(); - props.onToggle(job, !job.enabled); - }} + event.stopPropagation(); + props.onToggle(job, !job.enabled); + }} > ${job.enabled ? "Disable" : "Enable"} @@ -402,9 +401,9 @@ function renderJob(job: CronJob, props: CronProps) { class="btn" ?disabled=${props.busy} @click=${(event: Event) => { - event.stopPropagation(); - props.onRun(job); - }} + event.stopPropagation(); + props.onRun(job); + }} > Run @@ -412,9 +411,9 @@ function renderJob(job: CronJob, props: CronProps) { class="btn" ?disabled=${props.busy} @click=${(event: Event) => { - event.stopPropagation(); - props.onLoadRuns(job.id); - }} + event.stopPropagation(); + props.onLoadRuns(job.id); + }} > Runs @@ -422,9 +421,9 @@ function renderJob(job: CronJob, props: CronProps) { class="btn danger" ?disabled=${props.busy} @click=${(event: Event) => { - event.stopPropagation(); - props.onRemove(job); - }} + event.stopPropagation(); + props.onRemove(job); + }} > Remove diff --git a/ui/src/ui/views/debug.ts b/ui/src/ui/views/debug.ts index 35e2e1af2..8f1115225 100644 --- a/ui/src/ui/views/debug.ts +++ b/ui/src/ui/views/debug.ts @@ -2,6 +2,8 @@ import { html, nothing } from "lit"; import { formatEventPayload } from "../presenter"; import type { EventLogEntry } from "../app-events"; +import "../components/button"; +import "../components/input"; export type DebugProps = { loading: boolean; @@ -29,9 +31,9 @@ export function renderDebug(props: DebugProps) {
Snapshots
Status, health, and heartbeat data.
- +
@@ -52,28 +54,26 @@ export function renderDebug(props: DebugProps) {
Manual RPC
Send a raw gateway method with JSON params.
-
- +
+ props.onCallMethodChange(e.detail.value)} + >
-
- +
+ Call
${props.callError ? html`
diff --git a/ui/src/ui/views/logs.ts b/ui/src/ui/views/logs.ts index 6da434dbe..c3ef33c47 100644 --- a/ui/src/ui/views/logs.ts +++ b/ui/src/ui/views/logs.ts @@ -1,6 +1,9 @@ import { html, nothing } from "lit"; import type { LogEntry, LogLevel } from "../types"; +import "../components/button"; +import "../components/input"; +import "../components/checkbox"; const LEVELS: LogLevel[] = ["trace", "debug", "info", "warn", "error", "fatal"]; @@ -54,73 +57,70 @@ export function renderLogs(props: LogsProps) {
Gateway file logs (JSONL).
- - +
-
- -
-
+
${LEVELS.map( - (level) => html` -
${props.file - ? html`
File: ${props.file}
` - : nothing} + ? html`
File: ${props.file}
` + : nothing} ${props.truncated - ? html`
+ ? html`
Log output truncated; showing latest chunk.
` - : nothing} + : nothing} ${props.error - ? html`
${props.error}
` - : nothing} + ? html`
${props.error}
` + : nothing} -
+
${filtered.length === 0 - ? html`
No log entries.
` - : filtered.map( - (entry) => html` + ? html`
No log entries.
` + : filtered.map( + (entry) => html`
${formatTime(entry.time)}
${entry.level ?? ""}
@@ -128,7 +128,7 @@ export function renderLogs(props: LogsProps) {
${entry.message ?? entry.raw}
`, - )} + )}
`; diff --git a/ui/src/ui/views/nodes.ts b/ui/src/ui/views/nodes.ts index 31beca988..bb57ddb46 100644 --- a/ui/src/ui/views/nodes.ts +++ b/ui/src/ui/views/nodes.ts @@ -12,6 +12,12 @@ import type { PairedDevice, PendingDevice, } from "../controllers/devices"; +import "../components/button"; +import "../components/badge"; +import "../components/input"; +import "../components/card"; +import "../components/checkbox"; +import "../components/select"; export type NodesProps = { loading: boolean; @@ -57,22 +63,24 @@ export function renderNodes(props: NodesProps) { ${renderExecApprovals(approvalsState)} ${renderBindings(bindingState)} ${renderDevices(props)} -
-
-
-
Nodes
-
Paired devices and live links.
+ +
+
+
+
Nodes
+
Paired devices and live links.
+
+ + ${props.loading ? "Loading…" : "Refresh"} +
-
-
+
${props.nodes.length === 0 - ? html`
No nodes found.
` - : props.nodes.map((n) => renderNode(n))} + ? html`
No nodes found.
` + : props.nodes.map((n) => renderNode(n))}
-
+ `; } @@ -81,37 +89,39 @@ function renderDevices(props: NodesProps) { const pending = Array.isArray(list.pending) ? list.pending : []; const paired = Array.isArray(list.paired) ? list.paired : []; return html` -
-
-
-
Devices
-
Pairing requests + role tokens.
+ +
+
+
+
Devices
+
Pairing requests + role tokens.
+
+ + ${props.devicesLoading ? "Loading…" : "Refresh"} +
-
${props.devicesError - ? html`
${props.devicesError}
` - : nothing} -
+ ? html`
${props.devicesError}
` + : nothing} +
${pending.length > 0 - ? html` + ? html`
Pending
${pending.map((req) => renderPendingDevice(req, props))} ` - : nothing} + : nothing} ${paired.length > 0 - ? html` + ? html`
Paired
${paired.map((device) => renderPairedDevice(device, props))} ` - : nothing} + : nothing} ${pending.length === 0 && paired.length === 0 - ? html`
No paired devices.
` - : nothing} + ? html`
No paired devices.
` + : nothing}
-
+ `; } @@ -132,12 +142,12 @@ function renderPendingDevice(req: PendingDevice, props: NodesProps) {
- - +
@@ -157,8 +167,8 @@ function renderPairedDevice(device: PairedDevice, props: NodesProps) {
${device.deviceId}${ip}
${roles} · ${scopes}
${tokens.length === 0 - ? html`
Tokens: none
` - : html` + ? html`
Tokens: none
` + : html`
Tokens
${tokens.map((token) => renderTokenRow(device.deviceId, token, props))} @@ -177,21 +187,22 @@ function renderTokenRow(deviceId: string, token: DeviceTokenSummary, props: Node
${token.role} · ${status} · ${scopes} · ${when}
- + ${token.revokedAtMs - ? nothing - : html` - + `}
@@ -399,11 +410,11 @@ function resolveExecApprovalsState(props: NodesProps): ExecApprovalsState { const selectedAgent = selectedScope !== EXEC_APPROVALS_DEFAULT_SCOPE ? ((form?.agents ?? {})[selectedScope] as Record | undefined) ?? - null + null : null; const allowlist = Array.isArray((selectedAgent as { allowlist?: unknown })?.allowlist) ? ((selectedAgent as { allowlist?: ExecApprovalsAllowlistEntry[] }).allowlist ?? - []) + []) : []; return { ready, @@ -433,80 +444,71 @@ function renderBindings(state: BindingState) { const supportsBinding = state.nodes.length > 0; const defaultValue = state.defaultBinding ?? ""; return html` -
-
+ +
Exec node binding
-
+
Pin agents to a specific node when using exec host=node.
- +
${state.formMode === "raw" - ? html`
+ ? html`
Switch the Config tab to Form mode to edit bindings here.
` - : nothing} + : nothing} ${!state.ready - ? html`
+ ? html`
Load config to edit bindings.
- +
` - : html` -
+ : html` +
Default binding
Used when agents do not override a node binding.
- + ({ label: n.label, value: n.id })) + ]} + @change=${(event: CustomEvent) => { + const value = event.detail.value.trim(); + state.onBindDefault(value ? value : null); + }} + > ${!supportsBinding - ? html`
No nodes with system.run available.
` - : nothing} + ? html`
No nodes with system.run available.
` + : nothing}
${state.agents.length === 0 - ? html`
No agents found.
` - : state.agents.map((agent) => - renderAgentBinding(agent, state), - )} + ? html`
No agents found.
` + : state.agents.map((agent) => + renderAgentBinding(agent, state), + )}
`} -
+ `; } @@ -514,40 +516,40 @@ function renderExecApprovals(state: ExecApprovalsState) { const ready = state.ready; const targetReady = state.target !== "node" || Boolean(state.targetNodeId); return html` -
-
+ +
Exec approvals
-
+
Allowlist and approval policy for exec host=gateway/node.
- +
${renderExecApprovalsTarget(state)} ${!ready - ? html`
+ ? html`
Load exec approvals to edit allowlists.
- +
` - : html` + : html` ${renderExecApprovalsTabs(state)} ${renderExecApprovalsPolicy(state)} ${state.selectedScope === EXEC_APPROVALS_DEFAULT_SCOPE - ? nothing - : renderExecApprovalsAllowlist(state)} + ? nothing + : renderExecApprovalsAllowlist(state)} `} -
+ `; } @@ -555,7 +557,7 @@ function renderExecApprovalsTarget(state: ExecApprovalsState) { const hasNodes = state.targetNodes.length > 0; const nodeValue = state.targetNodeId ?? ""; return html` -
+
Target
@@ -564,56 +566,46 @@ function renderExecApprovalsTarget(state: ExecApprovalsState) {
- + { + const value = event.detail.value; + if (value === "node") { + const first = state.targetNodes[0]?.id ?? null; + state.onSelectTarget("node", nodeValue || first); + } else { + state.onSelectTarget("gateway", null); + } + }} + > ${state.target === "node" - ? html` - + ? html` + ({ label: n.label, value: n.id })) + ]} + @change=${(event: CustomEvent) => { + const value = event.detail.value.trim(); + state.onSelectTarget("node", value ? value : null); + }} + > ` - : nothing} + : nothing}
${state.target === "node" && !hasNodes - ? html`
No nodes advertise exec approvals yet.
` - : nothing} + ? html`
No nodes advertise exec approvals yet.
` + : nothing}
`; } @@ -623,23 +615,25 @@ function renderExecApprovalsTabs(state: ExecApprovalsState) {
Scope
- + ${state.agents.map((agent) => { - const label = agent.name?.trim() ? `${agent.name} (${agent.id})` : agent.id; - return html` - + `; - })} + })}
`; @@ -671,8 +665,8 @@ function renderExecApprovalsPolicy(state: ExecApprovalsState) {
Security
${isDefaults - ? "Default security mode." - : `Default: ${defaults.security}.`} + ? "Default security mode." + : `Default: ${defaults.security}.`}
@@ -681,29 +675,29 @@ function renderExecApprovalsPolicy(state: ExecApprovalsState) {
@@ -722,29 +716,29 @@ function renderExecApprovalsPolicy(state: ExecApprovalsState) {
@@ -755,8 +749,8 @@ function renderExecApprovalsPolicy(state: ExecApprovalsState) {
Ask fallback
${isDefaults - ? "Applied when the UI prompt is unavailable." - : `Default: ${defaults.askFallback}.`} + ? "Applied when the UI prompt is unavailable." + : `Default: ${defaults.askFallback}.`}
@@ -765,29 +759,29 @@ function renderExecApprovalsPolicy(state: ExecApprovalsState) {
@@ -798,10 +792,10 @@ function renderExecApprovalsPolicy(state: ExecApprovalsState) {
Auto-allow skill CLIs
${isDefaults - ? "Allow skill executables listed by the Gateway." - : autoIsDefault - ? `Using default (${defaults.autoAllowSkills ? "on" : "off"}).` - : `Override (${autoEffective ? "on" : "off"}).`} + ? "Allow skill executables listed by the Gateway." + : autoIsDefault + ? `Using default (${defaults.autoAllowSkills ? "on" : "off"}).` + : `Override (${autoEffective ? "on" : "off"}).`}
@@ -812,20 +806,20 @@ function renderExecApprovalsPolicy(state: ExecApprovalsState) { ?disabled=${state.disabled} .checked=${autoEffective} @change=${(event: Event) => { - const target = event.target as HTMLInputElement; - state.onPatch([...basePath, "autoAllowSkills"], target.checked); - }} + const target = event.target as HTMLInputElement; + state.onPatch([...basePath, "autoAllowSkills"], target.checked); + }} /> ${!isDefaults && !autoIsDefault - ? html`` - : nothing} + : nothing}
@@ -845,19 +839,19 @@ function renderExecApprovalsAllowlist(state: ExecApprovalsState) { class="btn btn--sm" ?disabled=${state.disabled} @click=${() => { - const next = [...entries, { pattern: "" }]; - state.onPatch(allowlistPath, next); - }} + const next = [...entries, { pattern: "" }]; + state.onPatch(allowlistPath, next); + }} > Add pattern
${entries.length === 0 - ? html`
No allowlist entries yet.
` - : entries.map((entry, index) => - renderAllowlistEntry(state, entry, index), - )} + ? html`
No allowlist entries yet.
` + : entries.map((entry, index) => + renderAllowlistEntry(state, entry, index), + )}
`; } @@ -890,24 +884,24 @@ function renderAllowlistEntry( .value=${entry.pattern ?? ""} ?disabled=${state.disabled} @input=${(event: Event) => { - const target = event.target as HTMLInputElement; - state.onPatch( - ["agents", state.selectedScope, "allowlist", index, "pattern"], - target.value, - ); - }} + const target = event.target as HTMLInputElement; + state.onPatch( + ["agents", state.selectedScope, "allowlist", index, "pattern"], + target.value, + ); + }} /> @@ -927,8 +921,8 @@ function renderAgentBinding(agent: BindingAgent, state: BindingState) {
${agent.isDefault ? "default agent" : "agent"} · ${bindingValue === "__default__" - ? `uses default (${state.defaultBinding ?? "any"})` - : `override: ${agent.binding}`} + ? `uses default (${state.defaultBinding ?? "any"})` + : `override: ${agent.binding}`}
@@ -937,23 +931,23 @@ function renderAgentBinding(agent: BindingAgent, state: BindingState) {
@@ -1078,8 +1072,8 @@ function renderNode(node: Record) { ${caps.slice(0, 12).map((c) => html`${String(c)}`)} ${commands - .slice(0, 8) - .map((c) => html`${String(c)}`)} + .slice(0, 8) + .map((c) => html`${String(c)}`)}
diff --git a/ui/src/ui/views/overview.ts b/ui/src/ui/views/overview.ts index 6b51ed811..0133641ec 100644 --- a/ui/src/ui/views/overview.ts +++ b/ui/src/ui/views/overview.ts @@ -4,6 +4,10 @@ import type { GatewayHelloOk } from "../gateway"; import { formatAgo, formatDurationMs } from "../format"; import { formatNextRun } from "../presenter"; import type { UiSettings } from "../storage"; +import "../components/button"; +import "../components/input"; +import "../components/card"; +import "../components/badge"; export type OverviewProps = { connected: boolean; @@ -118,70 +122,64 @@ export function renderOverview(props: OverviewProps) { return html`
-
-
Gateway Access
-
Where the dashboard connects and how it authenticates.
-
- - - - + +
+
Gateway Access
+
Where the dashboard connects and how it authenticates.
+
+
+ { + props.onSettingsChange({ ...props.settings, gatewayUrl: e.detail.value }); + }} + > + { + props.onSettingsChange({ ...props.settings, token: e.detail.value }); + }} + > + { + props.onPasswordChange(e.detail.value); + }} + > + { + props.onSessionKeyChange(e.detail.value); + }} + >
- - + props.onConnect()}>Connect + props.onRefresh()}>Refresh Click Connect to apply connection changes.
-
+ -
-
Snapshot
-
Latest gateway handshake information.
-
+ +
+
Snapshot
+
Latest gateway handshake information.
+
+
Status
-
- ${props.connected ? "Connected" : "Disconnected"} +
+ + ${props.connected ? "Connected" : "Disconnected"} +
@@ -210,21 +208,21 @@ export function renderOverview(props: OverviewProps) { : html`
Use Channels to link WhatsApp, Telegram, Discord, Signal, or iMessage.
`} -
+
-
+
Instances
${props.presenceCount}
Presence beacons in the last 5 minutes.
-
-
+ +
Sessions
${props.sessionsCount ?? "n/a"}
Recent session keys tracked by the gateway.
-
-
+ +
Cron
${props.cronEnabled == null @@ -234,13 +232,15 @@ export function renderOverview(props: OverviewProps) { : "Disabled"}
Next wake ${formatNextRun(props.cronNext)}
-
+
-
-
Notes
-
Quick reminders for remote control setups.
-
+ +
+
Notes
+
Quick reminders for remote control setups.
+
+
Tailscale serve
@@ -256,6 +256,6 @@ export function renderOverview(props: OverviewProps) {
Use isolated sessions for recurring runs.
-
+ `; } diff --git a/ui/src/ui/views/sessions.ts b/ui/src/ui/views/sessions.ts index 7b5e97eb7..74e781687 100644 --- a/ui/src/ui/views/sessions.ts +++ b/ui/src/ui/views/sessions.ts @@ -4,6 +4,9 @@ import { formatAgo } from "../format"; import { formatSessionTokens } from "../presenter"; import { pathForTab } from "../navigation"; import type { GatewaySessionRow, SessionsListResult } from "../types"; +import "../components/button"; +import "../components/input"; +import "../components/checkbox"; export type SessionsProps = { loading: boolean; @@ -79,71 +82,65 @@ export function renderSessions(props: SessionsProps) {
Sessions
Active session keys and per-session overrides.
- +
-
- - -
${props.error - ? html`
${props.error}
` - : nothing} + ? html`
${props.error}
` + : nothing}
${props.result ? `Store: ${props.result.path}` : ""} @@ -162,10 +159,10 @@ export function renderSessions(props: SessionsProps) {
Actions
${rows.length === 0 - ? html`
No sessions found.
` - : rows.map((row) => - renderRow(row, props.basePath, props.onPatch, props.onDelete, props.loading), - )} + ? html`
No sessions found.
` + : rows.map((row) => + renderRow(row, props.basePath, props.onPatch, props.onDelete, props.loading), + )}
`; @@ -194,17 +191,17 @@ function renderRow( return html`
${canLink - ? html`${displayName}` - : displayName}
+ ? html`${displayName}` + : displayName}
{ - const value = (e.target as HTMLInputElement).value.trim(); - onPatch(row.key, { label: value || null }); - }} + const value = (e.target as HTMLInputElement).value.trim(); + onPatch(row.key, { label: value || null }); + }} />
${row.kind}
@@ -215,15 +212,15 @@ function renderRow( .value=${thinking} ?disabled=${disabled} @change=${(e: Event) => { - const value = (e.target as HTMLSelectElement).value; - onPatch(row.key, { - thinkingLevel: resolveThinkLevelPatchValue(value, isBinaryThinking), - }); - }} + const value = (e.target as HTMLSelectElement).value; + onPatch(row.key, { + thinkingLevel: resolveThinkLevelPatchValue(value, isBinaryThinking), + }); + }} > ${thinkLevels.map((level) => - html``, - )} + html``, + )}
@@ -231,13 +228,13 @@ function renderRow( .value=${verbose} ?disabled=${disabled} @change=${(e: Event) => { - const value = (e.target as HTMLSelectElement).value; - onPatch(row.key, { verboseLevel: value || null }); - }} + const value = (e.target as HTMLSelectElement).value; + onPatch(row.key, { verboseLevel: value || null }); + }} > ${VERBOSE_LEVELS.map( - (level) => html``, - )} + (level) => html``, + )}
@@ -245,19 +242,19 @@ function renderRow( .value=${reasoning} ?disabled=${disabled} @change=${(e: Event) => { - const value = (e.target as HTMLSelectElement).value; - onPatch(row.key, { reasoningLevel: value || null }); - }} + const value = (e.target as HTMLSelectElement).value; + onPatch(row.key, { reasoningLevel: value || null }); + }} > ${REASONING_LEVELS.map((level) => - html``, - )} + html``, + )}
- +
`;