This commit is contained in:
justelson 2026-01-28 18:12:32 -05:00 committed by GitHub
commit 2a2ecd5646
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
52 changed files with 4708 additions and 2139 deletions

6
.gitignore vendored
View File

@ -71,3 +71,9 @@ USER.md
# local tooling # local tooling
.serena/ .serena/
# AI Powers (external project resources)
.ai-powers/
# UI Redesign workspace
.redesign/

View File

@ -5,7 +5,13 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Moltbot Control</title> <title>Moltbot Control</title>
<meta name="color-scheme" content="dark light" /> <meta name="color-scheme" content="dark light" />
<meta name="theme-color" content="#1A1816" media="(prefers-color-scheme: dark)" />
<meta name="theme-color" content="#FAFAF9" media="(prefers-color-scheme: light)" />
<link rel="icon" href="/favicon.ico" sizes="any" /> <link rel="icon" href="/favicon.ico" sizes="any" />
<!-- Poppins Font -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
</head> </head>
<body> <body>
<moltbot-app></moltbot-app> <moltbot-app></moltbot-app>

View File

@ -1,4 +1,5 @@
@import "./styles/base.css"; @import "./styles/base.css";
@import "./styles/color-themes.css";
@import "./styles/layout.css"; @import "./styles/layout.css";
@import "./styles/layout.mobile.css"; @import "./styles/layout.mobile.css";
@import "./styles/components.css"; @import "./styles/components.css";

View File

@ -1,78 +1,136 @@
@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 { :root,
/* Background - Warmer dark with depth */ :root[data-color-theme="tangerine"] {
--bg: #12141a; /* ============================================
--bg-accent: #14161d; COLOR SYSTEM - DARK MODE (Default)
--bg-elevated: #1a1d25; ============================================ */
--bg-hover: #262a35;
--bg-muted: #262a35;
/* Card / Surface - More contrast between levels */ /* Background Colors - Muted Dark Theme */
--card: #181b22; --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-foreground: #f4f4f5;
--card-highlight: rgba(255, 255, 255, 0.05); --card-highlight: rgba(255, 159, 64, 0.03);
--popover: #181b22; --popover: var(--color-bg-secondary);
--popover-foreground: #f4f4f5; --popover-foreground: #f4f4f5;
/* Panel */ /* Panel */
--panel: #12141a; --panel: var(--color-bg-primary);
--panel-strong: #1a1d25; --panel-strong: var(--color-bg-elevated);
--panel-hover: #262a35; --panel-hover: var(--color-bg-tertiary);
--chrome: rgba(18, 20, 26, 0.95); --chrome: rgba(26, 24, 22, 0.95);
--chrome-strong: rgba(18, 20, 26, 0.98); --chrome-strong: rgba(26, 24, 22, 0.98);
/* Text - Slightly warmer */ /* Text Colors - High Contrast */
--text: #e4e4e7; --color-text-primary: #FFFFFF;
--text-strong: #fafafa; --color-text-secondary: #E0E0E0;
--chat-text: #e4e4e7; --color-text-tertiary: #B0B0B0;
--muted: #71717a; --color-text-disabled: #666666;
--muted-strong: #52525b; --color-text-inverse: #1A1A1A;
--muted-foreground: #71717a;
/* Border - Subtle but defined */ /* Legacy aliases */
--border: #27272a; --text: var(--color-text-secondary);
--border-strong: #3f3f46; --text-strong: var(--color-text-primary);
--border-hover: #52525b; --chat-text: var(--color-text-secondary);
--input: #27272a; --muted: var(--color-text-tertiary);
--ring: #ff5c5c; --muted-strong: #71717a;
--muted-foreground: var(--color-text-tertiary);
/* Accent - Punchy signature red */ /* Accent Colors - Tangerine Theme */
--accent: #ff5c5c; --color-accent-primary: #FF9F40;
--accent-hover: #ff7070; --color-accent-primary-hover: #FF8C2E;
--accent-muted: #ff5c5c; --color-accent-primary-active: #FF7A1C;
--accent-subtle: rgba(255, 92, 92, 0.15); --color-accent-secondary: #FFB366;
--accent-foreground: #fafafa;
--accent-glow: rgba(255, 92, 92, 0.25); /* Legacy aliases */
--primary: #ff5c5c; --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; --primary-foreground: #ffffff;
/* Secondary - Teal accent for variety */ /* Secondary Accent - Teal */
--secondary: #1e2028; --secondary: var(--color-bg-secondary);
--secondary-foreground: #f4f4f5; --secondary-foreground: #f4f4f5;
--accent-2: #14b8a6; --accent-2: #14b8a6;
--accent-2-muted: rgba(20, 184, 166, 0.7); --accent-2-muted: rgba(20, 184, 166, 0.7);
--accent-2-subtle: rgba(20, 184, 166, 0.15); --accent-2-subtle: rgba(20, 184, 166, 0.15);
/* Semantic - More saturated */ /* Semantic Colors - Dark Mode */
--ok: #22c55e; --color-accent-success: #4CAF50;
--ok-muted: rgba(34, 197, 94, 0.75); --color-accent-success-hover: #45A049;
--ok-subtle: rgba(34, 197, 94, 0.12); --color-accent-error: #FF5252;
--destructive: #ef4444; --color-accent-error-hover: #FF3838;
--destructive-foreground: #fafafa; --color-accent-warning: #FFB300;
--warn: #f59e0b; --color-accent-warning-hover: #FFA000;
--warn-muted: rgba(245, 158, 11, 0.75); --color-accent-info: #29B6F6;
--warn-subtle: rgba(245, 158, 11, 0.12); --color-accent-info-hover: #039BE5;
--danger: #ef4444;
--danger-muted: rgba(239, 68, 68, 0.75);
--danger-subtle: rgba(239, 68, 68, 0.12);
--info: #3b82f6;
/* Focus - With glow */ /* Legacy aliases */
--focus: rgba(255, 92, 92, 0.25); --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: 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-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); --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 */
--grid-line: rgba(255, 255, 255, 0.04); --grid-line: rgba(255, 255, 255, 0.04);
@ -80,19 +138,52 @@
--theme-switch-x: 50%; --theme-switch-x: 50%;
--theme-switch-y: 50%; --theme-switch-y: 50%;
/* Typography - Space Grotesk for personality */ /* ============================================
--mono: "JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, monospace; TYPOGRAPHY
--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 */ /* Font Families */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2); --font-family-base: 'Poppins', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.25), 0 0 0 1px rgba(255, 255, 255, 0.03); --font-family-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, monospace;
--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 */ /* 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-sm: 6px;
--radius-md: 8px; --radius-md: 8px;
--radius-lg: 12px; --radius-lg: 12px;
@ -100,81 +191,144 @@
--radius-full: 9999px; --radius-full: 9999px;
--radius: 8px; --radius: 8px;
/* Transitions - Snappy but smooth */ /* Shadows */
--ease-out: cubic-bezier(0.16, 1, 0.3, 1); --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2);
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.25), 0 0 0 1px rgba(255, 255, 255, 0.03);
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1); --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-fast: 120ms;
--duration-normal: 200ms; --duration-normal: 200ms;
--duration-slow: 350ms; --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; color-scheme: dark;
} }
/* Light theme - Clean with subtle warmth */ /* ============================================
:root[data-theme="light"] { LIGHT MODE THEME
--bg: #fafafa; ============================================ */
--bg-accent: #f5f5f5;
--bg-elevated: #ffffff;
--bg-hover: #f0f0f0;
--bg-muted: #f0f0f0;
--bg-content: #f5f5f5;
--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-foreground: #18181b;
--card-highlight: rgba(0, 0, 0, 0.03); --card-highlight: rgba(255, 140, 66, 0.03);
--popover: #ffffff; --popover: var(--color-bg-elevated);
--popover-foreground: #18181b; --popover-foreground: #18181b;
--panel: #fafafa; --panel: var(--color-bg-primary);
--panel-strong: #f5f5f5; --panel-strong: var(--color-bg-secondary);
--panel-hover: #ebebeb; --panel-hover: var(--color-bg-tertiary);
--chrome: rgba(250, 250, 250, 0.95); --chrome: rgba(250, 250, 249, 0.95);
--chrome-strong: rgba(250, 250, 250, 0.98); --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: #3f3f46;
--text-strong: #18181b; --text-strong: var(--color-text-primary);
--chat-text: #3f3f46; --chat-text: #3f3f46;
--muted: #71717a; --muted: #71717a;
--muted-strong: #52525b; --muted-strong: #52525b;
--muted-foreground: #71717a; --muted-foreground: #71717a;
--border: #e4e4e7; /* Accent Colors - Light Mode */
--border-strong: #d4d4d8; --color-accent-primary: #FF8C42;
--border-hover: #a1a1aa; --color-accent-primary-hover: #FF7A2E;
--input: #e4e4e7; --color-accent-primary-active: #E5681A;
--color-accent-secondary: #FFA366;
--accent: #dc2626; /* Legacy aliases */
--accent-hover: #ef4444; --accent: var(--color-accent-primary);
--accent-muted: #dc2626; --accent-hover: var(--color-accent-primary-hover);
--accent-subtle: rgba(220, 38, 38, 0.12); --accent-muted: var(--color-accent-primary);
--accent-subtle: rgba(255, 140, 66, 0.12);
--accent-foreground: #ffffff; --accent-foreground: #ffffff;
--accent-glow: rgba(220, 38, 38, 0.15); --accent-glow: rgba(255, 140, 66, 0.15);
--primary: #dc2626; --primary: var(--color-accent-primary);
--primary-foreground: #ffffff; --primary-foreground: #ffffff;
--secondary: #f4f4f5; --secondary: var(--color-bg-secondary);
--secondary-foreground: #3f3f46; --secondary-foreground: #3f3f46;
--accent-2: #0d9488; --accent-2: #0d9488;
--accent-2-muted: rgba(13, 148, 136, 0.75); --accent-2-muted: rgba(13, 148, 136, 0.75);
--accent-2-subtle: rgba(13, 148, 136, 0.12); --accent-2-subtle: rgba(13, 148, 136, 0.12);
--ok: #16a34a; /* Semantic Colors - Light Mode */
--ok-muted: rgba(22, 163, 74, 0.75); --color-accent-success: #2E7D32;
--ok-subtle: rgba(22, 163, 74, 0.1); --color-accent-success-hover: #1B5E20;
--destructive: #dc2626; --color-accent-error: #D32F2F;
--destructive-foreground: #fafafa; --color-accent-error-hover: #C62828;
--warn: #d97706; --color-accent-warning: #F57C00;
--warn-muted: rgba(217, 119, 6, 0.75); --color-accent-warning-hover: #E65100;
--warn-subtle: rgba(217, 119, 6, 0.1); --color-accent-info: #0288D1;
--danger: #dc2626; --color-accent-info-hover: #01579B;
--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); /* 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: 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); --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); --grid-line: rgba(0, 0, 0, 0.05);
/* Light shadows */ /* Light shadows */
@ -187,6 +341,10 @@
color-scheme: light; color-scheme: light;
} }
/* ============================================
BASE STYLES
============================================ */
* { * {
box-sizing: border-box; box-sizing: border-box;
} }
@ -287,7 +445,7 @@ select {
background: var(--border-strong); background: var(--border-strong);
} }
/* Animations - Polished with spring feel */ /* Animations */
@keyframes rise { @keyframes rise {
from { from {
opacity: 0; opacity: 0;
@ -350,14 +508,14 @@ select {
@keyframes glow-pulse { @keyframes glow-pulse {
0%, 100% { 0%, 100% {
box-shadow: 0 0 0 rgba(255, 92, 92, 0); box-shadow: 0 0 0 rgba(255, 159, 64, 0);
} }
50% { 50% {
box-shadow: 0 0 20px var(--accent-glow); box-shadow: 0 0 20px var(--accent-glow);
} }
} }
/* Stagger animation delays for grouped elements */ /* Stagger animation delays */
.stagger-1 { animation-delay: 0ms; } .stagger-1 { animation-delay: 0ms; }
.stagger-2 { animation-delay: 50ms; } .stagger-2 { animation-delay: 50ms; }
.stagger-3 { animation-delay: 100ms; } .stagger-3 { animation-delay: 100ms; }
@ -370,3 +528,26 @@ select {
outline: none; outline: none;
box-shadow: var(--focus-ring); 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;
}

View File

@ -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);
}

View File

@ -36,6 +36,41 @@
line-height: 1.5; 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 Stats - Bold values, subtle labels
=========================================== */ =========================================== */
@ -774,6 +809,120 @@
text-decoration: underline; 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 Log Stream
=========================================== */ =========================================== */
@ -1035,8 +1184,8 @@
} }
:root[data-theme="light"] .chat-line.user .chat-bubble { :root[data-theme="light"] .chat-line.user .chat-bubble {
border-color: rgba(234, 88, 12, 0.2); border-color: rgba(255, 140, 66, 0.25);
background: rgba(251, 146, 60, 0.12); background: rgba(255, 140, 66, 0.12);
} }
.chat-line.assistant .chat-bubble { .chat-line.assistant .chat-bubble {
@ -1291,6 +1440,95 @@
background: var(--bg); 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 { .chat-stamp {
font-size: 11px; font-size: 11px;
color: var(--muted); color: var(--muted);

View File

@ -613,36 +613,38 @@
.cfg-fields { .cfg-fields {
display: grid; display: grid;
gap: 22px; gap: 16px;
} }
.cfg-field { .cfg-field {
display: grid; display: grid;
gap: 8px; gap: 6px;
} }
.cfg-field--error { .cfg-field--error {
padding: 14px; padding: 12px 14px;
border-radius: var(--radius-md); border-radius: var(--radius-md);
background: var(--danger-subtle); background: rgba(239, 68, 68, 0.08);
border: 1px solid rgba(239, 68, 68, 0.3); border: 1px solid rgba(239, 68, 68, 0.25);
} }
.cfg-field__label { .cfg-field__label {
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 500;
color: var(--text); color: var(--text);
letter-spacing: -0.01em;
} }
.cfg-field__help { .cfg-field__help {
font-size: 12px; font-size: 12px;
color: var(--muted); color: var(--muted);
line-height: 1.45; line-height: 1.5;
} }
.cfg-field__error { .cfg-field__error {
font-size: 12px; font-size: 12px;
color: var(--danger); color: var(--danger);
font-weight: 500;
} }
/* Text Input */ /* Text Input */
@ -651,66 +653,7 @@
gap: 10px; gap: 10px;
} }
.cfg-input { /* Textarea */
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 { .cfg-textarea {
@ -743,135 +686,11 @@
font-size: 12px; 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 */ /* Toggle Row */
.cfg-toggle-row { .cfg-toggle-row {
@ -879,28 +698,31 @@
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 18px; gap: 18px;
padding: 16px 18px; padding: 14px 16px;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius-lg); border-radius: var(--radius-md);
background: var(--bg-accent); background: var(--card);
cursor: pointer; cursor: pointer;
transition: transition:
background var(--duration-fast) ease, 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) { .cfg-toggle-row:hover:not(.disabled) {
background: var(--bg-hover); background: var(--bg-hover);
border-color: var(--border-strong); border-color: var(--border-strong);
box-shadow: var(--shadow-sm), inset 0 1px 0 var(--card-highlight);
} }
.cfg-toggle-row.disabled { .cfg-toggle-row.disabled {
opacity: 0.55; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
} }
:root[data-theme="light"] .cfg-toggle-row { :root[data-theme="light"] .cfg-toggle-row {
background: white; background: var(--card);
} }
:root[data-theme="light"] .cfg-toggle-row:hover:not(.disabled) { :root[data-theme="light"] .cfg-toggle-row:hover:not(.disabled) {
@ -914,77 +736,21 @@
.cfg-toggle-row__label { .cfg-toggle-row__label {
display: block; display: block;
font-size: 14px; font-size: 13px;
font-weight: 500; font-weight: 500;
color: var(--text); color: var(--text);
letter-spacing: -0.01em;
} }
.cfg-toggle-row__help { .cfg-toggle-row__help {
display: block; display: block;
margin-top: 3px; margin-top: 4px;
font-size: 12px; font-size: 12px;
color: var(--muted); 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) */ /* Object (collapsible) */
.cfg-object { .cfg-object {
@ -1090,39 +856,7 @@
background: white; 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 { .cfg-array__help {
padding: 12px 18px; padding: 12px 18px;
@ -1169,36 +903,7 @@
letter-spacing: 0.05em; 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 { .cfg-array__item-content {
padding: 18px; padding: 18px;
@ -1231,35 +936,6 @@
color: var(--muted); 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 { .cfg-map__empty {
padding: 28px 18px; padding: 28px 18px;
text-align: center; text-align: center;
@ -1288,32 +964,6 @@
min-width: 0; 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 variants */
.pill--sm { .pill--sm {
padding: 5px 12px; padding: 5px 12px;
@ -1337,6 +987,8 @@
@media (max-width: 768px) { @media (max-width: 768px) {
.config-layout { .config-layout {
grid-template-columns: 1fr; grid-template-columns: 1fr;
height: auto;
margin: -8px;
} }
.config-sidebar { .config-sidebar {
@ -1345,22 +997,53 @@
} }
.config-sidebar__header { .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 { .config-nav {
display: flex; display: flex;
flex-wrap: nowrap; flex-wrap: nowrap;
gap: 6px; gap: 6px;
padding: 10px 14px; padding: 10px 12px;
overflow-x: auto; overflow-x: auto;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.config-nav::-webkit-scrollbar {
display: none;
} }
.config-nav__item { .config-nav__item {
flex: 0 0 auto; flex: 0 0 auto;
padding: 9px 14px; padding: 10px 14px;
white-space: nowrap; white-space: nowrap;
min-height: 44px; /* Touch target */
} }
.config-nav__label { .config-nav__label {
@ -1368,12 +1051,25 @@
} }
.config-sidebar__footer { .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 { .config-actions {
flex-wrap: wrap; flex-direction: column;
padding: 14px 16px; align-items: stretch;
gap: 10px;
padding: 12px 14px;
} }
.config-actions__left, .config-actions__left,
@ -1382,30 +1078,122 @@
justify-content: center; 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 { .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 { .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 { .config-content {
padding: 18px; padding: 14px;
}
/* Section cards */
.config-section-card {
border-radius: var(--radius-md);
} }
.config-section-card__header { .config-section-card__header {
padding: 16px 18px;
}
.config-section-card__content {
padding: 18px;
}
.cfg-toggle-row {
padding: 14px 16px; 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 { .cfg-map__item {
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 10px; gap: 10px;
@ -1414,34 +1202,142 @@
.cfg-map__item-remove { .cfg-map__item-remove {
justify-self: end; 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) { @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 { .config-nav__icon {
width: 26px; width: 24px;
height: 26px; height: 24px;
font-size: 17px;
} }
.config-nav__label { .config-nav__label {
display: none; 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 { .config-section-card__icon {
width: 30px; width: 26px;
height: 30px; height: 26px;
} }
.config-section-card__title { .config-section-card__title {
font-size: 16px; font-size: 14px;
} }
.cfg-segmented { .config-section-card__content {
flex-wrap: wrap; padding: 14px;
} }
.cfg-segmented__btn { .cfg-toggle-row {
flex: 1 0 auto; padding: 12px;
min-width: 70px; }
.config-diff {
margin: 10px 12px 0;
}
.config-raw-field textarea {
min-height: 350px;
} }
} }

View File

@ -164,6 +164,33 @@
box-sizing: border-box; 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 { .topbar-status .pill .mono {
display: flex; display: flex;
align-items: center; align-items: center;
@ -197,6 +224,7 @@
overflow-x: hidden; overflow-x: hidden;
padding: 16px 12px; padding: 16px 12px;
background: var(--bg); background: var(--bg);
border-right: 1px solid var(--border);
scrollbar-width: none; /* Firefox */ scrollbar-width: none; /* Firefox */
transition: transition:
width var(--shell-focus-duration) var(--shell-focus-ease), width var(--shell-focus-duration) var(--shell-focus-ease),
@ -206,7 +234,8 @@
} }
.nav::-webkit-scrollbar { .nav::-webkit-scrollbar {
display: none; /* Chrome/Safari */ display: none;
/* Chrome/Safari */
} }
.shell--chat-focus .nav { .shell--chat-focus .nav {
@ -277,12 +306,16 @@
/* Nav groups */ /* Nav groups */
.nav-group { .nav-group {
margin-bottom: 20px; margin-bottom: 20px;
padding-bottom: 20px;
border-bottom: 1px solid var(--border);
display: grid; display: grid;
gap: 2px; gap: 2px;
} }
.nav-group:last-child { .nav-group:last-child {
margin-bottom: 0; margin-bottom: 0;
padding-bottom: 0;
border-bottom: none;
} }
.nav-group__items { .nav-group__items {
@ -417,7 +450,7 @@
.content { .content {
grid-area: content; grid-area: content;
padding: 12px 16px 32px; padding: 32px 16px 32px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 24px; gap: 24px;
@ -442,14 +475,15 @@
justify-content: space-between; justify-content: space-between;
gap: 16px; gap: 16px;
padding: 4px 8px; padding: 4px 8px;
overflow: hidden; margin-bottom: 24px;
overflow: visible;
transform-origin: top center; transform-origin: top center;
transition: transition:
opacity var(--shell-focus-duration) var(--shell-focus-ease), opacity var(--shell-focus-duration) var(--shell-focus-ease),
transform 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), max-height var(--shell-focus-duration) var(--shell-focus-ease),
padding 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 { .shell--chat-focus .content-header {
@ -489,7 +523,7 @@
gap: 16px; gap: 16px;
} }
.content--chat .content-header > div:first-child { .content--chat .content-header>div:first-child {
text-align: left; text-align: left;
} }

View File

@ -61,6 +61,11 @@
align-items: center; align-items: center;
} }
/* Hide nav toggle button on mobile (sidebar is always visible/stacked) */
.nav-collapse-toggle {
display: none;
}
.brand { .brand {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
@ -80,6 +85,43 @@
flex-wrap: nowrap; 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 { .topbar-status .pill {
padding: 4px 8px; padding: 4px 8px;
font-size: 11px; font-size: 11px;
@ -214,9 +256,13 @@
width: 100%; width: 100%;
} }
/* Chat thread - ensure proper scrolling on mobile */
.chat-thread { .chat-thread {
margin-top: 8px; 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 { .chat-msg {
@ -228,15 +274,47 @@
border-radius: var(--radius-md); border-radius: var(--radius-md);
} }
/* Chat compose - fixed at bottom on mobile */
.chat-compose { .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; 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 { .chat-compose__field textarea {
min-height: 60px; min-height: 44px;
max-height: 120px;
padding: 8px 10px; padding: 8px 10px;
border-radius: var(--radius-md); 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 */ /* Log stream */
@ -315,6 +393,35 @@
font-size: 13px; 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 { .nav {
padding: 6px 8px; padding: 6px 8px;
} }
@ -345,10 +452,16 @@
padding: 8px 10px; padding: 8px 10px;
} }
.chat-compose {
padding: 10px 6px;
padding-bottom: calc(10px + env(safe-area-inset-bottom, 0px));
}
.chat-compose__field textarea { .chat-compose__field textarea {
min-height: 52px; min-height: 40px;
max-height: 100px;
padding: 8px 10px; padding: 8px 10px;
font-size: 13px; font-size: 16px; /* Prevents zoom on iOS */
} }
.btn { .btn {
@ -372,3 +485,10 @@
height: 11px; 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 */
}
}

View File

@ -8,6 +8,7 @@ import {
syncTabWithLocation, syncTabWithLocation,
syncThemeWithSettings, syncThemeWithSettings,
} from "./app-settings"; } from "./app-settings";
import { applyColorTheme } from "./theme";
import { observeTopbar, scheduleChatScroll, scheduleLogsScroll } from "./app-scroll"; import { observeTopbar, scheduleChatScroll, scheduleLogsScroll } from "./app-scroll";
import { import {
startLogsPolling, startLogsPolling,
@ -31,6 +32,7 @@ type LifecycleHost = {
logsEntries: unknown[]; logsEntries: unknown[];
popStateHandler: () => void; popStateHandler: () => void;
topbarObserver: ResizeObserver | null; topbarObserver: ResizeObserver | null;
settings: { colorTheme: string };
}; };
export function handleConnected(host: LifecycleHost) { export function handleConnected(host: LifecycleHost) {
@ -42,6 +44,10 @@ export function handleConnected(host: LifecycleHost) {
syncThemeWithSettings( syncThemeWithSettings(
host as unknown as Parameters<typeof syncThemeWithSettings>[0], host as unknown as Parameters<typeof syncThemeWithSettings>[0],
); );
// Apply saved color theme
if (host.settings?.colorTheme) {
applyColorTheme(host.settings.colorTheme as any);
}
attachThemeListener( attachThemeListener(
host as unknown as Parameters<typeof attachThemeListener>[0], host as unknown as Parameters<typeof attachThemeListener>[0],
); );

View File

@ -79,6 +79,10 @@ import {
saveExecApprovals, saveExecApprovals,
updateExecApprovalsFormValue, updateExecApprovalsFormValue,
} from "./controllers/exec-approvals"; } 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 { loadCronRuns, toggleCronJob, runCronJob, removeCronJob, addCronJob } from "./controllers/cron";
import { loadDebug, callDebugMethod } from "./controllers/debug"; import { loadDebug, callDebugMethod } from "./controllers/debug";
import { loadLogs } from "./controllers/logs"; import { loadLogs } from "./controllers/logs";
@ -139,12 +143,23 @@ export function renderApp(state: AppViewState) {
</div> </div>
</div> </div>
<div class="topbar-status"> <div class="topbar-status">
<div class="pill"> <div class="health-badge">
<span class="statusDot ${state.connected ? "ok" : ""}"></span> <span class="statusDot ${state.connected ? "ok" : ""}"></span>
<span>Health</span> <span>Health</span>
<span class="mono">${state.connected ? "OK" : "Offline"}</span> <span class="health-badge__status">${state.connected ? "OK" : "Offline"}</span>
</div> </div>
<div style="display: flex; align-items: center; gap: 4px;">
${renderThemeToggle(state)} ${renderThemeToggle(state)}
<theme-selector
.colorTheme=${state.settings.colorTheme}
@change=${(e: CustomEvent) => {
const host = state as unknown as { setColorTheme: (theme: string) => void };
if (host.setColorTheme) {
host.setColorTheme(e.detail.colorTheme);
}
}}
></theme-selector>
</div>
</div> </div>
</header> </header>
<aside class="nav ${state.settings.navCollapsed ? "nav--collapsed" : ""}"> <aside class="nav ${state.settings.navCollapsed ? "nav--collapsed" : ""}">
@ -200,7 +215,7 @@ export function renderApp(state: AppViewState) {
</div> </div>
<div class="page-meta"> <div class="page-meta">
${state.lastError ${state.lastError
? html`<div class="pill danger">${state.lastError}</div>` ? html`<ui-badge variant="destructive">${state.lastError}</ui-badge>`
: nothing} : nothing}
${isChat ? renderChatControls(state) : nothing} ${isChat ? renderChatControls(state) : nothing}
</div> </div>
@ -479,12 +494,12 @@ export function renderApp(state: AppViewState) {
}, },
onChatScroll: (event) => state.handleChatScroll(event), onChatScroll: (event) => state.handleChatScroll(event),
onDraftChange: (next) => (state.chatMessage = next), onDraftChange: (next) => (state.chatMessage = next),
attachments: state.chatAttachments,
onAttachmentsChange: (next) => (state.chatAttachments = next),
onSend: () => state.handleSendChat(), onSend: () => state.handleSendChat(),
canAbort: Boolean(state.chatRunId), canAbort: Boolean(state.chatRunId),
onAbort: () => void state.handleAbortChat(), onAbort: () => void state.handleAbortChat(),
onQueueRemove: (id) => state.removeQueuedMessage(id), onQueueRemove: (id) => state.removeQueuedMessage(id),
attachments: state.chatAttachments,
onAttachmentsChange: (next) => (state.chatAttachments = next),
onNewSession: () => onNewSession: () =>
state.handleSendChat("/new", { restoreDraft: true }), state.handleSendChat("/new", { restoreDraft: true }),
// Sidebar props for tool output viewing // Sidebar props for tool output viewing

View File

@ -200,6 +200,16 @@ export function applyResolvedTheme(host: SettingsHost, resolved: ResolvedTheme)
const root = document.documentElement; const root = document.documentElement;
root.dataset.theme = resolved; root.dataset.theme = resolved;
root.style.colorScheme = resolved; root.style.colorScheme = resolved;
// Update meta theme-color for mobile browsers (muted backgrounds)
let metaThemeColor = document.querySelector('meta[name="theme-color"]');
if (!metaThemeColor) {
metaThemeColor = document.createElement("meta");
metaThemeColor.setAttribute("name", "theme-color");
document.head.appendChild(metaThemeColor);
}
const color = resolved === "light" ? "#FAFAF9" : "#1A1816";
metaThemeColor.setAttribute("content", color);
} }
export function attachThemeListener(host: SettingsHost) { export function attachThemeListener(host: SettingsHost) {

View File

@ -351,6 +351,15 @@ export class MoltbotApp extends LitElement {
); );
} }
setColorTheme(colorTheme: string) {
import("./theme").then(({ applyColorTheme, saveColorTheme }) => {
applyColorTheme(colorTheme as any);
saveColorTheme(colorTheme as any);
this.settings = { ...this.settings, colorTheme: colorTheme as any };
this.requestUpdate();
});
}
async loadOverview() { async loadOverview() {
await loadOverviewInternal( await loadOverviewInternal(
this as unknown as Parameters<typeof loadOverviewInternal>[0], this as unknown as Parameters<typeof loadOverviewInternal>[0],

View File

@ -0,0 +1,108 @@
import { LitElement, html, css } from "lit";
import { customElement, property } from "lit/decorators.js";
import { classMap } from "lit/directives/class-map.js";
export type BadgeVariant = "default" | "secondary" | "outline" | "destructive" | "success" | "warning";
@customElement("ui-badge")
export class UiBadge extends LitElement {
@property({ type: String }) variant: BadgeVariant = "default";
static styles = css`
:host {
display: inline-flex;
vertical-align: middle;
}
.badge {
display: inline-flex;
align-items: center;
border-radius: var(--radius-full, 9999px);
border: 1px solid transparent;
padding: 2px 8px;
font-size: 11px;
font-weight: 600;
line-height: 1.3;
transition: colors 0.2s ease;
text-transform: uppercase;
letter-spacing: 0.02em;
}
.variant-default {
border-color: transparent;
background-color: var(--accent, #FF9F40);
color: var(--primary-foreground, #fff);
}
.variant-secondary {
border-color: transparent;
background-color: var(--secondary, #2A2723);
color: var(--secondary-foreground, #EDE9E6);
}
.variant-outline {
border-color: var(--border, #3E3B38);
color: var(--foreground, #EDE9E6);
}
.variant-destructive {
border-color: transparent;
background-color: var(--danger, #EF4444);
color: white;
}
.variant-success {
border-color: transparent;
background-color: var(--ok, #22C55E);
color: white;
}
.variant-warning {
border-color: transparent;
background-color: var(--warn, #F59E0B);
color: white;
}
/* Light mode styles */
:host-context([data-theme="light"]) .variant-default {
background-color: var(--accent, #FF8C42);
}
:host-context([data-theme="light"]) .variant-secondary {
background-color: var(--secondary, #F5F4F2);
color: var(--secondary-foreground, #3f3f46);
}
:host-context([data-theme="light"]) .variant-outline {
border-color: var(--border, #e4e4e7);
color: var(--foreground, #1A1410);
}
:host-context([data-theme="light"]) .variant-destructive {
background-color: var(--danger, #D32F2F);
}
:host-context([data-theme="light"]) .variant-success {
background-color: var(--ok, #2E7D32);
}
:host-context([data-theme="light"]) .variant-warning {
background-color: var(--warn, #F57C00);
}
`;
render() {
return html`
<div class=${classMap({ badge: true, [`variant-${this.variant}`]: true })}>
<slot></slot>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ui-badge": UiBadge;
}
}

View File

@ -0,0 +1,206 @@
import { LitElement, html, css } from "lit";
import { customElement, property } from "lit/decorators.js";
import { classMap } from "lit/directives/class-map.js";
import "./icon";
export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger" | "outline";
export type ButtonSize = "sm" | "md" | "lg" | "icon";
@customElement("ui-button")
export class UiButton extends LitElement {
@property({ type: String }) variant: ButtonVariant = "secondary";
@property({ type: String }) size: ButtonSize = "md";
@property({ type: Boolean }) disabled = false;
@property({ type: Boolean }) loading = false;
@property({ type: String }) icon?: string;
@property({ type: String }) href?: string;
@property({ type: String }) target?: string;
@property({ type: String }) type: "button" | "submit" | "reset" = "button";
static styles = css`
:host {
display: inline-block;
vertical-align: middle;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
border: 1px solid transparent;
border-radius: var(--radius-md, 6px);
cursor: pointer;
font-family: inherit;
font-weight: 500;
line-height: 1;
text-decoration: none;
transition: all 0.2s ease;
white-space: nowrap;
position: relative;
}
.btn:focus-visible {
outline: 2px solid var(--accent, #FF9F40);
outline-offset: 2px;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
/* Sizes */
.size-sm {
height: 28px;
padding: 0 10px;
font-size: 12px;
}
.size-md {
height: 36px;
padding: 0 16px;
font-size: 14px;
}
.size-lg {
height: 44px;
padding: 0 24px;
font-size: 16px;
}
.size-icon {
height: 36px;
width: 36px;
padding: 0;
}
.size-icon.size-sm {
height: 28px;
width: 28px;
}
/* Variants */
.variant-primary {
background-color: var(--accent, #FF9F40);
color: var(--primary-foreground, #fff);
border-color: var(--accent, #FF9F40);
}
.variant-primary:hover {
background-color: var(--accent-hover, #F97316);
border-color: var(--accent-hover, #F97316);
}
.variant-secondary {
background-color: var(--secondary, #2A2723);
color: var(--secondary-foreground, #EDE9E6);
border-color: var(--border, #3E3B38);
}
.variant-secondary:hover {
background-color: var(--secondary-hover, #3E3B38);
border-color: var(--border-strong, #57534E);
}
.variant-outline {
background-color: transparent;
border-color: var(--border, #3E3B38);
color: var(--foreground, #EDE9E6);
}
.variant-outline:hover {
background-color: var(--secondary, #2A2723);
border-color: var(--border-strong, #57534E);
}
.variant-ghost {
background-color: transparent;
color: var(--muted-foreground, #A8A29D);
}
.variant-ghost:hover {
background-color: var(--secondary, #2A2723);
color: var(--foreground, #EDE9E6);
}
.variant-danger {
background-color: var(--danger, #EF4444);
color: white;
}
.variant-danger:hover {
background-color: var(--danger-hover, #DC2626);
}
/* Light mode overrides */
:host-context([data-theme="light"]) .variant-secondary {
background-color: white;
color: var(--foreground);
border-color: var(--border);
}
:host-context([data-theme="light"]) .variant-secondary:hover {
background-color: var(--bg-hover);
}
:host-context([data-theme="light"]) .variant-outline {
color: var(--foreground);
}
:host-context([data-theme="light"]) .variant-outline:hover {
background-color: var(--bg-hover);
}
:host-context([data-theme="light"]) .variant-ghost:hover {
background-color: var(--bg-hover);
}
/* Loading Spinner */
.spinner {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
`;
render() {
const classes = {
btn: true,
[`variant-${this.variant}`]: true,
[`size-${this.size}`]: true,
loading: this.loading,
};
const content = html`
${this.loading
? html`<ui-icon name="loader" class="spinner" .size=${this.size === "sm" ? 14 : 16}></ui-icon>`
: this.icon
? html`<ui-icon .name=${this.icon} .size=${this.size === "sm" ? 14 : 16}></ui-icon>`
: null}
<slot></slot>
`;
if (this.href) {
return html`
<a
class=${classMap(classes)}
href=${this.href}
target=${this.target || "_self"}
?disabled=${this.disabled}
>
${content}
</a>
`;
}
return html`
<button
class=${classMap(classes)}
type=${this.type}
?disabled=${this.disabled || this.loading}
>
${content}
</button>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ui-button": UiButton;
}
}

View File

@ -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`
<div class="card">
<div class="card-header">
<slot name="header"></slot>
</div>
<div class="card-content">
<slot></slot>
</div>
<slot name="footer"></slot>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ui-card": UiCard;
}
}

View File

@ -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`
<div
class="wrapper ${this.checked ? 'checked' : ''} ${this.disabled ? 'disabled' : ''}"
@click=${this.toggle}
>
<div class="checkbox">
${this.checked ? html`<ui-icon name="check" .size=${12} strokeWidth="3"></ui-icon>` : null}
</div>
${this.label ? html`<span class="label">${this.label}</span>` : null}
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ui-checkbox": UiCheckbox;
}
}

View File

@ -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`
<button
class="trigger"
@click=${this.toggleMenu}
?disabled=${this.disabled}
aria-haspopup="true"
aria-expanded=${this.open}
>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="1"/>
<circle cx="12" cy="5" r="1"/>
<circle cx="12" cy="19" r="1"/>
</svg>
</button>
<div class="menu ${this.open ? "open" : ""}">
${this.items.map(
(item) => html`
<button
class="menu-item ${item.variant === "danger" ? "danger" : ""}"
@click=${(e: Event) => this.handleItemClick(item, e)}
?disabled=${item.disabled}
>
${item.label}
</button>
`
)}
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ui-dropdown-menu": UiDropdownMenu;
}
}

View File

@ -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`
<style>
:host {
width: ${this.size}px;
height: ${this.size}px;
color: ${this.color};
}
</style>
${iconTemplate}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ui-icon": UiIcon;
}
}

View File

@ -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";

View File

@ -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`
<div class="input-wrapper ${this.error ? 'input-error' : ''}">
${this.label ? html`<label class="label">${this.label}</label>` : null}
<div class="input-container ${this.icon ? 'has-icon' : ''}">
${this.icon ? html`<ui-icon class="icon" .name=${this.icon} .size=${16}></ui-icon>` : null}
<input
.type=${this.type}
.value=${this.value}
.placeholder=${this.placeholder}
?disabled=${this.disabled}
@input=${this.handleInput}
@change=${this.handleChange}
/>
</div>
${this.error ? html`<span class="error-msg">${this.error}</span>` : null}
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ui-input": UiInput;
}
}

View File

@ -41,6 +41,19 @@ export class ResizableDivider extends LitElement {
:host(.dragging) { :host(.dragging) {
background: var(--accent, #007bff); 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() { render() {

View File

@ -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`
<div
class="segment ${opt.value === this.value ? 'active' : ''}"
@click=${() => this.select(opt.value)}
>
${opt.label}
</div>
`)}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ui-segmented-control": UiSegmentedControl;
}
}

View File

@ -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`
<div class="wrapper">
${this.label ? html`<label class="label">${this.label}</label>` : null}
<div class="select-container">
<select
.value=${this.value}
?disabled=${this.disabled}
@change=${this.handleChange}
>
${this.options.map(opt => html`
<option value=${opt.value} ?selected=${opt.value === this.value}>${opt.label}</option>
`)}
</select>
<ui-icon class="chevron" name="chevronDown" .size=${16}></ui-icon>
</div>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ui-select": UiSelect;
}
}

View File

@ -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`
<div
class="track ${this.checked ? 'checked' : ''} ${this.disabled ? 'disabled' : ''}"
@click=${this.toggle}
>
<div class="thumb"></div>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ui-switch": UiSwitch;
}
}

View File

@ -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`
<div class="textarea-wrapper ${this.error ? 'textarea-error' : ''}">
${this.label ? html`<label class="label">${this.label}</label>` : null}
<textarea
.value=${this.value}
.placeholder=${this.placeholder}
.rows=${this.rows}
?disabled=${this.disabled}
@input=${this.handleInput}
@change=${this.handleChange}
></textarea>
${this.error ? html`<span class="error-msg">${this.error}</span>` : null}
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ui-textarea": UiTextarea;
}
}

View File

@ -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`
<button class="trigger" @click=${this.toggleDropdown} title="Change color theme">
<ui-icon name="moreVertical" .size=${14}></ui-icon>
</button>
<div class="dropdown ${this.isOpen ? 'open' : ''}">
${Object.entries(COLOR_THEMES).map(([key, { name, preview }]) => html`
<button
class="dropdown-item ${key === this.colorTheme ? 'active' : ''}"
@click=${() => this.selectTheme(key as ColorTheme)}
>
<div class="color-preview" style="background-color: ${preview};"></div>
<span>${name}</span>
</button>
`)}
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"theme-selector": ThemeSelector;
}
}

View File

@ -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`
<slot></slot>
<div class="tooltip">${this.content}</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ui-tooltip": UiTooltip;
}
}

View File

@ -75,8 +75,6 @@ export async function approveDevicePairing(state: DevicesState, requestId: strin
export async function rejectDevicePairing(state: DevicesState, requestId: string) { export async function rejectDevicePairing(state: DevicesState, requestId: string) {
if (!state.client || !state.connected) return; if (!state.client || !state.connected) return;
const confirmed = window.confirm("Reject this device pairing request?");
if (!confirmed) return;
try { try {
await state.client.request("device.pair.reject", { requestId }); await state.client.request("device.pair.reject", { requestId });
await loadDevices(state); await loadDevices(state);
@ -118,10 +116,6 @@ export async function revokeDeviceToken(
params: { deviceId: string; role: string }, params: { deviceId: string; role: string },
) { ) {
if (!state.client || !state.connected) return; if (!state.client || !state.connected) return;
const confirmed = window.confirm(
`Revoke token for ${params.deviceId} (${params.role})?`,
);
if (!confirmed) return;
try { try {
await state.client.request("device.token.revoke", params); await state.client.request("device.token.revoke", params);
const identity = await loadOrCreateDeviceIdentity(); const identity = await loadOrCreateDeviceIdentity();

View File

@ -66,10 +66,6 @@ export async function patchSession(
export async function deleteSession(state: SessionsState, key: string) { export async function deleteSession(state: SessionsState, key: string) {
if (!state.client || !state.connected) return; if (!state.client || !state.connected) return;
if (state.sessionsLoading) 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.sessionsLoading = true;
state.sessionsError = null; state.sessionsError = null;
try { try {

View File

@ -19,6 +19,7 @@ export const icons = {
// UI icons // UI icons
menu: html`<svg viewBox="0 0 24 24"><line x1="4" x2="20" y1="12" y2="12"/><line x1="4" x2="20" y1="6" y2="6"/><line x1="4" x2="20" y1="18" y2="18"/></svg>`, menu: html`<svg viewBox="0 0 24 24"><line x1="4" x2="20" y1="12" y2="12"/><line x1="4" x2="20" y1="6" y2="6"/><line x1="4" x2="20" y1="18" y2="18"/></svg>`,
moreVertical: html`<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="1"/><circle cx="12" cy="5" r="1"/><circle cx="12" cy="19" r="1"/></svg>`,
x: html`<svg viewBox="0 0 24 24"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>`, x: html`<svg viewBox="0 0 24 24"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>`,
check: html`<svg viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></svg>`, check: html`<svg viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></svg>`,
copy: html`<svg viewBox="0 0 24 24"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>`, copy: html`<svg viewBox="0 0 24 24"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>`,

View File

@ -1,6 +1,6 @@
const KEY = "moltbot.control.settings.v1"; const KEY = "moltbot.control.settings.v1";
import type { ThemeMode } from "./theme"; import type { ThemeMode, ColorTheme } from "./theme";
export type UiSettings = { export type UiSettings = {
gatewayUrl: string; gatewayUrl: string;
@ -8,6 +8,7 @@ export type UiSettings = {
sessionKey: string; sessionKey: string;
lastActiveSessionKey: string; lastActiveSessionKey: string;
theme: ThemeMode; theme: ThemeMode;
colorTheme: ColorTheme;
chatFocusMode: boolean; chatFocusMode: boolean;
chatShowThinking: boolean; chatShowThinking: boolean;
splitRatio: number; // Sidebar split ratio (0.4 to 0.7, default 0.6) splitRatio: number; // Sidebar split ratio (0.4 to 0.7, default 0.6)
@ -27,6 +28,7 @@ export function loadSettings(): UiSettings {
sessionKey: "main", sessionKey: "main",
lastActiveSessionKey: "main", lastActiveSessionKey: "main",
theme: "system", theme: "system",
colorTheme: "tangerine",
chatFocusMode: false, chatFocusMode: false,
chatShowThinking: true, chatShowThinking: true,
splitRatio: 0.6, splitRatio: 0.6,
@ -61,6 +63,15 @@ export function loadSettings(): UiSettings {
parsed.theme === "system" parsed.theme === "system"
? parsed.theme ? parsed.theme
: defaults.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: chatFocusMode:
typeof parsed.chatFocusMode === "boolean" typeof parsed.chatFocusMode === "boolean"
? parsed.chatFocusMode ? parsed.chatFocusMode

View File

@ -1,5 +1,9 @@
export type ThemeMode = "system" | "light" | "dark"; export type ThemeMode = "system" | "light" | "dark";
export type ResolvedTheme = "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 { export function getSystemTheme(): ResolvedTheme {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") { if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
@ -14,3 +18,107 @@ export function resolveTheme(mode: ThemeMode): ResolvedTheme {
if (mode === "system") return getSystemTheme(); if (mode === "system") return getSystemTheme();
return mode; 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<ColorTheme, { name: string; preview: string }> = {
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" },
};

View File

@ -102,7 +102,10 @@ export function renderChannelConfigSection(params: {
const { channelId, props } = params; const { channelId, props } = params;
const disabled = props.configSaving || props.configSchemaLoading; const disabled = props.configSaving || props.configSchemaLoading;
return html` return html`
<div style="margin-top: 16px;"> <div class="channel-config-section">
<div style="font-weight: 500; font-size: 13px; color: var(--muted); margin-bottom: 12px; text-transform: uppercase; letter-spacing: 0.04em;">
Configuration
</div>
${props.configSchemaLoading ${props.configSchemaLoading
? html`<div class="muted">Loading config schema…</div>` ? html`<div class="muted">Loading config schema…</div>`
: renderChannelConfigForm({ : renderChannelConfigForm({
@ -113,22 +116,6 @@ export function renderChannelConfigSection(params: {
disabled, disabled,
onPatch: props.onConfigPatch, onPatch: props.onConfigPatch,
})} })}
<div class="row" style="margin-top: 12px;">
<button
class="btn primary"
?disabled=${disabled || !props.configFormDirty}
@click=${() => props.onConfigSave()}
>
${props.configSaving ? "Saving…" : "Save"}
</button>
<button
class="btn"
?disabled=${disabled}
@click=${() => props.onConfigReload()}
>
Reload
</button>
</div>
</div> </div>
`; `;
} }

View File

@ -11,9 +11,11 @@ export function renderDiscordCard(params: {
accountCountLabel: unknown; accountCountLabel: unknown;
}) { }) {
const { props, discord, accountCountLabel } = params; const { props, discord, accountCountLabel } = params;
const configDisabled = props.configSaving || props.configSchemaLoading;
return html` return html`
<div class="card"> <div class="card card--channel">
<div class="card-content">
<div class="card-title">Discord</div> <div class="card-title">Discord</div>
<div class="card-sub">Bot status and channel configuration.</div> <div class="card-sub">Bot status and channel configuration.</div>
${accountCountLabel} ${accountCountLabel}
@ -37,12 +39,6 @@ export function renderDiscordCard(params: {
</div> </div>
</div> </div>
${discord?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${discord.lastError}
</div>`
: nothing}
${discord?.probe ${discord?.probe
? html`<div class="callout" style="margin-top: 12px;"> ? html`<div class="callout" style="margin-top: 12px;">
Probe ${discord.probe.ok ? "ok" : "failed"} · Probe ${discord.probe.ok ? "ok" : "failed"} ·
@ -51,11 +47,35 @@ export function renderDiscordCard(params: {
: nothing} : nothing}
${renderChannelConfigSection({ channelId: "discord", props })} ${renderChannelConfigSection({ channelId: "discord", props })}
</div>
<div class="row" style="margin-top: 12px;"> <div class="card-footer">
<button class="btn" @click=${() => props.onRefresh(true)}> ${discord?.lastError
? html`<div class="callout danger" style="margin-bottom: 12px;">
${discord.lastError}
</div>`
: nothing}
<div class="row" style="gap: 8px; align-items: center; flex-wrap: wrap;">
<ui-button @click=${() => props.onRefresh(true)}>
Probe Probe
</button> </ui-button>
<div style="margin-left: auto; display: flex; gap: 8px;">
<ui-button
variant="primary"
?disabled=${configDisabled || !props.configFormDirty}
@click=${() => props.onConfigSave()}
>
${props.configSaving ? "Saving…" : "Save Config"}
</ui-button>
<ui-button
?disabled=${configDisabled}
@click=${() => props.onConfigReload()}
>
Reload
</ui-button>
</div>
</div>
</div> </div>
</div> </div>
`; `;

View File

@ -11,9 +11,11 @@ export function renderGoogleChatCard(params: {
accountCountLabel: unknown; accountCountLabel: unknown;
}) { }) {
const { props, googleChat, accountCountLabel } = params; const { props, googleChat, accountCountLabel } = params;
const configDisabled = props.configSaving || props.configSchemaLoading;
return html` return html`
<div class="card"> <div class="card card--channel">
<div class="card-content">
<div class="card-title">Google Chat</div> <div class="card-title">Google Chat</div>
<div class="card-sub">Chat API webhook status and channel configuration.</div> <div class="card-sub">Chat API webhook status and channel configuration.</div>
${accountCountLabel} ${accountCountLabel}
@ -49,12 +51,6 @@ export function renderGoogleChatCard(params: {
</div> </div>
</div> </div>
${googleChat?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${googleChat.lastError}
</div>`
: nothing}
${googleChat?.probe ${googleChat?.probe
? html`<div class="callout" style="margin-top: 12px;"> ? html`<div class="callout" style="margin-top: 12px;">
Probe ${googleChat.probe.ok ? "ok" : "failed"} · Probe ${googleChat.probe.ok ? "ok" : "failed"} ·
@ -63,11 +59,35 @@ export function renderGoogleChatCard(params: {
: nothing} : nothing}
${renderChannelConfigSection({ channelId: "googlechat", props })} ${renderChannelConfigSection({ channelId: "googlechat", props })}
</div>
<div class="row" style="margin-top: 12px;"> <div class="card-footer">
<button class="btn" @click=${() => props.onRefresh(true)}> ${googleChat?.lastError
? html`<div class="callout danger" style="margin-bottom: 12px;">
${googleChat.lastError}
</div>`
: nothing}
<div class="row" style="gap: 8px; align-items: center; flex-wrap: wrap;">
<ui-button @click=${() => props.onRefresh(true)}>
Probe Probe
</button> </ui-button>
<div style="margin-left: auto; display: flex; gap: 8px;">
<ui-button
variant="primary"
?disabled=${configDisabled || !props.configFormDirty}
@click=${() => props.onConfigSave()}
>
${props.configSaving ? "Saving…" : "Save Config"}
</ui-button>
<ui-button
?disabled=${configDisabled}
@click=${() => props.onConfigReload()}
>
Reload
</ui-button>
</div>
</div>
</div> </div>
</div> </div>
`; `;

View File

@ -11,9 +11,11 @@ export function renderIMessageCard(params: {
accountCountLabel: unknown; accountCountLabel: unknown;
}) { }) {
const { props, imessage, accountCountLabel } = params; const { props, imessage, accountCountLabel } = params;
const configDisabled = props.configSaving || props.configSchemaLoading;
return html` return html`
<div class="card"> <div class="card card--channel">
<div class="card-content">
<div class="card-title">iMessage</div> <div class="card-title">iMessage</div>
<div class="card-sub">macOS bridge status and channel configuration.</div> <div class="card-sub">macOS bridge status and channel configuration.</div>
${accountCountLabel} ${accountCountLabel}
@ -37,12 +39,6 @@ export function renderIMessageCard(params: {
</div> </div>
</div> </div>
${imessage?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${imessage.lastError}
</div>`
: nothing}
${imessage?.probe ${imessage?.probe
? html`<div class="callout" style="margin-top: 12px;"> ? html`<div class="callout" style="margin-top: 12px;">
Probe ${imessage.probe.ok ? "ok" : "failed"} · Probe ${imessage.probe.ok ? "ok" : "failed"} ·
@ -51,11 +47,35 @@ export function renderIMessageCard(params: {
: nothing} : nothing}
${renderChannelConfigSection({ channelId: "imessage", props })} ${renderChannelConfigSection({ channelId: "imessage", props })}
</div>
<div class="row" style="margin-top: 12px;"> <div class="card-footer">
<button class="btn" @click=${() => props.onRefresh(true)}> ${imessage?.lastError
? html`<div class="callout danger" style="margin-bottom: 12px;">
${imessage.lastError}
</div>`
: nothing}
<div class="row" style="gap: 8px; align-items: center; flex-wrap: wrap;">
<ui-button @click=${() => props.onRefresh(true)}>
Probe Probe
</button> </ui-button>
<div style="margin-left: auto; display: flex; gap: 8px;">
<ui-button
variant="primary"
?disabled=${configDisabled || !props.configFormDirty}
@click=${() => props.onConfigSave()}
>
${props.configSaving ? "Saving…" : "Save Config"}
</ui-button>
<ui-button
?disabled=${configDisabled}
@click=${() => props.onConfigReload()}
>
Reload
</ui-button>
</div>
</div>
</div> </div>
</div> </div>
`; `;

View File

@ -235,36 +235,33 @@ export function renderNostrProfileForm(params: {
: nothing} : nothing}
<div style="display: flex; gap: 8px; margin-top: 16px; flex-wrap: wrap;"> <div style="display: flex; gap: 8px; margin-top: 16px; flex-wrap: wrap;">
<button <ui-button
class="btn primary" variant="primary"
@click=${callbacks.onSave} @click=${callbacks.onSave}
?disabled=${state.saving || !isDirty} ?disabled=${state.saving || !isDirty}
> >
${state.saving ? "Saving..." : "Save & Publish"} ${state.saving ? "Saving..." : "Save & Publish"}
</button> </ui-button>
<button <ui-button
class="btn"
@click=${callbacks.onImport} @click=${callbacks.onImport}
?disabled=${state.importing || state.saving} ?disabled=${state.importing || state.saving}
> >
${state.importing ? "Importing..." : "Import from Relays"} ${state.importing ? "Importing..." : "Import from Relays"}
</button> </ui-button>
<button <ui-button
class="btn"
@click=${callbacks.onToggleAdvanced} @click=${callbacks.onToggleAdvanced}
> >
${state.showAdvanced ? "Hide Advanced" : "Show Advanced"} ${state.showAdvanced ? "Hide Advanced" : "Show Advanced"}
</button> </ui-button>
<button <ui-button
class="btn"
@click=${callbacks.onCancel} @click=${callbacks.onCancel}
?disabled=${state.saving} ?disabled=${state.saving}
> >
Cancel Cancel
</button> </ui-button>
</div> </div>
${isDirty ${isDirty

View File

@ -50,6 +50,7 @@ export function renderNostrCard(params: {
const summaryLastError = nostr?.lastError ?? primaryAccount?.lastError ?? null; const summaryLastError = nostr?.lastError ?? primaryAccount?.lastError ?? null;
const hasMultipleAccounts = nostrAccounts.length > 1; const hasMultipleAccounts = nostrAccounts.length > 1;
const showingForm = profileFormState !== null && profileFormState !== undefined; const showingForm = profileFormState !== null && profileFormState !== undefined;
const configDisabled = props.configSaving || props.configSchemaLoading;
const renderAccountCard = (account: ChannelAccountSnapshot) => { const renderAccountCard = (account: ChannelAccountSnapshot) => {
const publicKey = (account as { publicKey?: string }).publicKey; const publicKey = (account as { publicKey?: string }).publicKey;
@ -120,13 +121,12 @@ export function renderNostrCard(params: {
<div style="font-weight: 500;">Profile</div> <div style="font-weight: 500;">Profile</div>
${summaryConfigured ${summaryConfigured
? html` ? html`
<button <ui-button
class="btn btn-sm" size="sm"
@click=${onEditProfile} @click=${onEditProfile}
style="font-size: 12px; padding: 4px 8px;"
> >
Edit Profile Edit Profile
</button> </ui-button>
` `
: nothing} : nothing}
</div> </div>
@ -167,7 +167,8 @@ export function renderNostrCard(params: {
}; };
return html` return html`
<div class="card"> <div class="card card--channel">
<div class="card-content">
<div class="card-title">Nostr</div> <div class="card-title">Nostr</div>
<div class="card-sub">Decentralized DMs via Nostr relays (NIP-04).</div> <div class="card-sub">Decentralized DMs via Nostr relays (NIP-04).</div>
${accountCountLabel} ${accountCountLabel}
@ -201,16 +202,34 @@ export function renderNostrCard(params: {
</div> </div>
`} `}
${summaryLastError
? html`<div class="callout danger" style="margin-top: 12px;">${summaryLastError}</div>`
: nothing}
${renderProfileSection()} ${renderProfileSection()}
${renderChannelConfigSection({ channelId: "nostr", props })} ${renderChannelConfigSection({ channelId: "nostr", props })}
</div>
<div class="row" style="margin-top: 12px;"> <div class="card-footer">
<button class="btn" @click=${() => props.onRefresh(false)}>Refresh</button> ${summaryLastError
? html`<div class="callout danger" style="margin-bottom: 12px;">${summaryLastError}</div>`
: nothing}
<div class="row" style="gap: 8px; align-items: center; flex-wrap: wrap;">
<ui-button @click=${() => props.onRefresh(false)}>Refresh</ui-button>
<div style="margin-left: auto; display: flex; gap: 8px;">
<ui-button
variant="primary"
?disabled=${configDisabled || !props.configFormDirty}
@click=${() => props.onConfigSave()}
>
${props.configSaving ? "Saving…" : "Save Config"}
</ui-button>
<ui-button
?disabled=${configDisabled}
@click=${() => props.onConfigReload()}
>
Reload
</ui-button>
</div>
</div>
</div> </div>
</div> </div>
`; `;

View File

@ -11,9 +11,11 @@ export function renderSignalCard(params: {
accountCountLabel: unknown; accountCountLabel: unknown;
}) { }) {
const { props, signal, accountCountLabel } = params; const { props, signal, accountCountLabel } = params;
const configDisabled = props.configSaving || props.configSchemaLoading;
return html` return html`
<div class="card"> <div class="card card--channel">
<div class="card-content">
<div class="card-title">Signal</div> <div class="card-title">Signal</div>
<div class="card-sub">signal-cli status and channel configuration.</div> <div class="card-sub">signal-cli status and channel configuration.</div>
${accountCountLabel} ${accountCountLabel}
@ -41,12 +43,6 @@ export function renderSignalCard(params: {
</div> </div>
</div> </div>
${signal?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${signal.lastError}
</div>`
: nothing}
${signal?.probe ${signal?.probe
? html`<div class="callout" style="margin-top: 12px;"> ? html`<div class="callout" style="margin-top: 12px;">
Probe ${signal.probe.ok ? "ok" : "failed"} · Probe ${signal.probe.ok ? "ok" : "failed"} ·
@ -55,11 +51,35 @@ export function renderSignalCard(params: {
: nothing} : nothing}
${renderChannelConfigSection({ channelId: "signal", props })} ${renderChannelConfigSection({ channelId: "signal", props })}
</div>
<div class="row" style="margin-top: 12px;"> <div class="card-footer">
<button class="btn" @click=${() => props.onRefresh(true)}> ${signal?.lastError
? html`<div class="callout danger" style="margin-bottom: 12px;">
${signal.lastError}
</div>`
: nothing}
<div class="row" style="gap: 8px; align-items: center; flex-wrap: wrap;">
<ui-button @click=${() => props.onRefresh(true)}>
Probe Probe
</button> </ui-button>
<div style="margin-left: auto; display: flex; gap: 8px;">
<ui-button
variant="primary"
?disabled=${configDisabled || !props.configFormDirty}
@click=${() => props.onConfigSave()}
>
${props.configSaving ? "Saving…" : "Save Config"}
</ui-button>
<ui-button
?disabled=${configDisabled}
@click=${() => props.onConfigReload()}
>
Reload
</ui-button>
</div>
</div>
</div> </div>
</div> </div>
`; `;

View File

@ -11,9 +11,11 @@ export function renderSlackCard(params: {
accountCountLabel: unknown; accountCountLabel: unknown;
}) { }) {
const { props, slack, accountCountLabel } = params; const { props, slack, accountCountLabel } = params;
const configDisabled = props.configSaving || props.configSchemaLoading;
return html` return html`
<div class="card"> <div class="card card--channel">
<div class="card-content">
<div class="card-title">Slack</div> <div class="card-title">Slack</div>
<div class="card-sub">Socket mode status and channel configuration.</div> <div class="card-sub">Socket mode status and channel configuration.</div>
${accountCountLabel} ${accountCountLabel}
@ -37,12 +39,6 @@ export function renderSlackCard(params: {
</div> </div>
</div> </div>
${slack?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${slack.lastError}
</div>`
: nothing}
${slack?.probe ${slack?.probe
? html`<div class="callout" style="margin-top: 12px;"> ? html`<div class="callout" style="margin-top: 12px;">
Probe ${slack.probe.ok ? "ok" : "failed"} · Probe ${slack.probe.ok ? "ok" : "failed"} ·
@ -51,11 +47,35 @@ export function renderSlackCard(params: {
: nothing} : nothing}
${renderChannelConfigSection({ channelId: "slack", props })} ${renderChannelConfigSection({ channelId: "slack", props })}
</div>
<div class="row" style="margin-top: 12px;"> <div class="card-footer">
<button class="btn" @click=${() => props.onRefresh(true)}> ${slack?.lastError
? html`<div class="callout danger" style="margin-bottom: 12px;">
${slack.lastError}
</div>`
: nothing}
<div class="row" style="gap: 8px; align-items: center; flex-wrap: wrap;">
<ui-button @click=${() => props.onRefresh(true)}>
Probe Probe
</button> </ui-button>
<div style="margin-left: auto; display: flex; gap: 8px;">
<ui-button
variant="primary"
?disabled=${configDisabled || !props.configFormDirty}
@click=${() => props.onConfigSave()}
>
${props.configSaving ? "Saving…" : "Save Config"}
</ui-button>
<ui-button
?disabled=${configDisabled}
@click=${() => props.onConfigReload()}
>
Reload
</ui-button>
</div>
</div>
</div> </div>
</div> </div>
`; `;

View File

@ -13,6 +13,7 @@ export function renderTelegramCard(params: {
}) { }) {
const { props, telegram, telegramAccounts, accountCountLabel } = params; const { props, telegram, telegramAccounts, accountCountLabel } = params;
const hasMultipleAccounts = telegramAccounts.length > 1; const hasMultipleAccounts = telegramAccounts.length > 1;
const configDisabled = props.configSaving || props.configSchemaLoading;
const renderAccountCard = (account: ChannelAccountSnapshot) => { const renderAccountCard = (account: ChannelAccountSnapshot) => {
const probe = account.probe as { bot?: { username?: string } } | undefined; const probe = account.probe as { bot?: { username?: string } } | undefined;
@ -52,7 +53,8 @@ export function renderTelegramCard(params: {
}; };
return html` return html`
<div class="card"> <div class="card card--channel">
<div class="card-content">
<div class="card-title">Telegram</div> <div class="card-title">Telegram</div>
<div class="card-sub">Bot status and channel configuration.</div> <div class="card-sub">Bot status and channel configuration.</div>
${accountCountLabel} ${accountCountLabel}
@ -88,12 +90,6 @@ export function renderTelegramCard(params: {
</div> </div>
`} `}
${telegram?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${telegram.lastError}
</div>`
: nothing}
${telegram?.probe ${telegram?.probe
? html`<div class="callout" style="margin-top: 12px;"> ? html`<div class="callout" style="margin-top: 12px;">
Probe ${telegram.probe.ok ? "ok" : "failed"} · Probe ${telegram.probe.ok ? "ok" : "failed"} ·
@ -102,11 +98,35 @@ export function renderTelegramCard(params: {
: nothing} : nothing}
${renderChannelConfigSection({ channelId: "telegram", props })} ${renderChannelConfigSection({ channelId: "telegram", props })}
</div>
<div class="row" style="margin-top: 12px;"> <div class="card-footer">
<button class="btn" @click=${() => props.onRefresh(true)}> ${telegram?.lastError
? html`<div class="callout danger" style="margin-bottom: 12px;">
${telegram.lastError}
</div>`
: nothing}
<div class="row" style="gap: 8px; align-items: center; flex-wrap: wrap;">
<ui-button @click=${() => props.onRefresh(true)}>
Probe Probe
</button> </ui-button>
<div style="margin-left: auto; display: flex; gap: 8px;">
<ui-button
variant="primary"
?disabled=${configDisabled || !props.configFormDirty}
@click=${() => props.onConfigSave()}
>
${props.configSaving ? "Saving…" : "Save Config"}
</ui-button>
<ui-button
?disabled=${configDisabled}
@click=${() => props.onConfigReload()}
>
Reload
</ui-button>
</div>
</div>
</div> </div>
</div> </div>
`; `;

View File

@ -5,6 +5,7 @@ import type { WhatsAppStatus } from "../types";
import type { ChannelsProps } from "./channels.types"; import type { ChannelsProps } from "./channels.types";
import { renderChannelConfigSection } from "./channels.config"; import { renderChannelConfigSection } from "./channels.config";
import { formatDuration } from "./channels.shared"; import { formatDuration } from "./channels.shared";
import "../components/dropdown-menu";
export function renderWhatsAppCard(params: { export function renderWhatsAppCard(params: {
props: ChannelsProps; props: ChannelsProps;
@ -13,8 +14,32 @@ export function renderWhatsAppCard(params: {
}) { }) {
const { props, whatsapp, accountCountLabel } = 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` return html`
<div class="card"> <div class="card card--channel">
<div class="card-content">
<div class="card-title">WhatsApp</div> <div class="card-title">WhatsApp</div>
<div class="card-sub">Link WhatsApp Web and monitor connection health.</div> <div class="card-sub">Link WhatsApp Web and monitor connection health.</div>
${accountCountLabel} ${accountCountLabel}
@ -60,12 +85,6 @@ export function renderWhatsAppCard(params: {
</div> </div>
</div> </div>
${whatsapp?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${whatsapp.lastError}
</div>`
: nothing}
${props.whatsappMessage ${props.whatsappMessage
? html`<div class="callout" style="margin-top: 12px;"> ? html`<div class="callout" style="margin-top: 12px;">
${props.whatsappMessage} ${props.whatsappMessage}
@ -78,41 +97,49 @@ export function renderWhatsAppCard(params: {
</div>` </div>`
: nothing} : nothing}
<div class="row" style="margin-top: 14px; flex-wrap: wrap;"> ${renderChannelConfigSection({ channelId: "whatsapp", props })}
<button </div>
class="btn primary"
<div class="card-footer">
${whatsapp?.lastError
? html`<div class="callout danger" style="margin-bottom: 12px;">
${whatsapp.lastError}
</div>`
: nothing}
<div class="row" style="gap: 8px; align-items: center; flex-wrap: wrap;">
<ui-button
variant="primary"
?disabled=${props.whatsappBusy} ?disabled=${props.whatsappBusy}
@click=${() => props.onWhatsAppStart(false)} @click=${() => props.onWhatsAppStart(false)}
> >
${props.whatsappBusy ? "Working…" : "Show QR"} ${props.whatsappBusy ? "Working…" : "Show QR"}
</button> </ui-button>
<button <ui-button @click=${() => props.onRefresh(true)}>
class="btn"
?disabled=${props.whatsappBusy}
@click=${() => props.onWhatsAppStart(true)}
>
Relink
</button>
<button
class="btn"
?disabled=${props.whatsappBusy}
@click=${() => props.onWhatsAppWait()}
>
Wait for scan
</button>
<button
class="btn danger"
?disabled=${props.whatsappBusy}
@click=${() => props.onWhatsAppLogout()}
>
Logout
</button>
<button class="btn" @click=${() => props.onRefresh(true)}>
Refresh Refresh
</button> </ui-button>
<ui-dropdown-menu
.items=${menuItems}
?disabled=${props.whatsappBusy}
@select=${handleMenuSelect}
></ui-dropdown-menu>
<div style="margin-left: auto; display: flex; gap: 8px;">
<ui-button
variant="primary"
?disabled=${configDisabled || !props.configFormDirty}
@click=${() => props.onConfigSave()}
>
${props.configSaving ? "Saving…" : "Save Config"}
</ui-button>
<ui-button
?disabled=${configDisabled}
@click=${() => props.onConfigReload()}
>
Reload
</ui-button>
</div>
</div>
</div> </div>
${renderChannelConfigSection({ channelId: "whatsapp", props })}
</div> </div>
`; `;
} }

View File

@ -16,6 +16,7 @@ import {
} from "../chat/grouped-render"; } from "../chat/grouped-render";
import { renderMarkdownSidebar } from "./markdown-sidebar"; import { renderMarkdownSidebar } from "./markdown-sidebar";
import "../components/resizable-divider"; import "../components/resizable-divider";
import "../components/button";
export type CompactionIndicatorStatus = { export type CompactionIndicatorStatus = {
active: boolean; active: boolean;

View File

@ -27,13 +27,18 @@ function jsonValue(value: unknown): string {
} }
// SVG Icons as template literals // SVG Icons as template literals
const icons = { import "../components";
chevronDown: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"></polyline></svg>`,
plus: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>`, // Helper to wrap generic patch with component events
minus: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="12" x2="19" y2="12"></line></svg>`, function handlePatch(
trash: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>`, path: Array<string | number>,
edit: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg>`, onPatch: (path: Array<string | number>, value: unknown) => void
}; ) {
return (e: CustomEvent) => {
const { value, checked } = e.detail;
onPatch(path, checked ?? value);
};
}
export function renderNode(params: { export function renderNode(params: {
schema: JsonSchema; schema: JsonSchema;
@ -83,22 +88,21 @@ export function renderNode(params: {
if (allLiterals && literals.length > 0 && literals.length <= 5) { if (allLiterals && literals.length > 0 && literals.length <= 5) {
// Use segmented control for small sets // Use segmented control for small sets
const resolvedValue = value ?? schema.default; const resolvedValue = value ?? schema.default;
const options = literals.map((lit) => ({ label: String(lit), value: String(lit) }));
return html` return html`
<div class="cfg-field"> <div class="cfg-field">
${showLabel ? html`<label class="cfg-field__label">${label}</label>` : nothing} ${showLabel ? html`<label class="cfg-field__label">${label}</label>` : nothing}
${help ? html`<div class="cfg-field__help">${help}</div>` : nothing} ${help ? html`<div class="cfg-field__help">${help}</div>` : nothing}
<div class="cfg-segmented"> <ui-segmented-control
${literals.map((lit, idx) => html` .options=${options}
<button .value=${String(resolvedValue)}
type="button" @change=${(e: CustomEvent) => {
class="cfg-segmented__btn ${lit === resolvedValue || String(lit) === String(resolvedValue) ? 'active' : ''}" // Find original literal type
?disabled=${disabled} const selected = literals.find(l => String(l) === e.detail.value);
@click=${() => onPatch(path, lit)} onPatch(path, selected);
> }}
${String(lit)} ></ui-segmented-control>
</button>
`)}
</div>
</div> </div>
`; `;
} }
@ -142,22 +146,18 @@ export function renderNode(params: {
const options = schema.enum; const options = schema.enum;
if (options.length <= 5) { if (options.length <= 5) {
const resolvedValue = value ?? schema.default; const resolvedValue = value ?? schema.default;
const enumOptions = options.map((opt) => ({ label: String(opt), value: String(opt) }));
return html` return html`
<div class="cfg-field"> <div class="cfg-field">
${showLabel ? html`<label class="cfg-field__label">${label}</label>` : nothing} ${showLabel ? html`<label class="cfg-field__label">${label}</label>` : nothing}
${help ? html`<div class="cfg-field__help">${help}</div>` : nothing} ${help ? html`<div class="cfg-field__help">${help}</div>` : nothing}
<div class="cfg-segmented"> <ui-segmented-control
${options.map((opt) => html` .options=${enumOptions}
<button .value=${String(resolvedValue)}
type="button" @change=${(e: CustomEvent) => {
class="cfg-segmented__btn ${opt === resolvedValue || String(opt) === String(resolvedValue) ? 'active' : ''}" onPatch(path, e.detail.value);
?disabled=${disabled} }}
@click=${() => onPatch(path, opt)} ></ui-segmented-control>
>
${String(opt)}
</button>
`)}
</div>
</div> </div>
`; `;
} }
@ -178,21 +178,17 @@ export function renderNode(params: {
if (type === "boolean") { if (type === "boolean") {
const displayValue = typeof value === "boolean" ? value : typeof schema.default === "boolean" ? schema.default : false; const displayValue = typeof value === "boolean" ? value : typeof schema.default === "boolean" ? schema.default : false;
return html` return html`
<label class="cfg-toggle-row ${disabled ? 'disabled' : ''}"> <div class="cfg-toggle-row ${disabled ? 'disabled' : ''}">
<div class="cfg-toggle-row__content"> <div class="cfg-toggle-row__content">
<span class="cfg-toggle-row__label">${label}</span> <span class="cfg-toggle-row__label">${label}</span>
${help ? html`<span class="cfg-toggle-row__help">${help}</span>` : nothing} ${help ? html`<span class="cfg-toggle-row__help">${help}</span>` : nothing}
</div> </div>
<div class="cfg-toggle"> <ui-switch
<input
type="checkbox"
.checked=${displayValue} .checked=${displayValue}
?disabled=${disabled} ?disabled=${disabled}
@change=${(e: Event) => onPatch(path, (e.target as HTMLInputElement).checked)} @change=${handlePatch(path, onPatch)}
/> ></ui-switch>
<span class="cfg-toggle__track"></span>
</div> </div>
</label>
`; `;
} }
@ -241,14 +237,13 @@ function renderTextInput(params: {
${showLabel ? html`<label class="cfg-field__label">${label}</label>` : nothing} ${showLabel ? html`<label class="cfg-field__label">${label}</label>` : nothing}
${help ? html`<div class="cfg-field__help">${help}</div>` : nothing} ${help ? html`<div class="cfg-field__help">${help}</div>` : nothing}
<div class="cfg-input-wrap"> <div class="cfg-input-wrap">
<input <ui-input
type=${isSensitive ? "password" : inputType} .type=${isSensitive ? "password" : inputType}
class="cfg-input" .placeholder=${placeholder}
placeholder=${placeholder}
.value=${displayValue == null ? "" : String(displayValue)} .value=${displayValue == null ? "" : String(displayValue)}
?disabled=${disabled} ?disabled=${disabled}
@input=${(e: Event) => { @input=${(e: CustomEvent) => {
const raw = (e.target as HTMLInputElement).value; const raw = e.detail.value;
if (inputType === "number") { if (inputType === "number") {
if (raw.trim() === "") { if (raw.trim() === "") {
onPatch(path, undefined); onPatch(path, undefined);
@ -260,15 +255,16 @@ function renderTextInput(params: {
} }
onPatch(path, raw); onPatch(path, raw);
}} }}
/> ></ui-input>
${schema.default !== undefined ? html` ${schema.default !== undefined ? html`
<button <ui-button
type="button" variant="ghost"
size="icon"
class="cfg-input__reset" class="cfg-input__reset"
title="Reset to default" title="Reset to default"
?disabled=${disabled} ?disabled=${disabled}
@click=${() => onPatch(path, schema.default)} @click=${() => onPatch(path, schema.default)}
></button> ></ui-button>
` : nothing} ` : nothing}
</div> </div>
</div> </div>
@ -297,29 +293,16 @@ function renderNumberInput(params: {
${showLabel ? html`<label class="cfg-field__label">${label}</label>` : nothing} ${showLabel ? html`<label class="cfg-field__label">${label}</label>` : nothing}
${help ? html`<div class="cfg-field__help">${help}</div>` : nothing} ${help ? html`<div class="cfg-field__help">${help}</div>` : nothing}
<div class="cfg-number"> <div class="cfg-number">
<button <ui-input
type="button"
class="cfg-number__btn"
?disabled=${disabled}
@click=${() => onPatch(path, numValue - 1)}
></button>
<input
type="number" type="number"
class="cfg-number__input"
.value=${displayValue == null ? "" : String(displayValue)} .value=${displayValue == null ? "" : String(displayValue)}
?disabled=${disabled} ?disabled=${disabled}
@input=${(e: Event) => { @input=${(e: CustomEvent) => {
const raw = (e.target as HTMLInputElement).value; const raw = e.detail.value;
const parsed = raw === "" ? undefined : Number(raw); const parsed = raw === "" ? undefined : Number(raw);
onPatch(path, parsed); onPatch(path, parsed);
}} }}
/> ></ui-input>
<button
type="button"
class="cfg-number__btn"
?disabled=${disabled}
@click=${() => onPatch(path, numValue + 1)}
>+</button>
</div> </div>
</div> </div>
`; `;
@ -350,20 +333,18 @@ function renderSelect(params: {
<div class="cfg-field"> <div class="cfg-field">
${showLabel ? html`<label class="cfg-field__label">${label}</label>` : nothing} ${showLabel ? html`<label class="cfg-field__label">${label}</label>` : nothing}
${help ? html`<div class="cfg-field__help">${help}</div>` : nothing} ${help ? html`<div class="cfg-field__help">${help}</div>` : nothing}
<select <ui-select
class="cfg-select" .options=${[
{ label: "Select...", value: unset },
...options.map((opt, idx) => ({ label: String(opt), value: String(idx) }))
]}
?disabled=${disabled} ?disabled=${disabled}
.value=${currentIndex >= 0 ? String(currentIndex) : unset} .value=${currentIndex >= 0 ? String(currentIndex) : unset}
@change=${(e: Event) => { @change=${(e: CustomEvent) => {
const val = (e.target as HTMLSelectElement).value; const val = e.detail.value;
onPatch(path, val === unset ? undefined : options[Number(val)]); onPatch(path, val === unset ? undefined : options[Number(val)]);
}} }}
> ></ui-select>
<option value=${unset}>Select...</option>
${options.map((opt, idx) => html`
<option value=${String(idx)}>${String(opt)}</option>
`)}
</select>
</div> </div>
`; `;
} }
@ -437,7 +418,7 @@ function renderObject(params: {
<details class="cfg-object" open> <details class="cfg-object" open>
<summary class="cfg-object__header"> <summary class="cfg-object__header">
<span class="cfg-object__title">${label}</span> <span class="cfg-object__title">${label}</span>
<span class="cfg-object__chevron">${icons.chevronDown}</span> <ui-icon name="chevronDown" class="cfg-object__chevron"></ui-icon>
</summary> </summary>
${help ? html`<div class="cfg-object__help">${help}</div>` : nothing} ${help ? html`<div class="cfg-object__help">${help}</div>` : nothing}
<div class="cfg-object__content"> <div class="cfg-object__content">
@ -499,19 +480,17 @@ function renderArray(params: {
<div class="cfg-array"> <div class="cfg-array">
<div class="cfg-array__header"> <div class="cfg-array__header">
${showLabel ? html`<span class="cfg-array__label">${label}</span>` : nothing} ${showLabel ? html`<span class="cfg-array__label">${label}</span>` : nothing}
<span class="cfg-array__count">${arr.length} item${arr.length !== 1 ? 's' : ''}</span> <ui-badge variant="secondary">${arr.length} item${arr.length !== 1 ? 's' : ''}</ui-badge>
<button <ui-button
type="button" variant="secondary"
class="cfg-array__add" size="sm"
icon="plus"
?disabled=${disabled} ?disabled=${disabled}
@click=${() => { @click=${() => {
const next = [...arr, defaultValue(itemsSchema)]; const next = [...arr, defaultValue(itemsSchema)];
onPatch(path, next); onPatch(path, next);
}} }}
> >Add</ui-button>
<span class="cfg-array__add-icon">${icons.plus}</span>
Add
</button>
</div> </div>
${help ? html`<div class="cfg-array__help">${help}</div>` : nothing} ${help ? html`<div class="cfg-array__help">${help}</div>` : nothing}
@ -525,9 +504,10 @@ function renderArray(params: {
<div class="cfg-array__item"> <div class="cfg-array__item">
<div class="cfg-array__item-header"> <div class="cfg-array__item-header">
<span class="cfg-array__item-index">#${idx + 1}</span> <span class="cfg-array__item-index">#${idx + 1}</span>
<button <ui-button
type="button" variant="ghost"
class="cfg-array__item-remove" size="icon"
icon="trash"
title="Remove item" title="Remove item"
?disabled=${disabled} ?disabled=${disabled}
@click=${() => { @click=${() => {
@ -535,9 +515,7 @@ function renderArray(params: {
next.splice(idx, 1); next.splice(idx, 1);
onPatch(path, next); onPatch(path, next);
}} }}
> ></ui-button>
${icons.trash}
</button>
</div> </div>
<div class="cfg-array__item-content"> <div class="cfg-array__item-content">
${renderNode({ ${renderNode({
@ -577,9 +555,10 @@ function renderMapField(params: {
<div class="cfg-map"> <div class="cfg-map">
<div class="cfg-map__header"> <div class="cfg-map__header">
<span class="cfg-map__label">Custom entries</span> <span class="cfg-map__label">Custom entries</span>
<button <ui-button
type="button" variant="secondary"
class="cfg-map__add" size="sm"
icon="plus"
?disabled=${disabled} ?disabled=${disabled}
@click=${() => { @click=${() => {
const next = { ...(value ?? {}) }; const next = { ...(value ?? {}) };
@ -592,10 +571,7 @@ function renderMapField(params: {
next[key] = anySchema ? {} : defaultValue(schema); next[key] = anySchema ? {} : defaultValue(schema);
onPatch(path, next); onPatch(path, next);
}} }}
> >Add Entry</ui-button>
<span class="cfg-map__add-icon">${icons.plus}</span>
Add Entry
</button>
</div> </div>
${entries.length === 0 ? html` ${entries.length === 0 ? html`
@ -608,14 +584,12 @@ function renderMapField(params: {
return html` return html`
<div class="cfg-map__item"> <div class="cfg-map__item">
<div class="cfg-map__item-key"> <div class="cfg-map__item-key">
<input <ui-input
type="text"
class="cfg-input cfg-input--sm"
placeholder="Key" placeholder="Key"
.value=${key} .value=${key}
?disabled=${disabled} ?disabled=${disabled}
@change=${(e: Event) => { @change=${(e: CustomEvent) => {
const nextKey = (e.target as HTMLInputElement).value.trim(); const nextKey = e.detail.value.trim();
if (!nextKey || nextKey === key) return; if (!nextKey || nextKey === key) return;
const next = { ...(value ?? {}) }; const next = { ...(value ?? {}) };
if (nextKey in next) return; if (nextKey in next) return;
@ -623,20 +597,18 @@ function renderMapField(params: {
delete next[key]; delete next[key];
onPatch(path, next); onPatch(path, next);
}} }}
/> ></ui-input>
</div> </div>
<div class="cfg-map__item-value"> <div class="cfg-map__item-value">
${anySchema ${anySchema
? html` ? html`
<textarea <ui-textarea
class="cfg-textarea cfg-textarea--sm"
placeholder="JSON value" placeholder="JSON value"
rows="2" .rows=${2}
.value=${fallback} .value=${fallback}
?disabled=${disabled} ?disabled=${disabled}
@change=${(e: Event) => { @change=${(e: CustomEvent) => {
const target = e.target as HTMLTextAreaElement; const raw = e.detail.value.trim();
const raw = target.value.trim();
if (!raw) { if (!raw) {
onPatch(valuePath, undefined); onPatch(valuePath, undefined);
return; return;
@ -644,10 +616,10 @@ function renderMapField(params: {
try { try {
onPatch(valuePath, JSON.parse(raw)); onPatch(valuePath, JSON.parse(raw));
} catch { } catch {
target.value = fallback; // target.value = fallback; // ui-textarea handles its own value
} }
}} }}
></textarea> ></ui-textarea>
` `
: renderNode({ : renderNode({
schema, schema,
@ -660,9 +632,11 @@ function renderMapField(params: {
onPatch, onPatch,
})} })}
</div> </div>
<button <ui-button
type="button" type="button"
class="cfg-map__item-remove" variant="ghost"
size="icon"
icon="trash"
title="Remove entry" title="Remove entry"
?disabled=${disabled} ?disabled=${disabled}
@click=${() => { @click=${() => {
@ -670,9 +644,7 @@ function renderMapField(params: {
delete next[key]; delete next[key];
onPatch(path, next); onPatch(path, next);
}} }}
> ></ui-button>
${icons.trash}
</button>
</div> </div>
`; `;
})} })}

View File

@ -7,6 +7,10 @@ import {
schemaType, schemaType,
type JsonSchema, type JsonSchema,
} from "./config-form.shared"; } from "./config-form.shared";
import "../components/button";
import "../components/badge";
import "../components/input";
import "../components/textarea";
export type ConfigProps = { export type ConfigProps = {
raw: string; raw: string;
@ -261,17 +265,12 @@ export function renderConfig(props: ConfigProps) {
<!-- Search --> <!-- Search -->
<div class="config-search"> <div class="config-search">
<svg class="config-search__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <ui-input
<circle cx="11" cy="11" r="8"></circle>
<path d="M21 21l-4.35-4.35"></path>
</svg>
<input
type="text"
class="config-search__input"
placeholder="Search settings..." placeholder="Search settings..."
.value=${props.searchQuery} .value=${props.searchQuery}
@input=${(e: Event) => props.onSearchChange((e.target as HTMLInputElement).value)} icon="search"
/> @input=${(e: CustomEvent) => props.onSearchChange(e.detail.value)}
></ui-input>
${props.searchQuery ? html` ${props.searchQuery ? html`
<button <button
class="config-search__clear" class="config-search__clear"
@ -305,7 +304,6 @@ export function renderConfig(props: ConfigProps) {
<div class="config-mode-toggle"> <div class="config-mode-toggle">
<button <button
class="config-mode-toggle__btn ${props.formMode === "form" ? "active" : ""}" class="config-mode-toggle__btn ${props.formMode === "form" ? "active" : ""}"
?disabled=${props.schemaLoading || !props.schema}
@click=${() => props.onFormModeChange("form")} @click=${() => props.onFormModeChange("form")}
> >
Form Form
@ -332,30 +330,31 @@ export function renderConfig(props: ConfigProps) {
`} `}
</div> </div>
<div class="config-actions__right"> <div class="config-actions__right">
<button class="btn btn--sm" ?disabled=${props.loading} @click=${props.onReload}> <ui-button size="sm" ?disabled=${props.loading} @click=${props.onReload}>
${props.loading ? "Loading…" : "Reload"} ${props.loading ? "Loading…" : "Reload"}
</button> </ui-button>
<button <ui-button
class="btn btn--sm primary" variant="primary"
size="sm"
?disabled=${!canSave} ?disabled=${!canSave}
@click=${props.onSave} @click=${props.onSave}
> >
${props.saving ? "Saving…" : "Save"} ${props.saving ? "Saving…" : "Save"}
</button> </ui-button>
<button <ui-button
class="btn btn--sm" size="sm"
?disabled=${!canApply} ?disabled=${!canApply}
@click=${props.onApply} @click=${props.onApply}
> >
${props.applying ? "Applying…" : "Apply"} ${props.applying ? "Applying…" : "Apply"}
</button> </ui-button>
<button <ui-button
class="btn btn--sm" size="sm"
?disabled=${!canUpdate} ?disabled=${!canUpdate}
@click=${props.onUpdate} @click=${props.onUpdate}
> >
${props.updating ? "Updating…" : "Update"} ${props.updating ? "Updating…" : "Update"}
</button> </ui-button>
</div> </div>
</div> </div>
@ -451,14 +450,15 @@ export function renderConfig(props: ConfigProps) {
: nothing} : nothing}
` `
: html` : html`
<label class="field config-raw-field"> <div class="config-raw-wrap">
<span>Raw JSON5</span> <ui-textarea
<textarea label="Raw JSON5"
.value=${props.raw} .value=${props.raw}
@input=${(e: Event) => .rows=${25}
props.onRawChange((e.target as HTMLTextAreaElement).value)} @input=${(e: CustomEvent) =>
></textarea> props.onRawChange(e.detail.value)}
</label> ></ui-textarea>
</div>
`} `}
</div> </div>

View File

@ -116,15 +116,14 @@ export function renderCron(props: CronProps) {
placeholder="default" placeholder="default"
/> />
</label> </label>
<label class="field checkbox"> <div style="display: flex; align-items: flex-end; height: 100%; padding-bottom: 10px;">
<span>Enabled</span> <ui-checkbox
<input label="Enabled"
type="checkbox"
.checked=${props.form.enabled} .checked=${props.form.enabled}
@change=${(e: Event) => @change=${(e: CustomEvent) =>
props.onFormChange({ enabled: (e.target as HTMLInputElement).checked })} props.onFormChange({ enabled: e.detail.checked })}
/> ></ui-checkbox>
</label> </div>
<label class="field"> <label class="field">
<span>Schedule</span> <span>Schedule</span>
<select <select

View File

@ -2,6 +2,8 @@ import { html, nothing } from "lit";
import { formatEventPayload } from "../presenter"; import { formatEventPayload } from "../presenter";
import type { EventLogEntry } from "../app-events"; import type { EventLogEntry } from "../app-events";
import "../components/button";
import "../components/input";
export type DebugProps = { export type DebugProps = {
loading: boolean; loading: boolean;
@ -45,9 +47,9 @@ export function renderDebug(props: DebugProps) {
<div class="card-title">Snapshots</div> <div class="card-title">Snapshots</div>
<div class="card-sub">Status, health, and heartbeat data.</div> <div class="card-sub">Status, health, and heartbeat data.</div>
</div> </div>
<button class="btn" ?disabled=${props.loading} @click=${props.onRefresh}> <ui-button ?disabled=${props.loading} @click=${props.onRefresh}>
${props.loading ? "Refreshing…" : "Refresh"} ${props.loading ? "Refreshing…" : "Refresh"}
</button> </ui-button>
</div> </div>
<div class="stack" style="margin-top: 12px;"> <div class="stack" style="margin-top: 12px;">
<div> <div>
@ -74,28 +76,26 @@ export function renderDebug(props: DebugProps) {
<div class="card"> <div class="card">
<div class="card-title">Manual RPC</div> <div class="card-title">Manual RPC</div>
<div class="card-sub">Send a raw gateway method with JSON params.</div> <div class="card-sub">Send a raw gateway method with JSON params.</div>
<div class="form-grid" style="margin-top: 16px;"> <div style="margin-top: 16px; display: grid; gap: 16px;">
<label class="field"> <ui-input
<span>Method</span> label="Method"
<input
.value=${props.callMethod} .value=${props.callMethod}
@input=${(e: Event) =>
props.onCallMethodChange((e.target as HTMLInputElement).value)}
placeholder="system-presence" placeholder="system-presence"
/> @input=${(e: CustomEvent) => props.onCallMethodChange(e.detail.value)}
</label> ></ui-input>
<label class="field"> <label class="field">
<span>Params (JSON)</span> <span>Params (JSON)</span>
<textarea <textarea
.value=${props.callParams} .value=${props.callParams}
@input=${(e: Event) => @input=${(e: Event) =>
props.onCallParamsChange((e.target as HTMLTextAreaElement).value)} props.onCallParamsChange((e.target as HTMLTextAreaElement).value)}
placeholder="{}"
rows="6" rows="6"
></textarea> ></textarea>
</label> </label>
</div> </div>
<div class="row" style="margin-top: 12px;"> <div style="margin-top: 16px;">
<button class="btn primary" @click=${props.onCall}>Call</button> <ui-button variant="primary" @click=${props.onCall}>Call</ui-button>
</div> </div>
${props.callError ${props.callError
? html`<div class="callout danger" style="margin-top: 12px;"> ? html`<div class="callout danger" style="margin-top: 12px;">

View File

@ -1,6 +1,9 @@
import { html, nothing } from "lit"; import { html, nothing } from "lit";
import type { LogEntry, LogLevel } from "../types"; import type { LogEntry, LogLevel } from "../types";
import "../components/button";
import "../components/input";
import "../components/checkbox";
const LEVELS: LogLevel[] = ["trace", "debug", "info", "warn", "error", "fatal"]; const LEVELS: LogLevel[] = ["trace", "debug", "info", "warn", "error", "fatal"];
@ -54,69 +57,66 @@ export function renderLogs(props: LogsProps) {
<div class="card-sub">Gateway file logs (JSONL).</div> <div class="card-sub">Gateway file logs (JSONL).</div>
</div> </div>
<div class="row" style="gap: 8px;"> <div class="row" style="gap: 8px;">
<button class="btn" ?disabled=${props.loading} @click=${props.onRefresh}> <ui-button ?disabled=${props.loading} @click=${props.onRefresh}>
${props.loading ? "Loading…" : "Refresh"} ${props.loading ? "Loading…" : "Refresh"}
</button> </ui-button>
<button <ui-button
class="btn"
?disabled=${filtered.length === 0} ?disabled=${filtered.length === 0}
@click=${() => props.onExport(filtered.map((entry) => entry.raw), exportLabel)} @click=${() => props.onExport(filtered.map((entry) => entry.raw), exportLabel)}
> >
Export ${exportLabel} Export ${exportLabel}
</button> </ui-button>
</div> </div>
</div> </div>
<div class="filters" style="margin-top: 14px;"> <div style="margin-top: 16px; display: flex; gap: 16px; align-items: center; flex-wrap: wrap;">
<label class="field" style="min-width: 220px;"> <ui-input
<span>Filter</span> label="Filter"
<input
.value=${props.filterText} .value=${props.filterText}
@input=${(e: Event) => @input=${(e: CustomEvent) => props.onFilterTextChange(e.detail.value)}
props.onFilterTextChange((e.target as HTMLInputElement).value)}
placeholder="Search logs" placeholder="Search logs"
/> style="width: 240px;"
</label> ></ui-input>
<label class="field checkbox"> <div style="margin-top: 19px;">
<span>Auto-follow</span> <ui-checkbox
<input label="Auto-follow"
type="checkbox"
.checked=${props.autoFollow} .checked=${props.autoFollow}
@change=${(e: Event) => @change=${(e: CustomEvent) => props.onToggleAutoFollow(e.detail.checked)}
props.onToggleAutoFollow((e.target as HTMLInputElement).checked)} ></ui-checkbox>
/> </div>
</label>
</div> </div>
<div class="chip-row" style="margin-top: 12px;"> <div style="margin-top: 16px; display: flex; gap: 12px; flex-wrap: wrap; align-items: center;">
${LEVELS.map( ${LEVELS.map(
(level) => html` (level) => html`
<label class="chip log-chip ${level}"> <label class="log-level-filter log-level-filter--${level}" style="display: inline-flex; align-items: center; gap: 8px; cursor: pointer; user-select: none;">
<input <input
type="checkbox" type="checkbox"
.checked=${props.levelFilters[level]} .checked=${props.levelFilters[level]}
@change=${(e: Event) => @change=${(e: Event) =>
props.onLevelToggle(level, (e.target as HTMLInputElement).checked)} props.onLevelToggle(level, (e.target as HTMLInputElement).checked)}
style="display: none;"
/> />
<span>${level}</span> <span class="log-level-filter__checkbox"></span>
<span class="log-level-filter__label">${level}</span>
</label> </label>
`, `,
)} )}
</div> </div>
${props.file ${props.file
? html`<div class="muted" style="margin-top: 10px;">File: ${props.file}</div>` ? html`<div class="muted" style="margin-top: 12px;">File: ${props.file}</div>`
: nothing} : nothing}
${props.truncated ${props.truncated
? html`<div class="callout" style="margin-top: 10px;"> ? html`<div class="callout" style="margin-top: 12px;">
Log output truncated; showing latest chunk. Log output truncated; showing latest chunk.
</div>` </div>`
: nothing} : nothing}
${props.error ${props.error
? html`<div class="callout danger" style="margin-top: 10px;">${props.error}</div>` ? html`<div class="callout danger" style="margin-top: 12px;">${props.error}</div>`
: nothing} : nothing}
<div class="log-stream" style="margin-top: 12px;" @scroll=${props.onScroll}> <div class="log-stream" style="margin-top: 16px;" @scroll=${props.onScroll}>
${filtered.length === 0 ${filtered.length === 0
? html`<div class="muted" style="padding: 12px;">No log entries.</div>` ? html`<div class="muted" style="padding: 12px;">No log entries.</div>`
: filtered.map( : filtered.map(

View File

@ -12,6 +12,12 @@ import type {
PairedDevice, PairedDevice,
PendingDevice, PendingDevice,
} from "../controllers/devices"; } from "../controllers/devices";
import "../components/button";
import "../components/badge";
import "../components/input";
import "../components/card";
import "../components/checkbox";
import "../components/select";
export type NodesProps = { export type NodesProps = {
loading: boolean; loading: boolean;
@ -57,22 +63,24 @@ export function renderNodes(props: NodesProps) {
${renderExecApprovals(approvalsState)} ${renderExecApprovals(approvalsState)}
${renderBindings(bindingState)} ${renderBindings(bindingState)}
${renderDevices(props)} ${renderDevices(props)}
<section class="card"> <ui-card>
<div class="row" style="justify-content: space-between;"> <div slot="header">
<div class="row" style="justify-content: space-between; width: 100%;">
<div> <div>
<div class="card-title">Nodes</div> <div class="card-title">Nodes</div>
<div class="card-sub">Paired devices and live links.</div> <div class="card-desc">Paired devices and live links.</div>
</div> </div>
<button class="btn" ?disabled=${props.loading} @click=${props.onRefresh}> <ui-button ?disabled=${props.loading} @click=${props.onRefresh}>
${props.loading ? "Loading…" : "Refresh"} ${props.loading ? "Loading…" : "Refresh"}
</button> </ui-button>
</div> </div>
<div class="list" style="margin-top: 16px;"> </div>
<div class="list">
${props.nodes.length === 0 ${props.nodes.length === 0
? html`<div class="muted">No nodes found.</div>` ? html`<div class="muted">No nodes found.</div>`
: props.nodes.map((n) => renderNode(n))} : props.nodes.map((n) => renderNode(n))}
</div> </div>
</section> </ui-card>
`; `;
} }
@ -81,20 +89,22 @@ function renderDevices(props: NodesProps) {
const pending = Array.isArray(list.pending) ? list.pending : []; const pending = Array.isArray(list.pending) ? list.pending : [];
const paired = Array.isArray(list.paired) ? list.paired : []; const paired = Array.isArray(list.paired) ? list.paired : [];
return html` return html`
<section class="card"> <ui-card>
<div class="row" style="justify-content: space-between;"> <div slot="header">
<div class="row" style="justify-content: space-between; width: 100%;">
<div> <div>
<div class="card-title">Devices</div> <div class="card-title">Devices</div>
<div class="card-sub">Pairing requests + role tokens.</div> <div class="card-desc">Pairing requests + role tokens.</div>
</div> </div>
<button class="btn" ?disabled=${props.devicesLoading} @click=${props.onDevicesRefresh}> <ui-button ?disabled=${props.devicesLoading} @click=${props.onDevicesRefresh}>
${props.devicesLoading ? "Loading…" : "Refresh"} ${props.devicesLoading ? "Loading…" : "Refresh"}
</button> </ui-button>
</div>
</div> </div>
${props.devicesError ${props.devicesError
? html`<div class="callout danger" style="margin-top: 12px;">${props.devicesError}</div>` ? html`<div class="callout danger" style="margin-bottom: 12px;">${props.devicesError}</div>`
: nothing} : nothing}
<div class="list" style="margin-top: 16px;"> <div class="list">
${pending.length > 0 ${pending.length > 0
? html` ? html`
<div class="muted" style="margin-bottom: 8px;">Pending</div> <div class="muted" style="margin-bottom: 8px;">Pending</div>
@ -111,7 +121,7 @@ function renderDevices(props: NodesProps) {
? html`<div class="muted">No paired devices.</div>` ? html`<div class="muted">No paired devices.</div>`
: nothing} : nothing}
</div> </div>
</section> </ui-card>
`; `;
} }
@ -132,12 +142,12 @@ function renderPendingDevice(req: PendingDevice, props: NodesProps) {
</div> </div>
<div class="list-meta"> <div class="list-meta">
<div class="row" style="justify-content: flex-end; gap: 8px; flex-wrap: wrap;"> <div class="row" style="justify-content: flex-end; gap: 8px; flex-wrap: wrap;">
<button class="btn btn--sm primary" @click=${() => props.onDeviceApprove(req.requestId)}> <ui-button variant="primary" size="sm" @click=${() => props.onDeviceApprove(req.requestId)}>
Approve Approve
</button> </ui-button>
<button class="btn btn--sm" @click=${() => props.onDeviceReject(req.requestId)}> <ui-button size="sm" @click=${() => props.onDeviceReject(req.requestId)}>
Reject Reject
</button> </ui-button>
</div> </div>
</div> </div>
</div> </div>
@ -177,21 +187,22 @@ function renderTokenRow(deviceId: string, token: DeviceTokenSummary, props: Node
<div class="row" style="justify-content: space-between; gap: 8px;"> <div class="row" style="justify-content: space-between; gap: 8px;">
<div class="list-sub">${token.role} · ${status} · ${scopes} · ${when}</div> <div class="list-sub">${token.role} · ${status} · ${scopes} · ${when}</div>
<div class="row" style="justify-content: flex-end; gap: 6px; flex-wrap: wrap;"> <div class="row" style="justify-content: flex-end; gap: 6px; flex-wrap: wrap;">
<button <ui-button
class="btn btn--sm" size="sm"
@click=${() => props.onDeviceRotate(deviceId, token.role, token.scopes)} @click=${() => props.onDeviceRotate(deviceId, token.role, token.scopes)}
> >
Rotate Rotate
</button> </ui-button>
${token.revokedAtMs ${token.revokedAtMs
? nothing ? nothing
: html` : html`
<button <ui-button
class="btn btn--sm danger" size="sm"
variant="danger"
@click=${() => props.onDeviceRevoke(deviceId, token.role)} @click=${() => props.onDeviceRevoke(deviceId, token.role)}
> >
Revoke Revoke
</button> </ui-button>
`} `}
</div> </div>
</div> </div>
@ -433,25 +444,25 @@ function renderBindings(state: BindingState) {
const supportsBinding = state.nodes.length > 0; const supportsBinding = state.nodes.length > 0;
const defaultValue = state.defaultBinding ?? ""; const defaultValue = state.defaultBinding ?? "";
return html` return html`
<section class="card"> <ui-card>
<div class="row" style="justify-content: space-between; align-items: center;"> <div slot="header" style="display: contents;">
<div> <div>
<div class="card-title">Exec node binding</div> <div class="card-title">Exec node binding</div>
<div class="card-sub"> <div class="card-desc">
Pin agents to a specific node when using <span class="mono">exec host=node</span>. Pin agents to a specific node when using <span class="mono">exec host=node</span>.
</div> </div>
</div> </div>
<button <ui-button
class="btn" variant="primary"
?disabled=${state.disabled || !state.configDirty} ?disabled=${state.disabled || !state.configDirty}
@click=${state.onSave} @click=${state.onSave}
> >
${state.configSaving ? "Saving…" : "Save"} ${state.configSaving ? "Saving…" : "Save"}
</button> </ui-button>
</div> </div>
${state.formMode === "raw" ${state.formMode === "raw"
? html`<div class="callout warn" style="margin-top: 12px;"> ? html`<div class="callout warn" style="margin-bottom: 12px;">
Switch the Config tab to <strong>Form</strong> mode to edit bindings here. Switch the Config tab to <strong>Form</strong> mode to edit bindings here.
</div>` </div>`
: nothing} : nothing}
@ -459,42 +470,33 @@ function renderBindings(state: BindingState) {
${!state.ready ${!state.ready
? html`<div class="row" style="margin-top: 12px; gap: 12px;"> ? html`<div class="row" style="margin-top: 12px; gap: 12px;">
<div class="muted">Load config to edit bindings.</div> <div class="muted">Load config to edit bindings.</div>
<button class="btn" ?disabled=${state.configLoading} @click=${state.onLoadConfig}> <ui-button ?disabled=${state.configLoading} @click=${state.onLoadConfig}>
${state.configLoading ? "Loading…" : "Load config"} ${state.configLoading ? "Loading…" : "Load config"}
</button> </ui-button>
</div>` </div>`
: html` : html`
<div class="list" style="margin-top: 16px;"> <div class="list">
<div class="list-item"> <div class="list-item">
<div class="list-main"> <div class="list-main">
<div class="list-title">Default binding</div> <div class="list-title">Default binding</div>
<div class="list-sub">Used when agents do not override a node binding.</div> <div class="list-sub">Used when agents do not override a node binding.</div>
</div> </div>
<div class="list-meta"> <div class="list-meta">
<label class="field"> <ui-select
<span>Node</span> label="Node"
<select
?disabled=${state.disabled || !supportsBinding} ?disabled=${state.disabled || !supportsBinding}
@change=${(event: Event) => { .value=${defaultValue}
const target = event.target as HTMLSelectElement; .options=${[
const value = target.value.trim(); { label: "Any node", value: "" },
...state.nodes.map(n => ({ label: n.label, value: n.id }))
]}
@change=${(event: CustomEvent) => {
const value = event.detail.value.trim();
state.onBindDefault(value ? value : null); state.onBindDefault(value ? value : null);
}} }}
> ></ui-select>
<option value="" ?selected=${defaultValue === ""}>Any node</option>
${state.nodes.map(
(node) =>
html`<option
value=${node.id}
?selected=${defaultValue === node.id}
>
${node.label}
</option>`,
)}
</select>
</label>
${!supportsBinding ${!supportsBinding
? html`<div class="muted">No nodes with system.run available.</div>` ? html`<div class="muted" style="margin-top: 4px; font-size: 11px;">No nodes with system.run available.</div>`
: nothing} : nothing}
</div> </div>
</div> </div>
@ -506,7 +508,7 @@ function renderBindings(state: BindingState) {
)} )}
</div> </div>
`} `}
</section> </ui-card>
`; `;
} }
@ -514,21 +516,21 @@ function renderExecApprovals(state: ExecApprovalsState) {
const ready = state.ready; const ready = state.ready;
const targetReady = state.target !== "node" || Boolean(state.targetNodeId); const targetReady = state.target !== "node" || Boolean(state.targetNodeId);
return html` return html`
<section class="card"> <ui-card>
<div class="row" style="justify-content: space-between; align-items: center;"> <div slot="header" style="display: contents;">
<div> <div>
<div class="card-title">Exec approvals</div> <div class="card-title">Exec approvals</div>
<div class="card-sub"> <div class="card-desc">
Allowlist and approval policy for <span class="mono">exec host=gateway/node</span>. Allowlist and approval policy for <span class="mono">exec host=gateway/node</span>.
</div> </div>
</div> </div>
<button <ui-button
class="btn" variant="primary"
?disabled=${state.disabled || !state.dirty || !targetReady} ?disabled=${state.disabled || !state.dirty || !targetReady}
@click=${state.onSave} @click=${state.onSave}
> >
${state.saving ? "Saving…" : "Save"} ${state.saving ? "Saving…" : "Save"}
</button> </ui-button>
</div> </div>
${renderExecApprovalsTarget(state)} ${renderExecApprovalsTarget(state)}
@ -536,9 +538,9 @@ function renderExecApprovals(state: ExecApprovalsState) {
${!ready ${!ready
? html`<div class="row" style="margin-top: 12px; gap: 12px;"> ? html`<div class="row" style="margin-top: 12px; gap: 12px;">
<div class="muted">Load exec approvals to edit allowlists.</div> <div class="muted">Load exec approvals to edit allowlists.</div>
<button class="btn" ?disabled=${state.loading || !targetReady} @click=${state.onLoad}> <ui-button ?disabled=${state.loading || !targetReady} @click=${state.onLoad}>
${state.loading ? "Loading…" : "Load approvals"} ${state.loading ? "Loading…" : "Load approvals"}
</button> </ui-button>
</div>` </div>`
: html` : html`
${renderExecApprovalsTabs(state)} ${renderExecApprovalsTabs(state)}
@ -547,7 +549,7 @@ function renderExecApprovals(state: ExecApprovalsState) {
? nothing ? nothing
: renderExecApprovalsAllowlist(state)} : renderExecApprovalsAllowlist(state)}
`} `}
</section> </ui-card>
`; `;
} }
@ -555,7 +557,7 @@ function renderExecApprovalsTarget(state: ExecApprovalsState) {
const hasNodes = state.targetNodes.length > 0; const hasNodes = state.targetNodes.length > 0;
const nodeValue = state.targetNodeId ?? ""; const nodeValue = state.targetNodeId ?? "";
return html` return html`
<div class="list" style="margin-top: 12px;"> <div class="list" style="margin-top: 12px; margin-bottom: 12px;">
<div class="list-item"> <div class="list-item">
<div class="list-main"> <div class="list-main">
<div class="list-title">Target</div> <div class="list-title">Target</div>
@ -564,13 +566,16 @@ function renderExecApprovalsTarget(state: ExecApprovalsState) {
</div> </div>
</div> </div>
<div class="list-meta"> <div class="list-meta">
<label class="field"> <ui-select
<span>Host</span> label="Host"
<select
?disabled=${state.disabled} ?disabled=${state.disabled}
@change=${(event: Event) => { .value=${state.target}
const target = event.target as HTMLSelectElement; .options=${[
const value = target.value; { label: "Gateway", value: "gateway" },
{ label: "Node", value: "node" }
]}
@change=${(event: CustomEvent) => {
const value = event.detail.value;
if (value === "node") { if (value === "node") {
const first = state.targetNodes[0]?.id ?? null; const first = state.targetNodes[0]?.id ?? null;
state.onSelectTarget("node", nodeValue || first); state.onSelectTarget("node", nodeValue || first);
@ -578,35 +583,22 @@ function renderExecApprovalsTarget(state: ExecApprovalsState) {
state.onSelectTarget("gateway", null); state.onSelectTarget("gateway", null);
} }
}} }}
> ></ui-select>
<option value="gateway" ?selected=${state.target === "gateway"}>Gateway</option>
<option value="node" ?selected=${state.target === "node"}>Node</option>
</select>
</label>
${state.target === "node" ${state.target === "node"
? html` ? html`
<label class="field"> <ui-select
<span>Node</span> label="Node"
<select
?disabled=${state.disabled || !hasNodes} ?disabled=${state.disabled || !hasNodes}
@change=${(event: Event) => { .value=${nodeValue}
const target = event.target as HTMLSelectElement; .options=${[
const value = target.value.trim(); { label: "Select node", value: "" },
...state.targetNodes.map(n => ({ label: n.label, value: n.id }))
]}
@change=${(event: CustomEvent) => {
const value = event.detail.value.trim();
state.onSelectTarget("node", value ? value : null); state.onSelectTarget("node", value ? value : null);
}} }}
> ></ui-select>
<option value="" ?selected=${nodeValue === ""}>Select node</option>
${state.targetNodes.map(
(node) =>
html`<option
value=${node.id}
?selected=${nodeValue === node.id}
>
${node.label}
</option>`,
)}
</select>
</label>
` `
: nothing} : nothing}
</div> </div>
@ -623,21 +615,23 @@ function renderExecApprovalsTabs(state: ExecApprovalsState) {
<div class="row" style="margin-top: 12px; gap: 8px; flex-wrap: wrap;"> <div class="row" style="margin-top: 12px; gap: 8px; flex-wrap: wrap;">
<span class="label">Scope</span> <span class="label">Scope</span>
<div class="row" style="gap: 8px; flex-wrap: wrap;"> <div class="row" style="gap: 8px; flex-wrap: wrap;">
<button <ui-button
class="btn btn--sm ${state.selectedScope === EXEC_APPROVALS_DEFAULT_SCOPE ? "active" : ""}" size="sm"
variant=${state.selectedScope === EXEC_APPROVALS_DEFAULT_SCOPE ? "primary" : "secondary"}
@click=${() => state.onSelectScope(EXEC_APPROVALS_DEFAULT_SCOPE)} @click=${() => state.onSelectScope(EXEC_APPROVALS_DEFAULT_SCOPE)}
> >
Defaults Defaults
</button> </ui-button>
${state.agents.map((agent) => { ${state.agents.map((agent) => {
const label = agent.name?.trim() ? `${agent.name} (${agent.id})` : agent.id; const label = agent.name?.trim() ? `${agent.name} (${agent.id})` : agent.id;
return html` return html`
<button <ui-button
class="btn btn--sm ${state.selectedScope === agent.id ? "active" : ""}" size="sm"
variant=${state.selectedScope === agent.id ? "primary" : "secondary"}
@click=${() => state.onSelectScope(agent.id)} @click=${() => state.onSelectScope(agent.id)}
> >
${label} ${label}
</button> </ui-button>
`; `;
})} })}
</div> </div>

View File

@ -4,6 +4,10 @@ import type { GatewayHelloOk } from "../gateway";
import { formatAgo, formatDurationMs } from "../format"; import { formatAgo, formatDurationMs } from "../format";
import { formatNextRun } from "../presenter"; import { formatNextRun } from "../presenter";
import type { UiSettings } from "../storage"; import type { UiSettings } from "../storage";
import "../components/button";
import "../components/input";
import "../components/card";
import "../components/badge";
export type OverviewProps = { export type OverviewProps = {
connected: boolean; connected: boolean;
@ -118,70 +122,64 @@ export function renderOverview(props: OverviewProps) {
return html` return html`
<section class="grid grid-cols-2"> <section class="grid grid-cols-2">
<div class="card"> <ui-card>
<div slot="header">
<div class="card-title">Gateway Access</div> <div class="card-title">Gateway Access</div>
<div class="card-sub">Where the dashboard connects and how it authenticates.</div> <div class="card-desc">Where the dashboard connects and how it authenticates.</div>
<div class="form-grid" style="margin-top: 16px;"> </div>
<label class="field"> <div class="form-grid">
<span>WebSocket URL</span> <ui-input
<input label="WebSocket URL"
.value=${props.settings.gatewayUrl} .value=${props.settings.gatewayUrl}
@input=${(e: Event) => {
const v = (e.target as HTMLInputElement).value;
props.onSettingsChange({ ...props.settings, gatewayUrl: v });
}}
placeholder="ws://100.x.y.z:18789" placeholder="ws://100.x.y.z:18789"
/> @input=${(e: CustomEvent) => {
</label> props.onSettingsChange({ ...props.settings, gatewayUrl: e.detail.value });
<label class="field">
<span>Gateway Token</span>
<input
.value=${props.settings.token}
@input=${(e: Event) => {
const v = (e.target as HTMLInputElement).value;
props.onSettingsChange({ ...props.settings, token: v });
}} }}
></ui-input>
<ui-input
label="Gateway Token"
.value=${props.settings.token}
placeholder="CLAWDBOT_GATEWAY_TOKEN" placeholder="CLAWDBOT_GATEWAY_TOKEN"
/> @input=${(e: CustomEvent) => {
</label> props.onSettingsChange({ ...props.settings, token: e.detail.value });
<label class="field"> }}
<span>Password (not stored)</span> ></ui-input>
<input <ui-input
label="Password (not stored)"
type="password" type="password"
.value=${props.password} .value=${props.password}
@input=${(e: Event) => {
const v = (e.target as HTMLInputElement).value;
props.onPasswordChange(v);
}}
placeholder="system or shared password" placeholder="system or shared password"
/> @input=${(e: CustomEvent) => {
</label> props.onPasswordChange(e.detail.value);
<label class="field">
<span>Default Session Key</span>
<input
.value=${props.settings.sessionKey}
@input=${(e: Event) => {
const v = (e.target as HTMLInputElement).value;
props.onSessionKeyChange(v);
}} }}
/> ></ui-input>
</label> <ui-input
label="Default Session Key"
.value=${props.settings.sessionKey}
@input=${(e: CustomEvent) => {
props.onSessionKeyChange(e.detail.value);
}}
></ui-input>
</div> </div>
<div class="row" style="margin-top: 14px;"> <div class="row" style="margin-top: 14px;">
<button class="btn" @click=${() => props.onConnect()}>Connect</button> <ui-button variant="primary" @click=${() => props.onConnect()}>Connect</ui-button>
<button class="btn" @click=${() => props.onRefresh()}>Refresh</button> <ui-button variant="secondary" @click=${() => props.onRefresh()}>Refresh</ui-button>
<span class="muted">Click Connect to apply connection changes.</span> <span class="muted">Click Connect to apply connection changes.</span>
</div> </div>
</div> </ui-card>
<div class="card"> <ui-card>
<div slot="header">
<div class="card-title">Snapshot</div> <div class="card-title">Snapshot</div>
<div class="card-sub">Latest gateway handshake information.</div> <div class="card-desc">Latest gateway handshake information.</div>
<div class="stat-grid" style="margin-top: 16px;"> </div>
<div class="stat-grid">
<div class="stat"> <div class="stat">
<div class="stat-label">Status</div> <div class="stat-label">Status</div>
<div class="stat-value ${props.connected ? "ok" : "warn"}"> <div class="stat-value">
<ui-badge variant=${props.connected ? "success" : "secondary"}>
${props.connected ? "Connected" : "Disconnected"} ${props.connected ? "Connected" : "Disconnected"}
</ui-badge>
</div> </div>
</div> </div>
<div class="stat"> <div class="stat">
@ -210,21 +208,21 @@ export function renderOverview(props: OverviewProps) {
: html`<div class="callout" style="margin-top: 14px;"> : html`<div class="callout" style="margin-top: 14px;">
Use Channels to link WhatsApp, Telegram, Discord, Signal, or iMessage. Use Channels to link WhatsApp, Telegram, Discord, Signal, or iMessage.
</div>`} </div>`}
</div> </ui-card>
</section> </section>
<section class="grid grid-cols-3" style="margin-top: 18px;"> <section class="grid grid-cols-3" style="margin-top: 18px;">
<div class="card stat-card"> <ui-card class="stat-card">
<div class="stat-label">Instances</div> <div class="stat-label">Instances</div>
<div class="stat-value">${props.presenceCount}</div> <div class="stat-value">${props.presenceCount}</div>
<div class="muted">Presence beacons in the last 5 minutes.</div> <div class="muted">Presence beacons in the last 5 minutes.</div>
</div> </ui-card>
<div class="card stat-card"> <ui-card class="stat-card">
<div class="stat-label">Sessions</div> <div class="stat-label">Sessions</div>
<div class="stat-value">${props.sessionsCount ?? "n/a"}</div> <div class="stat-value">${props.sessionsCount ?? "n/a"}</div>
<div class="muted">Recent session keys tracked by the gateway.</div> <div class="muted">Recent session keys tracked by the gateway.</div>
</div> </ui-card>
<div class="card stat-card"> <ui-card class="stat-card">
<div class="stat-label">Cron</div> <div class="stat-label">Cron</div>
<div class="stat-value"> <div class="stat-value">
${props.cronEnabled == null ${props.cronEnabled == null
@ -234,13 +232,15 @@ export function renderOverview(props: OverviewProps) {
: "Disabled"} : "Disabled"}
</div> </div>
<div class="muted">Next wake ${formatNextRun(props.cronNext)}</div> <div class="muted">Next wake ${formatNextRun(props.cronNext)}</div>
</div> </ui-card>
</section> </section>
<section class="card" style="margin-top: 18px;"> <ui-card style="margin-top: 18px;">
<div slot="header">
<div class="card-title">Notes</div> <div class="card-title">Notes</div>
<div class="card-sub">Quick reminders for remote control setups.</div> <div class="card-desc">Quick reminders for remote control setups.</div>
<div class="note-grid" style="margin-top: 14px;"> </div>
<div class="note-grid">
<div> <div>
<div class="note-title">Tailscale serve</div> <div class="note-title">Tailscale serve</div>
<div class="muted"> <div class="muted">
@ -256,6 +256,6 @@ export function renderOverview(props: OverviewProps) {
<div class="muted">Use isolated sessions for recurring runs.</div> <div class="muted">Use isolated sessions for recurring runs.</div>
</div> </div>
</div> </div>
</section> </ui-card>
`; `;
} }

View File

@ -4,6 +4,9 @@ import { formatAgo } from "../format";
import { formatSessionTokens } from "../presenter"; import { formatSessionTokens } from "../presenter";
import { pathForTab } from "../navigation"; import { pathForTab } from "../navigation";
import type { GatewaySessionRow, SessionsListResult } from "../types"; import type { GatewaySessionRow, SessionsListResult } from "../types";
import "../components/button";
import "../components/input";
import "../components/checkbox";
export type SessionsProps = { export type SessionsProps = {
loading: boolean; loading: boolean;
@ -79,66 +82,60 @@ export function renderSessions(props: SessionsProps) {
<div class="card-title">Sessions</div> <div class="card-title">Sessions</div>
<div class="card-sub">Active session keys and per-session overrides.</div> <div class="card-sub">Active session keys and per-session overrides.</div>
</div> </div>
<button class="btn" ?disabled=${props.loading} @click=${props.onRefresh}> <ui-button ?disabled=${props.loading} @click=${props.onRefresh}>
${props.loading ? "Loading…" : "Refresh"} ${props.loading ? "Loading…" : "Refresh"}
</button> </ui-button>
</div> </div>
<div class="filters" style="margin-top: 14px;"> <div style="margin-top: 16px; display: flex; gap: 16px; align-items: center; flex-wrap: wrap;">
<label class="field"> <ui-input
<span>Active within (minutes)</span> label="Active within (minutes)"
<input
.value=${props.activeMinutes} .value=${props.activeMinutes}
@input=${(e: Event) => @input=${(e: CustomEvent) =>
props.onFiltersChange({ props.onFiltersChange({
activeMinutes: (e.target as HTMLInputElement).value, activeMinutes: e.detail.value,
limit: props.limit, limit: props.limit,
includeGlobal: props.includeGlobal, includeGlobal: props.includeGlobal,
includeUnknown: props.includeUnknown, includeUnknown: props.includeUnknown,
})} })}
/> style="width: 200px;"
</label> ></ui-input>
<label class="field"> <ui-input
<span>Limit</span> label="Limit"
<input
.value=${props.limit} .value=${props.limit}
@input=${(e: Event) => @input=${(e: CustomEvent) =>
props.onFiltersChange({ props.onFiltersChange({
activeMinutes: props.activeMinutes, activeMinutes: props.activeMinutes,
limit: (e.target as HTMLInputElement).value, limit: e.detail.value,
includeGlobal: props.includeGlobal, includeGlobal: props.includeGlobal,
includeUnknown: props.includeUnknown, includeUnknown: props.includeUnknown,
})} })}
/> style="width: 120px;"
</label> ></ui-input>
<label class="field checkbox"> <div style="display: flex; gap: 16px; align-items: center; margin-top: 19px;">
<span>Include global</span> <ui-checkbox
<input label="Include global"
type="checkbox"
.checked=${props.includeGlobal} .checked=${props.includeGlobal}
@change=${(e: Event) => @change=${(e: CustomEvent) =>
props.onFiltersChange({ props.onFiltersChange({
activeMinutes: props.activeMinutes, activeMinutes: props.activeMinutes,
limit: props.limit, limit: props.limit,
includeGlobal: (e.target as HTMLInputElement).checked, includeGlobal: e.detail.checked,
includeUnknown: props.includeUnknown, includeUnknown: props.includeUnknown,
})} })}
/> ></ui-checkbox>
</label> <ui-checkbox
<label class="field checkbox"> label="Include unknown"
<span>Include unknown</span>
<input
type="checkbox"
.checked=${props.includeUnknown} .checked=${props.includeUnknown}
@change=${(e: Event) => @change=${(e: CustomEvent) =>
props.onFiltersChange({ props.onFiltersChange({
activeMinutes: props.activeMinutes, activeMinutes: props.activeMinutes,
limit: props.limit, limit: props.limit,
includeGlobal: props.includeGlobal, includeGlobal: props.includeGlobal,
includeUnknown: (e.target as HTMLInputElement).checked, includeUnknown: e.detail.checked,
})} })}
/> ></ui-checkbox>
</label> </div>
</div> </div>
${props.error ${props.error
@ -255,9 +252,9 @@ function renderRow(
</select> </select>
</div> </div>
<div> <div>
<button class="btn danger" ?disabled=${disabled} @click=${() => onDelete(row.key)}> <ui-button variant="danger" ?disabled=${disabled} @click=${() => onDelete(row.key)}>
Delete Delete
</button> </ui-button>
</div> </div>
</div> </div>
`; `;