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
.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" />
<title>Moltbot Control</title>
<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" />
<!-- 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>
<body>
<moltbot-app></moltbot-app>

View File

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

View File

@ -1,192 +1,350 @@
@import url("https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap");
/**
* Clawdbot UI - Base Styles
* Design System: Tangerine Theme
* Font: Poppins
* Modes: Light & Dark
* Target: WCAG AAA compliance
*/
:root {
/* Background - Warmer dark with depth */
--bg: #12141a;
--bg-accent: #14161d;
--bg-elevated: #1a1d25;
--bg-hover: #262a35;
--bg-muted: #262a35;
/* Card / Surface - More contrast between levels */
--card: #181b22;
:root,
:root[data-color-theme="tangerine"] {
/* ============================================
COLOR SYSTEM - DARK MODE (Default)
============================================ */
/* Background Colors - Muted Dark Theme */
--color-bg-primary: #1A1816;
--color-bg-secondary: #242220;
--color-bg-tertiary: #2E2C28;
--color-bg-elevated: #3A3834;
/* Legacy aliases for compatibility */
--bg: var(--color-bg-primary);
--bg-accent: var(--color-bg-secondary);
--bg-elevated: var(--color-bg-elevated);
--bg-hover: var(--color-bg-tertiary);
--bg-muted: var(--color-bg-tertiary);
--bg-content: var(--color-bg-secondary);
/* Card / Surface */
--card: var(--color-bg-secondary);
--card-foreground: #f4f4f5;
--card-highlight: rgba(255, 255, 255, 0.05);
--popover: #181b22;
--card-highlight: rgba(255, 159, 64, 0.03);
--popover: var(--color-bg-secondary);
--popover-foreground: #f4f4f5;
/* Panel */
--panel: #12141a;
--panel-strong: #1a1d25;
--panel-hover: #262a35;
--chrome: rgba(18, 20, 26, 0.95);
--chrome-strong: rgba(18, 20, 26, 0.98);
/* Text - Slightly warmer */
--text: #e4e4e7;
--text-strong: #fafafa;
--chat-text: #e4e4e7;
--muted: #71717a;
--muted-strong: #52525b;
--muted-foreground: #71717a;
/* Border - Subtle but defined */
--border: #27272a;
--border-strong: #3f3f46;
--border-hover: #52525b;
--input: #27272a;
--ring: #ff5c5c;
/* Accent - Punchy signature red */
--accent: #ff5c5c;
--accent-hover: #ff7070;
--accent-muted: #ff5c5c;
--accent-subtle: rgba(255, 92, 92, 0.15);
--accent-foreground: #fafafa;
--accent-glow: rgba(255, 92, 92, 0.25);
--primary: #ff5c5c;
--panel: var(--color-bg-primary);
--panel-strong: var(--color-bg-elevated);
--panel-hover: var(--color-bg-tertiary);
--chrome: rgba(26, 24, 22, 0.95);
--chrome-strong: rgba(26, 24, 22, 0.98);
/* Text Colors - High Contrast */
--color-text-primary: #FFFFFF;
--color-text-secondary: #E0E0E0;
--color-text-tertiary: #B0B0B0;
--color-text-disabled: #666666;
--color-text-inverse: #1A1A1A;
/* Legacy aliases */
--text: var(--color-text-secondary);
--text-strong: var(--color-text-primary);
--chat-text: var(--color-text-secondary);
--muted: var(--color-text-tertiary);
--muted-strong: #71717a;
--muted-foreground: var(--color-text-tertiary);
/* Accent Colors - Tangerine Theme */
--color-accent-primary: #FF9F40;
--color-accent-primary-hover: #FF8C2E;
--color-accent-primary-active: #FF7A1C;
--color-accent-secondary: #FFB366;
/* Legacy aliases */
--accent: var(--color-accent-primary);
--accent-hover: var(--color-accent-primary-hover);
--accent-muted: var(--color-accent-primary);
--accent-subtle: rgba(255, 159, 64, 0.15);
--accent-foreground: var(--color-text-inverse);
--accent-glow: rgba(255, 159, 64, 0.25);
--primary: var(--color-accent-primary);
--primary-foreground: #ffffff;
/* Secondary - Teal accent for variety */
--secondary: #1e2028;
/* Secondary Accent - Teal */
--secondary: var(--color-bg-secondary);
--secondary-foreground: #f4f4f5;
--accent-2: #14b8a6;
--accent-2-muted: rgba(20, 184, 166, 0.7);
--accent-2-subtle: rgba(20, 184, 166, 0.15);
/* Semantic - More saturated */
--ok: #22c55e;
--ok-muted: rgba(34, 197, 94, 0.75);
--ok-subtle: rgba(34, 197, 94, 0.12);
--destructive: #ef4444;
/* Semantic Colors - Dark Mode */
--color-accent-success: #4CAF50;
--color-accent-success-hover: #45A049;
--color-accent-error: #FF5252;
--color-accent-error-hover: #FF3838;
--color-accent-warning: #FFB300;
--color-accent-warning-hover: #FFA000;
--color-accent-info: #29B6F6;
--color-accent-info-hover: #039BE5;
/* Legacy aliases */
--ok: var(--color-accent-success);
--ok-muted: rgba(76, 175, 80, 0.75);
--ok-subtle: rgba(76, 175, 80, 0.12);
--destructive: var(--color-accent-error);
--destructive-foreground: #fafafa;
--warn: #f59e0b;
--warn-muted: rgba(245, 158, 11, 0.75);
--warn-subtle: rgba(245, 158, 11, 0.12);
--danger: #ef4444;
--danger-muted: rgba(239, 68, 68, 0.75);
--danger-subtle: rgba(239, 68, 68, 0.12);
--info: #3b82f6;
/* Focus - With glow */
--focus: rgba(255, 92, 92, 0.25);
--warn: var(--color-accent-warning);
--warn-muted: rgba(255, 179, 0, 0.75);
--warn-subtle: rgba(255, 179, 0, 0.12);
--danger: var(--color-accent-error);
--danger-muted: rgba(255, 82, 82, 0.75);
--danger-subtle: rgba(255, 82, 82, 0.12);
--info: var(--color-accent-info);
/* Border Colors - Dark Mode */
--color-border-subtle: rgba(255, 159, 64, 0.1);
--color-border-default: rgba(255, 159, 64, 0.2);
--color-border-strong: rgba(255, 159, 64, 0.3);
/* Legacy aliases */
--border: #27272a;
--border-strong: #3f3f46;
--border-hover: #52525b;
--input: #27272a;
--ring: var(--color-accent-primary);
/* Focus Colors */
--color-focus-ring: var(--color-accent-primary);
--color-focus-ring-error: var(--color-accent-error);
--focus: rgba(255, 159, 64, 0.25);
--focus-ring: 0 0 0 2px var(--bg), 0 0 0 4px var(--ring);
--focus-glow: 0 0 0 2px var(--bg), 0 0 0 4px var(--ring), 0 0 20px var(--accent-glow);
/* Glassmorphism - Dark Mode */
--color-glass-bg: rgba(255, 159, 64, 0.05);
--color-glass-border: rgba(255, 159, 64, 0.15);
--glass-blur: 20px;
/* Chat Bubbles - Dark Mode */
--color-chat-user: var(--color-accent-primary);
--color-chat-assistant: var(--color-bg-tertiary);
--color-chat-system: var(--color-bg-elevated);
/* Grid */
--grid-line: rgba(255, 255, 255, 0.04);
/* Theme transition */
--theme-switch-x: 50%;
--theme-switch-y: 50%;
/* Typography - Space Grotesk for personality */
--mono: "JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, monospace;
--font-body: "Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-display: "Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
/* Shadows - Richer with subtle color */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.25), 0 0 0 1px rgba(255, 255, 255, 0.03);
--shadow-lg: 0 12px 28px rgba(0, 0, 0, 0.35), 0 0 0 1px rgba(255, 255, 255, 0.03);
--shadow-xl: 0 24px 48px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.03);
--shadow-glow: 0 0 30px var(--accent-glow);
/* Radii - Slightly larger for friendlier feel */
/* ============================================
TYPOGRAPHY
============================================ */
/* Font Families */
--font-family-base: 'Poppins', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-family-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, monospace;
/* Legacy aliases */
--mono: var(--font-family-mono);
--font-body: var(--font-family-base);
--font-display: var(--font-family-base);
/* Font Sizes */
--font-size-xs: 0.75rem;
--font-size-sm: 0.875rem;
--font-size-base: 1rem;
--font-size-md: 1.125rem;
--font-size-lg: 1.25rem;
--font-size-xl: 1.5rem;
--font-size-2xl: 1.875rem;
--font-size-3xl: 2.25rem;
/* Font Weights */
--font-weight-light: 300;
--font-weight-normal: 400;
--font-weight-medium: 500;
--font-weight-semibold: 600;
--font-weight-bold: 700;
/* Spacing */
--spacing-0: 0;
--spacing-1: 0.25rem;
--spacing-2: 0.5rem;
--spacing-3: 0.75rem;
--spacing-4: 1rem;
--spacing-5: 1.25rem;
--spacing-6: 1.5rem;
--spacing-8: 2rem;
--spacing-10: 2.5rem;
--spacing-12: 3rem;
/* Touch Targets */
--size-touch-min: 44px;
/* Border Radius */
--radius-sm: 6px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
--radius-full: 9999px;
--radius: 8px;
/* Transitions - Snappy but smooth */
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
/* Shadows */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.25), 0 0 0 1px rgba(255, 255, 255, 0.03);
--shadow-lg: 0 12px 28px rgba(0, 0, 0, 0.35), 0 0 0 1px rgba(255, 255, 255, 0.03);
--shadow-xl: 0 24px 48px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.03);
--shadow-glow: 0 0 30px var(--accent-glow);
--shadow-focus: 0 0 0 3px var(--color-focus-ring);
/* Transitions */
--duration-fast: 120ms;
--duration-normal: 200ms;
--duration-slow: 350ms;
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
color-scheme: dark;
}
/* Light theme - Clean with subtle warmth */
:root[data-theme="light"] {
--bg: #fafafa;
--bg-accent: #f5f5f5;
--bg-elevated: #ffffff;
--bg-hover: #f0f0f0;
--bg-muted: #f0f0f0;
--bg-content: #f5f5f5;
/* ============================================
LIGHT MODE THEME
============================================ */
--card: #ffffff;
:root[data-theme="light"],
:root[data-color-theme="tangerine"][data-theme="light"] {
/* Background Colors - Light Mode (Muted) */
--color-bg-primary: #FAFAF9;
--color-bg-secondary: #F5F4F2;
--color-bg-tertiary: #EFEEE9;
--color-bg-elevated: #FFFFFF;
/* Legacy aliases */
--bg: var(--color-bg-primary);
--bg-accent: var(--color-bg-secondary);
--bg-elevated: var(--color-bg-elevated);
--bg-hover: var(--color-bg-tertiary);
--bg-muted: var(--color-bg-tertiary);
--bg-content: var(--color-bg-secondary);
--card: var(--color-bg-elevated);
--card-foreground: #18181b;
--card-highlight: rgba(0, 0, 0, 0.03);
--popover: #ffffff;
--card-highlight: rgba(255, 140, 66, 0.03);
--popover: var(--color-bg-elevated);
--popover-foreground: #18181b;
--panel: #fafafa;
--panel-strong: #f5f5f5;
--panel-hover: #ebebeb;
--chrome: rgba(250, 250, 250, 0.95);
--chrome-strong: rgba(250, 250, 250, 0.98);
--panel: var(--color-bg-primary);
--panel-strong: var(--color-bg-secondary);
--panel-hover: var(--color-bg-tertiary);
--chrome: rgba(250, 250, 249, 0.95);
--chrome-strong: rgba(250, 250, 249, 0.98);
/* Text Colors - Light Mode */
--color-text-primary: #1A1410;
--color-text-secondary: #4A3F35;
--color-text-tertiary: #6A5A4A;
--color-text-disabled: #9A8A7A;
--color-text-inverse: #FFFFFF;
/* Legacy aliases */
--text: #3f3f46;
--text-strong: #18181b;
--text-strong: var(--color-text-primary);
--chat-text: #3f3f46;
--muted: #71717a;
--muted-strong: #52525b;
--muted-foreground: #71717a;
--border: #e4e4e7;
--border-strong: #d4d4d8;
--border-hover: #a1a1aa;
--input: #e4e4e7;
--accent: #dc2626;
--accent-hover: #ef4444;
--accent-muted: #dc2626;
--accent-subtle: rgba(220, 38, 38, 0.12);
/* Accent Colors - Light Mode */
--color-accent-primary: #FF8C42;
--color-accent-primary-hover: #FF7A2E;
--color-accent-primary-active: #E5681A;
--color-accent-secondary: #FFA366;
/* Legacy aliases */
--accent: var(--color-accent-primary);
--accent-hover: var(--color-accent-primary-hover);
--accent-muted: var(--color-accent-primary);
--accent-subtle: rgba(255, 140, 66, 0.12);
--accent-foreground: #ffffff;
--accent-glow: rgba(220, 38, 38, 0.15);
--primary: #dc2626;
--accent-glow: rgba(255, 140, 66, 0.15);
--primary: var(--color-accent-primary);
--primary-foreground: #ffffff;
--secondary: #f4f4f5;
--secondary: var(--color-bg-secondary);
--secondary-foreground: #3f3f46;
--accent-2: #0d9488;
--accent-2-muted: rgba(13, 148, 136, 0.75);
--accent-2-subtle: rgba(13, 148, 136, 0.12);
--ok: #16a34a;
--ok-muted: rgba(22, 163, 74, 0.75);
--ok-subtle: rgba(22, 163, 74, 0.1);
--destructive: #dc2626;
/* Semantic Colors - Light Mode */
--color-accent-success: #2E7D32;
--color-accent-success-hover: #1B5E20;
--color-accent-error: #D32F2F;
--color-accent-error-hover: #C62828;
--color-accent-warning: #F57C00;
--color-accent-warning-hover: #E65100;
--color-accent-info: #0288D1;
--color-accent-info-hover: #01579B;
/* Legacy aliases */
--ok: var(--color-accent-success);
--ok-muted: rgba(46, 125, 50, 0.75);
--ok-subtle: rgba(46, 125, 50, 0.1);
--destructive: var(--color-accent-error);
--destructive-foreground: #fafafa;
--warn: #d97706;
--warn-muted: rgba(217, 119, 6, 0.75);
--warn-subtle: rgba(217, 119, 6, 0.1);
--danger: #dc2626;
--danger-muted: rgba(220, 38, 38, 0.75);
--danger-subtle: rgba(220, 38, 38, 0.1);
--info: #2563eb;
--focus: rgba(220, 38, 38, 0.2);
--warn: var(--color-accent-warning);
--warn-muted: rgba(245, 124, 0, 0.75);
--warn-subtle: rgba(245, 124, 0, 0.1);
--danger: var(--color-accent-error);
--danger-muted: rgba(211, 47, 47, 0.75);
--danger-subtle: rgba(211, 47, 47, 0.1);
--info: var(--color-accent-info);
/* Border Colors - Light Mode */
--color-border-subtle: rgba(255, 140, 66, 0.15);
--color-border-default: rgba(255, 140, 66, 0.25);
--color-border-strong: rgba(255, 140, 66, 0.4);
/* Legacy aliases */
--border: #e4e4e7;
--border-strong: #d4d4d8;
--border-hover: #a1a1aa;
--input: #e4e4e7;
/* Focus Colors - Light Mode */
--color-focus-ring: var(--color-accent-primary);
--color-focus-ring-error: var(--color-accent-error);
--focus: rgba(255, 140, 66, 0.2);
--focus-glow: 0 0 0 2px var(--bg), 0 0 0 4px var(--ring), 0 0 16px var(--accent-glow);
/* Glassmorphism - Light Mode */
--color-glass-bg: rgba(255, 255, 255, 0.7);
--color-glass-border: rgba(255, 140, 66, 0.2);
/* Chat Bubbles - Light Mode */
--color-chat-user: var(--color-accent-primary);
--color-chat-assistant: var(--color-bg-secondary);
--color-chat-system: var(--color-bg-tertiary);
--grid-line: rgba(0, 0, 0, 0.05);
/* Light shadows */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.06);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08), 0 0 0 1px rgba(0, 0, 0, 0.04);
--shadow-lg: 0 12px 28px rgba(0, 0, 0, 0.12), 0 0 0 1px rgba(0, 0, 0, 0.04);
--shadow-xl: 0 24px 48px rgba(0, 0, 0, 0.15), 0 0 0 1px rgba(0, 0, 0, 0.04);
--shadow-glow: 0 0 24px var(--accent-glow);
color-scheme: light;
}
/* ============================================
BASE STYLES
============================================ */
* {
box-sizing: border-box;
}
@ -287,7 +445,7 @@ select {
background: var(--border-strong);
}
/* Animations - Polished with spring feel */
/* Animations */
@keyframes rise {
from {
opacity: 0;
@ -350,14 +508,14 @@ select {
@keyframes glow-pulse {
0%, 100% {
box-shadow: 0 0 0 rgba(255, 92, 92, 0);
box-shadow: 0 0 0 rgba(255, 159, 64, 0);
}
50% {
box-shadow: 0 0 20px var(--accent-glow);
}
}
/* Stagger animation delays for grouped elements */
/* Stagger animation delays */
.stagger-1 { animation-delay: 0ms; }
.stagger-2 { animation-delay: 50ms; }
.stagger-3 { animation-delay: 100ms; }
@ -370,3 +528,26 @@ select {
outline: none;
box-shadow: var(--focus-ring);
}
/* Accessibility */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}

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;
}
/* Channel card layout - content grows, buttons at bottom */
.card--channel {
display: flex;
flex-direction: column;
min-height: 400px;
}
.card--channel .card-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 16px;
}
.card--channel .card-footer {
margin-top: auto;
padding-top: 16px;
border-top: 1px solid var(--border);
}
.card--channel .card-errors {
margin-top: auto;
padding-top: 12px;
}
.card--channel .card-actions {
margin-top: 12px;
}
/* Config section spacing within channel cards */
.card--channel .channel-config-section {
margin-top: auto;
padding-top: 20px;
}
/* ===========================================
Stats - Bold values, subtle labels
=========================================== */
@ -774,6 +809,120 @@
text-decoration: underline;
}
/* ===========================================
Log Level Filters - Custom styled checkboxes
=========================================== */
.log-level-filter {
display: inline-flex;
align-items: center;
gap: 8px;
cursor: pointer;
user-select: none;
padding: 6px 12px;
border-radius: var(--radius-md);
border: 1px solid var(--border);
background: var(--secondary);
transition: all 0.2s ease;
}
.log-level-filter:hover {
border-color: var(--border-strong);
background: var(--bg-hover);
}
.log-level-filter__checkbox {
width: 18px;
height: 18px;
border: 1px solid var(--input-border, #3E3B38);
border-radius: var(--radius-sm, 4px);
background: var(--input-bg, #1A1816);
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
position: relative;
}
.log-level-filter__checkbox::after {
content: "✓";
font-size: 14px;
font-weight: 700;
opacity: 0;
transform: scale(0.5);
transition: all 0.2s ease;
}
.log-level-filter input:checked ~ .log-level-filter__checkbox::after {
opacity: 1;
transform: scale(1);
}
.log-level-filter__label {
font-size: 13px;
font-weight: 500;
text-transform: capitalize;
}
/* Log level specific colors */
.log-level-filter--trace .log-level-filter__checkbox,
.log-level-filter--debug .log-level-filter__checkbox {
border-color: #71717A;
}
.log-level-filter--trace input:checked ~ .log-level-filter__checkbox,
.log-level-filter--debug input:checked ~ .log-level-filter__checkbox {
background: #71717A;
border-color: #71717A;
}
.log-level-filter--trace input:checked ~ .log-level-filter__checkbox::after,
.log-level-filter--debug input:checked ~ .log-level-filter__checkbox::after {
color: white;
}
.log-level-filter--info .log-level-filter__checkbox {
border-color: #3B82F6;
}
.log-level-filter--info input:checked ~ .log-level-filter__checkbox {
background: #3B82F6;
border-color: #3B82F6;
}
.log-level-filter--info input:checked ~ .log-level-filter__checkbox::after {
color: white;
}
.log-level-filter--warn .log-level-filter__checkbox {
border-color: #F59E0B;
}
.log-level-filter--warn input:checked ~ .log-level-filter__checkbox {
background: #F59E0B;
border-color: #F59E0B;
}
.log-level-filter--warn input:checked ~ .log-level-filter__checkbox::after {
color: white;
}
.log-level-filter--error .log-level-filter__checkbox,
.log-level-filter--fatal .log-level-filter__checkbox {
border-color: #EF4444;
}
.log-level-filter--error input:checked ~ .log-level-filter__checkbox,
.log-level-filter--fatal input:checked ~ .log-level-filter__checkbox {
background: #EF4444;
border-color: #EF4444;
}
.log-level-filter--error input:checked ~ .log-level-filter__checkbox::after,
.log-level-filter--fatal input:checked ~ .log-level-filter__checkbox::after {
color: white;
}
/* ===========================================
Log Stream
=========================================== */
@ -1035,8 +1184,8 @@
}
:root[data-theme="light"] .chat-line.user .chat-bubble {
border-color: rgba(234, 88, 12, 0.2);
background: rgba(251, 146, 60, 0.12);
border-color: rgba(255, 140, 66, 0.25);
background: rgba(255, 140, 66, 0.12);
}
.chat-line.assistant .chat-bubble {
@ -1291,6 +1440,95 @@
background: var(--bg);
}
/* ===========================================
Light Mode - Custom Components
=========================================== */
/* Log level filters */
:root[data-theme="light"] .log-level-filter {
background: white;
border-color: var(--border);
}
:root[data-theme="light"] .log-level-filter:hover {
background: var(--bg-hover);
}
:root[data-theme="light"] .log-level-filter__checkbox {
background: white;
border-color: var(--input-border);
}
/* Dropdown menu */
:root[data-theme="light"] .dropdown-menu {
background: white;
border-color: var(--border);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
:root[data-theme="light"] .dropdown-menu__item:hover {
background: var(--bg-hover);
}
/* UI Components */
:root[data-theme="light"] ui-input input,
:root[data-theme="light"] ui-textarea textarea {
background: white;
border-color: var(--input-border);
}
:root[data-theme="light"] ui-input input:focus,
:root[data-theme="light"] ui-textarea textarea:focus {
background: white;
border-color: var(--accent);
}
:root[data-theme="light"] ui-checkbox .checkbox {
background: white;
border-color: var(--input-border);
}
:root[data-theme="light"] ui-button.variant-secondary {
background: white;
border-color: var(--border);
}
:root[data-theme="light"] ui-button.variant-secondary:hover {
background: var(--bg-hover);
}
:root[data-theme="light"] ui-button.variant-outline {
background: transparent;
border-color: var(--border);
}
:root[data-theme="light"] ui-button.variant-outline:hover {
background: var(--bg-hover);
}
:root[data-theme="light"] ui-button.variant-ghost:hover {
background: var(--bg-hover);
}
/* Card components */
:root[data-theme="light"] ui-card .card {
background: white;
border-color: var(--border);
}
:root[data-theme="light"] ui-card .card:hover {
border-color: var(--border-strong);
}
:root[data-theme="light"] ui-card .card-header {
border-color: var(--border);
}
:root[data-theme="light"] ui-card .card-footer {
background: var(--bg-hover);
border-color: var(--border);
}
.chat-stamp {
font-size: 11px;
color: var(--muted);

View File

@ -613,36 +613,38 @@
.cfg-fields {
display: grid;
gap: 22px;
gap: 16px;
}
.cfg-field {
display: grid;
gap: 8px;
gap: 6px;
}
.cfg-field--error {
padding: 14px;
padding: 12px 14px;
border-radius: var(--radius-md);
background: var(--danger-subtle);
border: 1px solid rgba(239, 68, 68, 0.3);
background: rgba(239, 68, 68, 0.08);
border: 1px solid rgba(239, 68, 68, 0.25);
}
.cfg-field__label {
font-size: 13px;
font-weight: 600;
font-weight: 500;
color: var(--text);
letter-spacing: -0.01em;
}
.cfg-field__help {
font-size: 12px;
color: var(--muted);
line-height: 1.45;
line-height: 1.5;
}
.cfg-field__error {
font-size: 12px;
color: var(--danger);
font-weight: 500;
}
/* Text Input */
@ -651,66 +653,7 @@
gap: 10px;
}
.cfg-input {
flex: 1;
padding: 11px 14px;
border: 1px solid var(--border-strong);
border-radius: var(--radius-md);
background: var(--bg-accent);
font-size: 14px;
outline: none;
transition:
border-color var(--duration-fast) ease,
box-shadow var(--duration-fast) ease,
background var(--duration-fast) ease;
}
.cfg-input::placeholder {
color: var(--muted);
opacity: 0.7;
}
.cfg-input:focus {
border-color: var(--accent);
box-shadow: var(--focus-ring);
background: var(--bg-hover);
}
:root[data-theme="light"] .cfg-input {
background: white;
}
:root[data-theme="light"] .cfg-input:focus {
background: white;
}
.cfg-input--sm {
padding: 9px 12px;
font-size: 13px;
}
.cfg-input__reset {
padding: 10px 14px;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--bg-elevated);
color: var(--muted);
font-size: 14px;
cursor: pointer;
transition:
background var(--duration-fast) ease,
color var(--duration-fast) ease;
}
.cfg-input__reset:hover:not(:disabled) {
background: var(--bg-hover);
color: var(--text);
}
.cfg-input__reset:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Textarea */
/* Textarea */
.cfg-textarea {
@ -743,135 +686,11 @@
font-size: 12px;
}
/* Number Input */
.cfg-number {
display: inline-flex;
border: 1px solid var(--border-strong);
border-radius: var(--radius-md);
overflow: hidden;
background: var(--bg-accent);
}
:root[data-theme="light"] .cfg-number {
background: white;
}
.cfg-number__btn {
width: 44px;
border: none;
background: var(--bg-elevated);
color: var(--text);
font-size: 18px;
font-weight: 300;
cursor: pointer;
transition: background var(--duration-fast) ease;
}
.cfg-number__btn:hover:not(:disabled) {
background: var(--bg-hover);
}
.cfg-number__btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
:root[data-theme="light"] .cfg-number__btn {
background: var(--bg-hover);
}
:root[data-theme="light"] .cfg-number__btn:hover:not(:disabled) {
background: var(--border);
}
.cfg-number__input {
width: 85px;
padding: 11px;
border: none;
border-left: 1px solid var(--border);
border-right: 1px solid var(--border);
background: transparent;
font-size: 14px;
text-align: center;
outline: none;
-moz-appearance: textfield;
}
.cfg-number__input::-webkit-outer-spin-button,
.cfg-number__input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* Select */
.cfg-select {
padding: 11px 40px 11px 14px;
border: 1px solid var(--border-strong);
border-radius: var(--radius-md);
background-color: var(--bg-accent);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%23888' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
font-size: 14px;
cursor: pointer;
outline: none;
appearance: none;
transition:
border-color var(--duration-fast) ease,
box-shadow var(--duration-fast) ease;
}
.cfg-select:focus {
border-color: var(--accent);
box-shadow: var(--focus-ring);
}
:root[data-theme="light"] .cfg-select {
background-color: white;
}
/* Segmented Control */
.cfg-segmented {
display: inline-flex;
padding: 4px;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--bg-accent);
}
:root[data-theme="light"] .cfg-segmented {
background: var(--bg-hover);
}
.cfg-segmented__btn {
padding: 9px 18px;
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--muted);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition:
background var(--duration-fast) ease,
color var(--duration-fast) ease,
box-shadow var(--duration-fast) ease;
}
.cfg-segmented__btn:hover:not(:disabled):not(.active) {
color: var(--text);
}
.cfg-segmented__btn.active {
background: var(--accent);
color: white;
box-shadow: var(--shadow-sm);
}
.cfg-segmented__btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Toggle Row */
.cfg-toggle-row {
@ -879,28 +698,31 @@
align-items: center;
justify-content: space-between;
gap: 18px;
padding: 16px 18px;
padding: 14px 16px;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: var(--bg-accent);
border-radius: var(--radius-md);
background: var(--card);
cursor: pointer;
transition:
background var(--duration-fast) ease,
border-color var(--duration-fast) ease;
border-color var(--duration-fast) ease,
box-shadow var(--duration-fast) ease;
box-shadow: inset 0 1px 0 var(--card-highlight);
}
.cfg-toggle-row:hover:not(.disabled) {
background: var(--bg-hover);
border-color: var(--border-strong);
box-shadow: var(--shadow-sm), inset 0 1px 0 var(--card-highlight);
}
.cfg-toggle-row.disabled {
opacity: 0.55;
opacity: 0.5;
cursor: not-allowed;
}
:root[data-theme="light"] .cfg-toggle-row {
background: white;
background: var(--card);
}
:root[data-theme="light"] .cfg-toggle-row:hover:not(.disabled) {
@ -914,77 +736,21 @@
.cfg-toggle-row__label {
display: block;
font-size: 14px;
font-size: 13px;
font-weight: 500;
color: var(--text);
letter-spacing: -0.01em;
}
.cfg-toggle-row__help {
display: block;
margin-top: 3px;
margin-top: 4px;
font-size: 12px;
color: var(--muted);
line-height: 1.45;
line-height: 1.5;
}
/* Toggle Switch */
.cfg-toggle {
position: relative;
flex-shrink: 0;
}
.cfg-toggle input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.cfg-toggle__track {
display: block;
width: 50px;
height: 28px;
background: var(--bg-elevated);
border: 1px solid var(--border-strong);
border-radius: var(--radius-full);
position: relative;
transition:
background var(--duration-normal) ease,
border-color var(--duration-normal) ease;
}
:root[data-theme="light"] .cfg-toggle__track {
background: var(--border);
}
.cfg-toggle__track::after {
content: "";
position: absolute;
top: 3px;
left: 3px;
width: 20px;
height: 20px;
background: var(--text);
border-radius: var(--radius-full);
box-shadow: var(--shadow-sm);
transition:
transform var(--duration-normal) var(--ease-out),
background var(--duration-normal) ease;
}
.cfg-toggle input:checked + .cfg-toggle__track {
background: var(--ok-subtle);
border-color: rgba(34, 197, 94, 0.4);
}
.cfg-toggle input:checked + .cfg-toggle__track::after {
transform: translateX(22px);
background: var(--ok);
}
.cfg-toggle input:focus + .cfg-toggle__track {
box-shadow: var(--focus-ring);
}
/* Object (collapsible) */
.cfg-object {
@ -1090,39 +856,7 @@
background: white;
}
.cfg-array__add {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 14px;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--bg-elevated);
color: var(--text);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: background var(--duration-fast) ease;
}
.cfg-array__add:hover:not(:disabled) {
background: var(--bg-hover);
}
.cfg-array__add:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.cfg-array__add-icon {
width: 14px;
height: 14px;
}
.cfg-array__add-icon svg {
width: 100%;
height: 100%;
}
.cfg-array__help {
padding: 12px 18px;
@ -1169,36 +903,7 @@
letter-spacing: 0.05em;
}
.cfg-array__item-remove {
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
border: none;
border-radius: var(--radius-md);
background: transparent;
color: var(--muted);
cursor: pointer;
transition:
background var(--duration-fast) ease,
color var(--duration-fast) ease;
}
.cfg-array__item-remove svg {
width: 16px;
height: 16px;
}
.cfg-array__item-remove:hover:not(:disabled) {
background: var(--danger-subtle);
color: var(--danger);
}
.cfg-array__item-remove:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.cfg-array__item-content {
padding: 18px;
@ -1231,35 +936,6 @@
color: var(--muted);
}
.cfg-map__add {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 14px;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--bg-elevated);
color: var(--text);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: background var(--duration-fast) ease;
}
.cfg-map__add:hover:not(:disabled) {
background: var(--bg-hover);
}
.cfg-map__add-icon {
width: 14px;
height: 14px;
}
.cfg-map__add-icon svg {
width: 100%;
height: 100%;
}
.cfg-map__empty {
padding: 28px 18px;
text-align: center;
@ -1288,32 +964,6 @@
min-width: 0;
}
.cfg-map__item-remove {
width: 34px;
height: 34px;
display: flex;
align-items: center;
justify-content: center;
border: none;
border-radius: var(--radius-md);
background: transparent;
color: var(--muted);
cursor: pointer;
transition:
background var(--duration-fast) ease,
color var(--duration-fast) ease;
}
.cfg-map__item-remove svg {
width: 16px;
height: 16px;
}
.cfg-map__item-remove:hover:not(:disabled) {
background: var(--danger-subtle);
color: var(--danger);
}
/* Pill variants */
.pill--sm {
padding: 5px 12px;
@ -1337,6 +987,8 @@
@media (max-width: 768px) {
.config-layout {
grid-template-columns: 1fr;
height: auto;
margin: -8px;
}
.config-sidebar {
@ -1345,22 +997,53 @@
}
.config-sidebar__header {
padding: 14px 16px;
padding: 12px 14px;
}
.config-sidebar__title {
font-size: 13px;
}
/* Search - better mobile sizing */
.config-search {
padding: 10px 12px;
}
.config-search__input {
padding: 10px 36px 10px 40px;
font-size: 16px; /* Prevents iOS zoom */
}
.config-search__icon {
left: 24px;
}
.config-search__clear {
right: 18px;
width: 24px;
height: 24px;
}
/* Horizontal scrolling nav */
.config-nav {
display: flex;
flex-wrap: nowrap;
gap: 6px;
padding: 10px 14px;
padding: 10px 12px;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.config-nav::-webkit-scrollbar {
display: none;
}
.config-nav__item {
flex: 0 0 auto;
padding: 9px 14px;
padding: 10px 14px;
white-space: nowrap;
min-height: 44px; /* Touch target */
}
.config-nav__label {
@ -1368,12 +1051,25 @@
}
.config-sidebar__footer {
display: none;
padding: 10px 12px;
}
.config-mode-toggle {
padding: 3px;
}
.config-mode-toggle__btn {
padding: 10px 16px;
font-size: 13px;
min-height: 40px; /* Touch target */
}
/* Actions bar - stack on mobile */
.config-actions {
flex-wrap: wrap;
padding: 14px 16px;
flex-direction: column;
align-items: stretch;
gap: 10px;
padding: 12px 14px;
}
.config-actions__left,
@ -1382,30 +1078,122 @@
justify-content: center;
}
.config-actions__right {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.config-changes-badge {
padding: 8px 14px;
font-size: 13px;
text-align: center;
}
/* Section hero - compact */
.config-section-hero {
padding: 14px 16px;
padding: 12px 14px;
}
.config-section-hero__icon {
width: 26px;
height: 26px;
}
.config-section-hero__title {
font-size: 15px;
}
.config-section-hero__desc {
font-size: 12px;
}
/* Subnav - horizontal scroll */
.config-subnav {
padding: 10px 16px 12px;
padding: 10px 14px 12px;
scrollbar-width: none;
}
.config-subnav::-webkit-scrollbar {
display: none;
}
.config-subnav__item {
padding: 9px 14px;
font-size: 13px;
min-height: 38px; /* Touch target */
}
/* Content area */
.config-content {
padding: 18px;
padding: 14px;
}
/* Section cards */
.config-section-card {
border-radius: var(--radius-md);
}
.config-section-card__header {
padding: 16px 18px;
}
.config-section-card__content {
padding: 18px;
}
.cfg-toggle-row {
padding: 14px 16px;
}
.config-section-card__icon {
width: 28px;
height: 28px;
}
.config-section-card__title {
font-size: 15px;
}
.config-section-card__desc {
font-size: 12px;
}
.config-section-card__content {
padding: 16px;
}
/* Form fields - better mobile spacing */
.cfg-fields {
gap: 14px;
}
.cfg-field__label {
font-size: 14px;
}
.cfg-field__help {
font-size: 13px;
}
/* Toggle rows - larger touch targets */
.cfg-toggle-row {
padding: 14px;
min-height: 60px;
}
.cfg-toggle-row__label {
font-size: 14px;
}
.cfg-toggle-row__help {
font-size: 13px;
}
/* Textarea - better mobile sizing */
.cfg-textarea {
padding: 12px;
font-size: 14px;
}
.config-raw-field textarea {
min-height: 400px;
font-size: 12px;
}
/* Map items - stack on mobile */
.cfg-map__item {
grid-template-columns: 1fr;
gap: 10px;
@ -1414,34 +1202,142 @@
.cfg-map__item-remove {
justify-self: end;
}
/* Object/Array - better mobile spacing */
.cfg-object__header,
.cfg-array__header {
padding: 12px 14px;
}
.cfg-object__content,
.cfg-array__item-content {
padding: 14px;
}
/* Diff panel */
.config-diff {
margin: 12px 14px 0;
}
.config-diff__summary {
padding: 12px 14px;
font-size: 12px;
}
.config-diff__content {
padding: 0 14px 14px;
}
.config-diff__item {
padding: 10px 12px;
font-size: 11px;
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.config-diff__path {
font-size: 11px;
}
.config-diff__values {
gap: 8px;
}
}
@media (max-width: 480px) {
.config-layout {
margin: -4px;
}
.config-sidebar__header {
padding: 10px 12px;
}
.config-search {
padding: 8px 10px;
}
.config-nav {
padding: 8px 10px;
}
/* Icon-only nav on small screens */
.config-nav__icon {
width: 26px;
height: 26px;
font-size: 17px;
width: 24px;
height: 24px;
}
.config-nav__label {
display: none;
}
.config-nav__item {
padding: 10px;
min-width: 44px;
justify-content: center;
}
.config-actions {
padding: 10px 12px;
}
.config-actions__right {
grid-template-columns: 1fr 1fr;
gap: 6px;
}
.config-section-hero {
padding: 10px 12px;
}
.config-section-hero__icon {
width: 24px;
height: 24px;
}
.config-section-hero__title {
font-size: 14px;
}
.config-section-hero__desc {
font-size: 11px;
}
.config-subnav {
padding: 8px 12px 10px;
}
.config-content {
padding: 12px;
}
.config-section-card__header {
padding: 12px 14px;
}
.config-section-card__icon {
width: 30px;
height: 30px;
width: 26px;
height: 26px;
}
.config-section-card__title {
font-size: 16px;
font-size: 14px;
}
.cfg-segmented {
flex-wrap: wrap;
.config-section-card__content {
padding: 14px;
}
.cfg-segmented__btn {
flex: 1 0 auto;
min-width: 70px;
.cfg-toggle-row {
padding: 12px;
}
}
.config-diff {
margin: 10px 12px 0;
}
.config-raw-field textarea {
min-height: 350px;
}
}

View File

@ -164,6 +164,33 @@
box-sizing: border-box;
}
/* Health badge - matches theme toggle size */
.health-badge {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
border-radius: var(--radius-full);
border: 1px solid var(--border);
background: var(--secondary);
height: 32px;
font-size: 12px;
font-weight: 500;
}
.health-badge__status {
font-family: var(--mono);
font-size: 11px;
}
.topbar-status ui-badge {
font-size: 12px;
}
.topbar-status ui-badge .statusDot {
margin-right: 2px;
}
.topbar-status .pill .mono {
display: flex;
align-items: center;
@ -197,6 +224,7 @@
overflow-x: hidden;
padding: 16px 12px;
background: var(--bg);
border-right: 1px solid var(--border);
scrollbar-width: none; /* Firefox */
transition:
width var(--shell-focus-duration) var(--shell-focus-ease),
@ -206,7 +234,8 @@
}
.nav::-webkit-scrollbar {
display: none; /* Chrome/Safari */
display: none;
/* Chrome/Safari */
}
.shell--chat-focus .nav {
@ -277,12 +306,16 @@
/* Nav groups */
.nav-group {
margin-bottom: 20px;
padding-bottom: 20px;
border-bottom: 1px solid var(--border);
display: grid;
gap: 2px;
}
.nav-group:last-child {
margin-bottom: 0;
padding-bottom: 0;
border-bottom: none;
}
.nav-group__items {
@ -417,7 +450,7 @@
.content {
grid-area: content;
padding: 12px 16px 32px;
padding: 32px 16px 32px;
display: flex;
flex-direction: column;
gap: 24px;
@ -442,14 +475,15 @@
justify-content: space-between;
gap: 16px;
padding: 4px 8px;
overflow: hidden;
margin-bottom: 24px;
overflow: visible;
transform-origin: top center;
transition:
opacity var(--shell-focus-duration) var(--shell-focus-ease),
transform var(--shell-focus-duration) var(--shell-focus-ease),
max-height var(--shell-focus-duration) var(--shell-focus-ease),
padding var(--shell-focus-duration) var(--shell-focus-ease);
max-height: 80px;
min-height: 80px;
}
.shell--chat-focus .content-header {
@ -489,7 +523,7 @@
gap: 16px;
}
.content--chat .content-header > div:first-child {
.content--chat .content-header>div:first-child {
text-align: left;
}
@ -606,4 +640,4 @@
.list-item {
grid-template-columns: 1fr;
}
}
}

View File

@ -61,6 +61,11 @@
align-items: center;
}
/* Hide nav toggle button on mobile (sidebar is always visible/stacked) */
.nav-collapse-toggle {
display: none;
}
.brand {
flex: 1;
min-width: 0;
@ -80,6 +85,43 @@
flex-wrap: nowrap;
}
/* Health badge - compact for mobile */
.health-badge {
padding: 3px 8px;
height: 28px;
font-size: 11px;
gap: 4px;
}
.health-badge span:nth-child(2) {
display: none; /* Hide "Health" text on mobile */
}
.health-badge__status {
font-size: 10px;
}
.health-badge .statusDot {
width: 6px;
height: 6px;
}
/* Theme controls - compact */
.theme-toggle {
--theme-item: 24px;
--theme-gap: 2px;
--theme-pad: 3px;
}
.theme-icon {
width: 12px;
height: 12px;
}
theme-selector {
display: inline-block;
}
.topbar-status .pill {
padding: 4px 8px;
font-size: 11px;
@ -214,9 +256,13 @@
width: 100%;
}
/* Chat thread - ensure proper scrolling on mobile */
.chat-thread {
margin-top: 8px;
padding: 12px 8px;
padding: 12px 8px 80px 8px; /* Extra bottom padding for compose area */
flex: 1;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.chat-msg {
@ -228,15 +274,47 @@
border-radius: var(--radius-md);
}
/* Chat compose - fixed at bottom on mobile */
.chat-compose {
position: fixed !important;
bottom: 0;
left: 0;
right: 0;
margin: 0;
padding: 12px 8px;
padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px));
background: var(--bg);
border-top: 1px solid var(--border);
gap: 8px;
z-index: 50;
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.1);
}
:root[data-theme="light"] .chat-compose {
background: var(--bg-content);
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.05);
}
.shell--chat .chat-compose {
position: fixed !important;
}
.chat-compose__field textarea {
min-height: 60px;
min-height: 44px;
max-height: 120px;
padding: 8px 10px;
border-radius: var(--radius-md);
font-size: 14px;
font-size: 16px; /* Prevents zoom on iOS */
resize: none;
}
.chat-compose__actions {
margin-top: 0;
}
/* Ensure content area accounts for fixed compose */
.shell--chat .content {
padding-bottom: 0;
}
/* Log stream */
@ -315,6 +393,35 @@
font-size: 13px;
}
/* Health badge - ultra compact */
.health-badge {
padding: 2px 6px;
height: 26px;
font-size: 10px;
gap: 3px;
}
.health-badge__status {
font-size: 9px;
}
.health-badge .statusDot {
width: 5px;
height: 5px;
}
/* Theme controls - ultra compact */
.theme-toggle {
--theme-item: 22px;
--theme-gap: 2px;
--theme-pad: 2px;
}
.theme-icon {
width: 11px;
height: 11px;
}
.nav {
padding: 6px 8px;
}
@ -345,10 +452,16 @@
padding: 8px 10px;
}
.chat-compose {
padding: 10px 6px;
padding-bottom: calc(10px + env(safe-area-inset-bottom, 0px));
}
.chat-compose__field textarea {
min-height: 52px;
min-height: 40px;
max-height: 100px;
padding: 8px 10px;
font-size: 13px;
font-size: 16px; /* Prevents zoom on iOS */
}
.btn {
@ -372,3 +485,10 @@
height: 11px;
}
}
/* Ensure chat content has proper spacing for fixed compose */
@media (max-width: 600px) {
.shell--chat .content {
padding-bottom: 80px; /* Space for fixed compose area */
}
}

View File

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

View File

@ -79,6 +79,10 @@ import {
saveExecApprovals,
updateExecApprovalsFormValue,
} from "./controllers/exec-approvals";
import "./components/badge";
import "./components/button";
import "./components/checkbox";
import "./components/theme-selector";
import { loadCronRuns, toggleCronJob, runCronJob, removeCronJob, addCronJob } from "./controllers/cron";
import { loadDebug, callDebugMethod } from "./controllers/debug";
import { loadLogs } from "./controllers/logs";
@ -119,10 +123,10 @@ export function renderApp(state: AppViewState) {
<button
class="nav-collapse-toggle"
@click=${() =>
state.applySettings({
...state.settings,
navCollapsed: !state.settings.navCollapsed,
})}
state.applySettings({
...state.settings,
navCollapsed: !state.settings.navCollapsed,
})}
title="${state.settings.navCollapsed ? "Expand sidebar" : "Collapse sidebar"}"
aria-label="${state.settings.navCollapsed ? "Expand sidebar" : "Collapse sidebar"}"
>
@ -139,30 +143,41 @@ export function renderApp(state: AppViewState) {
</div>
</div>
<div class="topbar-status">
<div class="pill">
<div class="health-badge">
<span class="statusDot ${state.connected ? "ok" : ""}"></span>
<span>Health</span>
<span class="mono">${state.connected ? "OK" : "Offline"}</span>
<span class="health-badge__status">${state.connected ? "OK" : "Offline"}</span>
</div>
<div style="display: flex; align-items: center; gap: 4px;">
${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>
${renderThemeToggle(state)}
</div>
</header>
<aside class="nav ${state.settings.navCollapsed ? "nav--collapsed" : ""}">
${TAB_GROUPS.map((group) => {
const isGroupCollapsed = state.settings.navGroupsCollapsed[group.label] ?? false;
const hasActiveTab = group.tabs.some((tab) => tab === state.tab);
return html`
const isGroupCollapsed = state.settings.navGroupsCollapsed[group.label] ?? false;
const hasActiveTab = group.tabs.some((tab) => tab === state.tab);
return html`
<div class="nav-group ${isGroupCollapsed && !hasActiveTab ? "nav-group--collapsed" : ""}">
<button
class="nav-label"
@click=${() => {
const next = { ...state.settings.navGroupsCollapsed };
next[group.label] = !isGroupCollapsed;
state.applySettings({
...state.settings,
navGroupsCollapsed: next,
});
}}
const next = { ...state.settings.navGroupsCollapsed };
next[group.label] = !isGroupCollapsed;
state.applySettings({
...state.settings,
navGroupsCollapsed: next,
});
}}
aria-expanded=${!isGroupCollapsed}
>
<span class="nav-label__text">${group.label}</span>
@ -173,7 +188,7 @@ export function renderApp(state: AppViewState) {
</div>
</div>
`;
})}
})}
<div class="nav-group nav-group--links">
<div class="nav-label nav-label--static">
<span class="nav-label__text">Resources</span>
@ -200,383 +215,383 @@ export function renderApp(state: AppViewState) {
</div>
<div class="page-meta">
${state.lastError
? html`<div class="pill danger">${state.lastError}</div>`
: nothing}
? html`<ui-badge variant="destructive">${state.lastError}</ui-badge>`
: nothing}
${isChat ? renderChatControls(state) : nothing}
</div>
</section>
${state.tab === "overview"
? renderOverview({
connected: state.connected,
hello: state.hello,
settings: state.settings,
password: state.password,
lastError: state.lastError,
presenceCount,
sessionsCount,
cronEnabled: state.cronStatus?.enabled ?? null,
cronNext,
lastChannelsRefresh: state.channelsLastSuccess,
onSettingsChange: (next) => state.applySettings(next),
onPasswordChange: (next) => (state.password = next),
onSessionKeyChange: (next) => {
state.sessionKey = next;
state.chatMessage = "";
state.resetToolStream();
state.applySettings({
...state.settings,
sessionKey: next,
lastActiveSessionKey: next,
});
void state.loadAssistantIdentity();
},
onConnect: () => state.connect(),
onRefresh: () => state.loadOverview(),
})
: nothing}
? renderOverview({
connected: state.connected,
hello: state.hello,
settings: state.settings,
password: state.password,
lastError: state.lastError,
presenceCount,
sessionsCount,
cronEnabled: state.cronStatus?.enabled ?? null,
cronNext,
lastChannelsRefresh: state.channelsLastSuccess,
onSettingsChange: (next) => state.applySettings(next),
onPasswordChange: (next) => (state.password = next),
onSessionKeyChange: (next) => {
state.sessionKey = next;
state.chatMessage = "";
state.resetToolStream();
state.applySettings({
...state.settings,
sessionKey: next,
lastActiveSessionKey: next,
});
void state.loadAssistantIdentity();
},
onConnect: () => state.connect(),
onRefresh: () => state.loadOverview(),
})
: nothing}
${state.tab === "channels"
? renderChannels({
connected: state.connected,
loading: state.channelsLoading,
snapshot: state.channelsSnapshot,
lastError: state.channelsError,
lastSuccessAt: state.channelsLastSuccess,
whatsappMessage: state.whatsappLoginMessage,
whatsappQrDataUrl: state.whatsappLoginQrDataUrl,
whatsappConnected: state.whatsappLoginConnected,
whatsappBusy: state.whatsappBusy,
configSchema: state.configSchema,
configSchemaLoading: state.configSchemaLoading,
configForm: state.configForm,
configUiHints: state.configUiHints,
configSaving: state.configSaving,
configFormDirty: state.configFormDirty,
nostrProfileFormState: state.nostrProfileFormState,
nostrProfileAccountId: state.nostrProfileAccountId,
onRefresh: (probe) => loadChannels(state, probe),
onWhatsAppStart: (force) => state.handleWhatsAppStart(force),
onWhatsAppWait: () => state.handleWhatsAppWait(),
onWhatsAppLogout: () => state.handleWhatsAppLogout(),
onConfigPatch: (path, value) => updateConfigFormValue(state, path, value),
onConfigSave: () => state.handleChannelConfigSave(),
onConfigReload: () => state.handleChannelConfigReload(),
onNostrProfileEdit: (accountId, profile) =>
state.handleNostrProfileEdit(accountId, profile),
onNostrProfileCancel: () => state.handleNostrProfileCancel(),
onNostrProfileFieldChange: (field, value) =>
state.handleNostrProfileFieldChange(field, value),
onNostrProfileSave: () => state.handleNostrProfileSave(),
onNostrProfileImport: () => state.handleNostrProfileImport(),
onNostrProfileToggleAdvanced: () => state.handleNostrProfileToggleAdvanced(),
})
: nothing}
? renderChannels({
connected: state.connected,
loading: state.channelsLoading,
snapshot: state.channelsSnapshot,
lastError: state.channelsError,
lastSuccessAt: state.channelsLastSuccess,
whatsappMessage: state.whatsappLoginMessage,
whatsappQrDataUrl: state.whatsappLoginQrDataUrl,
whatsappConnected: state.whatsappLoginConnected,
whatsappBusy: state.whatsappBusy,
configSchema: state.configSchema,
configSchemaLoading: state.configSchemaLoading,
configForm: state.configForm,
configUiHints: state.configUiHints,
configSaving: state.configSaving,
configFormDirty: state.configFormDirty,
nostrProfileFormState: state.nostrProfileFormState,
nostrProfileAccountId: state.nostrProfileAccountId,
onRefresh: (probe) => loadChannels(state, probe),
onWhatsAppStart: (force) => state.handleWhatsAppStart(force),
onWhatsAppWait: () => state.handleWhatsAppWait(),
onWhatsAppLogout: () => state.handleWhatsAppLogout(),
onConfigPatch: (path, value) => updateConfigFormValue(state, path, value),
onConfigSave: () => state.handleChannelConfigSave(),
onConfigReload: () => state.handleChannelConfigReload(),
onNostrProfileEdit: (accountId, profile) =>
state.handleNostrProfileEdit(accountId, profile),
onNostrProfileCancel: () => state.handleNostrProfileCancel(),
onNostrProfileFieldChange: (field, value) =>
state.handleNostrProfileFieldChange(field, value),
onNostrProfileSave: () => state.handleNostrProfileSave(),
onNostrProfileImport: () => state.handleNostrProfileImport(),
onNostrProfileToggleAdvanced: () => state.handleNostrProfileToggleAdvanced(),
})
: nothing}
${state.tab === "instances"
? renderInstances({
loading: state.presenceLoading,
entries: state.presenceEntries,
lastError: state.presenceError,
statusMessage: state.presenceStatus,
onRefresh: () => loadPresence(state),
})
: nothing}
? renderInstances({
loading: state.presenceLoading,
entries: state.presenceEntries,
lastError: state.presenceError,
statusMessage: state.presenceStatus,
onRefresh: () => loadPresence(state),
})
: nothing}
${state.tab === "sessions"
? renderSessions({
loading: state.sessionsLoading,
result: state.sessionsResult,
error: state.sessionsError,
activeMinutes: state.sessionsFilterActive,
limit: state.sessionsFilterLimit,
includeGlobal: state.sessionsIncludeGlobal,
includeUnknown: state.sessionsIncludeUnknown,
basePath: state.basePath,
onFiltersChange: (next) => {
state.sessionsFilterActive = next.activeMinutes;
state.sessionsFilterLimit = next.limit;
state.sessionsIncludeGlobal = next.includeGlobal;
state.sessionsIncludeUnknown = next.includeUnknown;
},
onRefresh: () => loadSessions(state),
onPatch: (key, patch) => patchSession(state, key, patch),
onDelete: (key) => deleteSession(state, key),
})
: nothing}
? renderSessions({
loading: state.sessionsLoading,
result: state.sessionsResult,
error: state.sessionsError,
activeMinutes: state.sessionsFilterActive,
limit: state.sessionsFilterLimit,
includeGlobal: state.sessionsIncludeGlobal,
includeUnknown: state.sessionsIncludeUnknown,
basePath: state.basePath,
onFiltersChange: (next) => {
state.sessionsFilterActive = next.activeMinutes;
state.sessionsFilterLimit = next.limit;
state.sessionsIncludeGlobal = next.includeGlobal;
state.sessionsIncludeUnknown = next.includeUnknown;
},
onRefresh: () => loadSessions(state),
onPatch: (key, patch) => patchSession(state, key, patch),
onDelete: (key) => deleteSession(state, key),
})
: nothing}
${state.tab === "cron"
? renderCron({
loading: state.cronLoading,
status: state.cronStatus,
jobs: state.cronJobs,
error: state.cronError,
busy: state.cronBusy,
form: state.cronForm,
channels: state.channelsSnapshot?.channelMeta?.length
? state.channelsSnapshot.channelMeta.map((entry) => entry.id)
: state.channelsSnapshot?.channelOrder ?? [],
channelLabels: state.channelsSnapshot?.channelLabels ?? {},
channelMeta: state.channelsSnapshot?.channelMeta ?? [],
runsJobId: state.cronRunsJobId,
runs: state.cronRuns,
onFormChange: (patch) => (state.cronForm = { ...state.cronForm, ...patch }),
onRefresh: () => state.loadCron(),
onAdd: () => addCronJob(state),
onToggle: (job, enabled) => toggleCronJob(state, job, enabled),
onRun: (job) => runCronJob(state, job),
onRemove: (job) => removeCronJob(state, job),
onLoadRuns: (jobId) => loadCronRuns(state, jobId),
})
: nothing}
? renderCron({
loading: state.cronLoading,
status: state.cronStatus,
jobs: state.cronJobs,
error: state.cronError,
busy: state.cronBusy,
form: state.cronForm,
channels: state.channelsSnapshot?.channelMeta?.length
? state.channelsSnapshot.channelMeta.map((entry) => entry.id)
: state.channelsSnapshot?.channelOrder ?? [],
channelLabels: state.channelsSnapshot?.channelLabels ?? {},
channelMeta: state.channelsSnapshot?.channelMeta ?? [],
runsJobId: state.cronRunsJobId,
runs: state.cronRuns,
onFormChange: (patch) => (state.cronForm = { ...state.cronForm, ...patch }),
onRefresh: () => state.loadCron(),
onAdd: () => addCronJob(state),
onToggle: (job, enabled) => toggleCronJob(state, job, enabled),
onRun: (job) => runCronJob(state, job),
onRemove: (job) => removeCronJob(state, job),
onLoadRuns: (jobId) => loadCronRuns(state, jobId),
})
: nothing}
${state.tab === "skills"
? renderSkills({
loading: state.skillsLoading,
report: state.skillsReport,
error: state.skillsError,
filter: state.skillsFilter,
edits: state.skillEdits,
messages: state.skillMessages,
busyKey: state.skillsBusyKey,
onFilterChange: (next) => (state.skillsFilter = next),
onRefresh: () => loadSkills(state, { clearMessages: true }),
onToggle: (key, enabled) => updateSkillEnabled(state, key, enabled),
onEdit: (key, value) => updateSkillEdit(state, key, value),
onSaveKey: (key) => saveSkillApiKey(state, key),
onInstall: (skillKey, name, installId) =>
installSkill(state, skillKey, name, installId),
})
: nothing}
? renderSkills({
loading: state.skillsLoading,
report: state.skillsReport,
error: state.skillsError,
filter: state.skillsFilter,
edits: state.skillEdits,
messages: state.skillMessages,
busyKey: state.skillsBusyKey,
onFilterChange: (next) => (state.skillsFilter = next),
onRefresh: () => loadSkills(state, { clearMessages: true }),
onToggle: (key, enabled) => updateSkillEnabled(state, key, enabled),
onEdit: (key, value) => updateSkillEdit(state, key, value),
onSaveKey: (key) => saveSkillApiKey(state, key),
onInstall: (skillKey, name, installId) =>
installSkill(state, skillKey, name, installId),
})
: nothing}
${state.tab === "nodes"
? renderNodes({
loading: state.nodesLoading,
nodes: state.nodes,
devicesLoading: state.devicesLoading,
devicesError: state.devicesError,
devicesList: state.devicesList,
configForm: state.configForm ?? (state.configSnapshot?.config as Record<string, unknown> | null),
configLoading: state.configLoading,
configSaving: state.configSaving,
configDirty: state.configFormDirty,
configFormMode: state.configFormMode,
execApprovalsLoading: state.execApprovalsLoading,
execApprovalsSaving: state.execApprovalsSaving,
execApprovalsDirty: state.execApprovalsDirty,
execApprovalsSnapshot: state.execApprovalsSnapshot,
execApprovalsForm: state.execApprovalsForm,
execApprovalsSelectedAgent: state.execApprovalsSelectedAgent,
execApprovalsTarget: state.execApprovalsTarget,
execApprovalsTargetNodeId: state.execApprovalsTargetNodeId,
onRefresh: () => loadNodes(state),
onDevicesRefresh: () => loadDevices(state),
onDeviceApprove: (requestId) => approveDevicePairing(state, requestId),
onDeviceReject: (requestId) => rejectDevicePairing(state, requestId),
onDeviceRotate: (deviceId, role, scopes) =>
rotateDeviceToken(state, { deviceId, role, scopes }),
onDeviceRevoke: (deviceId, role) =>
revokeDeviceToken(state, { deviceId, role }),
onLoadConfig: () => loadConfig(state),
onLoadExecApprovals: () => {
const target =
state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId
? { kind: "node" as const, nodeId: state.execApprovalsTargetNodeId }
: { kind: "gateway" as const };
return loadExecApprovals(state, target);
},
onBindDefault: (nodeId) => {
if (nodeId) {
updateConfigFormValue(state, ["tools", "exec", "node"], nodeId);
} else {
removeConfigFormValue(state, ["tools", "exec", "node"]);
}
},
onBindAgent: (agentIndex, nodeId) => {
const basePath = ["agents", "list", agentIndex, "tools", "exec", "node"];
if (nodeId) {
updateConfigFormValue(state, basePath, nodeId);
} else {
removeConfigFormValue(state, basePath);
}
},
onSaveBindings: () => saveConfig(state),
onExecApprovalsTargetChange: (kind, nodeId) => {
state.execApprovalsTarget = kind;
state.execApprovalsTargetNodeId = nodeId;
state.execApprovalsSnapshot = null;
state.execApprovalsForm = null;
state.execApprovalsDirty = false;
state.execApprovalsSelectedAgent = null;
},
onExecApprovalsSelectAgent: (agentId) => {
state.execApprovalsSelectedAgent = agentId;
},
onExecApprovalsPatch: (path, value) =>
updateExecApprovalsFormValue(state, path, value),
onExecApprovalsRemove: (path) =>
removeExecApprovalsFormValue(state, path),
onSaveExecApprovals: () => {
const target =
state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId
? { kind: "node" as const, nodeId: state.execApprovalsTargetNodeId }
: { kind: "gateway" as const };
return saveExecApprovals(state, target);
},
})
: nothing}
? renderNodes({
loading: state.nodesLoading,
nodes: state.nodes,
devicesLoading: state.devicesLoading,
devicesError: state.devicesError,
devicesList: state.devicesList,
configForm: state.configForm ?? (state.configSnapshot?.config as Record<string, unknown> | null),
configLoading: state.configLoading,
configSaving: state.configSaving,
configDirty: state.configFormDirty,
configFormMode: state.configFormMode,
execApprovalsLoading: state.execApprovalsLoading,
execApprovalsSaving: state.execApprovalsSaving,
execApprovalsDirty: state.execApprovalsDirty,
execApprovalsSnapshot: state.execApprovalsSnapshot,
execApprovalsForm: state.execApprovalsForm,
execApprovalsSelectedAgent: state.execApprovalsSelectedAgent,
execApprovalsTarget: state.execApprovalsTarget,
execApprovalsTargetNodeId: state.execApprovalsTargetNodeId,
onRefresh: () => loadNodes(state),
onDevicesRefresh: () => loadDevices(state),
onDeviceApprove: (requestId) => approveDevicePairing(state, requestId),
onDeviceReject: (requestId) => rejectDevicePairing(state, requestId),
onDeviceRotate: (deviceId, role, scopes) =>
rotateDeviceToken(state, { deviceId, role, scopes }),
onDeviceRevoke: (deviceId, role) =>
revokeDeviceToken(state, { deviceId, role }),
onLoadConfig: () => loadConfig(state),
onLoadExecApprovals: () => {
const target =
state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId
? { kind: "node" as const, nodeId: state.execApprovalsTargetNodeId }
: { kind: "gateway" as const };
return loadExecApprovals(state, target);
},
onBindDefault: (nodeId) => {
if (nodeId) {
updateConfigFormValue(state, ["tools", "exec", "node"], nodeId);
} else {
removeConfigFormValue(state, ["tools", "exec", "node"]);
}
},
onBindAgent: (agentIndex, nodeId) => {
const basePath = ["agents", "list", agentIndex, "tools", "exec", "node"];
if (nodeId) {
updateConfigFormValue(state, basePath, nodeId);
} else {
removeConfigFormValue(state, basePath);
}
},
onSaveBindings: () => saveConfig(state),
onExecApprovalsTargetChange: (kind, nodeId) => {
state.execApprovalsTarget = kind;
state.execApprovalsTargetNodeId = nodeId;
state.execApprovalsSnapshot = null;
state.execApprovalsForm = null;
state.execApprovalsDirty = false;
state.execApprovalsSelectedAgent = null;
},
onExecApprovalsSelectAgent: (agentId) => {
state.execApprovalsSelectedAgent = agentId;
},
onExecApprovalsPatch: (path, value) =>
updateExecApprovalsFormValue(state, path, value),
onExecApprovalsRemove: (path) =>
removeExecApprovalsFormValue(state, path),
onSaveExecApprovals: () => {
const target =
state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId
? { kind: "node" as const, nodeId: state.execApprovalsTargetNodeId }
: { kind: "gateway" as const };
return saveExecApprovals(state, target);
},
})
: nothing}
${state.tab === "chat"
? renderChat({
sessionKey: state.sessionKey,
onSessionKeyChange: (next) => {
state.sessionKey = next;
state.chatMessage = "";
state.chatAttachments = [];
state.chatStream = null;
state.chatStreamStartedAt = null;
state.chatRunId = null;
state.chatQueue = [];
state.resetToolStream();
state.resetChatScroll();
state.applySettings({
...state.settings,
sessionKey: next,
lastActiveSessionKey: next,
});
void state.loadAssistantIdentity();
void loadChatHistory(state);
void refreshChatAvatar(state);
},
thinkingLevel: state.chatThinkingLevel,
showThinking,
loading: state.chatLoading,
sending: state.chatSending,
compactionStatus: state.compactionStatus,
assistantAvatarUrl: chatAvatarUrl,
messages: state.chatMessages,
toolMessages: state.chatToolMessages,
stream: state.chatStream,
streamStartedAt: state.chatStreamStartedAt,
draft: state.chatMessage,
queue: state.chatQueue,
connected: state.connected,
canSend: state.connected,
disabledReason: chatDisabledReason,
error: state.lastError,
sessions: state.sessionsResult,
focusMode: chatFocus,
onRefresh: () => {
state.resetToolStream();
return Promise.all([loadChatHistory(state), refreshChatAvatar(state)]);
},
onToggleFocusMode: () => {
if (state.onboarding) return;
state.applySettings({
...state.settings,
chatFocusMode: !state.settings.chatFocusMode,
});
},
onChatScroll: (event) => state.handleChatScroll(event),
onDraftChange: (next) => (state.chatMessage = next),
attachments: state.chatAttachments,
onAttachmentsChange: (next) => (state.chatAttachments = next),
onSend: () => state.handleSendChat(),
canAbort: Boolean(state.chatRunId),
onAbort: () => void state.handleAbortChat(),
onQueueRemove: (id) => state.removeQueuedMessage(id),
onNewSession: () =>
state.handleSendChat("/new", { restoreDraft: true }),
// Sidebar props for tool output viewing
sidebarOpen: state.sidebarOpen,
sidebarContent: state.sidebarContent,
sidebarError: state.sidebarError,
splitRatio: state.splitRatio,
onOpenSidebar: (content: string) => state.handleOpenSidebar(content),
onCloseSidebar: () => state.handleCloseSidebar(),
onSplitRatioChange: (ratio: number) => state.handleSplitRatioChange(ratio),
assistantName: state.assistantName,
assistantAvatar: state.assistantAvatar,
})
: nothing}
? renderChat({
sessionKey: state.sessionKey,
onSessionKeyChange: (next) => {
state.sessionKey = next;
state.chatMessage = "";
state.chatAttachments = [];
state.chatStream = null;
state.chatStreamStartedAt = null;
state.chatRunId = null;
state.chatQueue = [];
state.resetToolStream();
state.resetChatScroll();
state.applySettings({
...state.settings,
sessionKey: next,
lastActiveSessionKey: next,
});
void state.loadAssistantIdentity();
void loadChatHistory(state);
void refreshChatAvatar(state);
},
thinkingLevel: state.chatThinkingLevel,
showThinking,
loading: state.chatLoading,
sending: state.chatSending,
compactionStatus: state.compactionStatus,
assistantAvatarUrl: chatAvatarUrl,
messages: state.chatMessages,
toolMessages: state.chatToolMessages,
stream: state.chatStream,
streamStartedAt: state.chatStreamStartedAt,
draft: state.chatMessage,
queue: state.chatQueue,
connected: state.connected,
canSend: state.connected,
disabledReason: chatDisabledReason,
error: state.lastError,
sessions: state.sessionsResult,
focusMode: chatFocus,
onRefresh: () => {
state.resetToolStream();
return Promise.all([loadChatHistory(state), refreshChatAvatar(state)]);
},
onToggleFocusMode: () => {
if (state.onboarding) return;
state.applySettings({
...state.settings,
chatFocusMode: !state.settings.chatFocusMode,
});
},
onChatScroll: (event) => state.handleChatScroll(event),
onDraftChange: (next) => (state.chatMessage = next),
onSend: () => state.handleSendChat(),
canAbort: Boolean(state.chatRunId),
onAbort: () => void state.handleAbortChat(),
onQueueRemove: (id) => state.removeQueuedMessage(id),
attachments: state.chatAttachments,
onAttachmentsChange: (next) => (state.chatAttachments = next),
onNewSession: () =>
state.handleSendChat("/new", { restoreDraft: true }),
// Sidebar props for tool output viewing
sidebarOpen: state.sidebarOpen,
sidebarContent: state.sidebarContent,
sidebarError: state.sidebarError,
splitRatio: state.splitRatio,
onOpenSidebar: (content: string) => state.handleOpenSidebar(content),
onCloseSidebar: () => state.handleCloseSidebar(),
onSplitRatioChange: (ratio: number) => state.handleSplitRatioChange(ratio),
assistantName: state.assistantName,
assistantAvatar: state.assistantAvatar,
})
: nothing}
${state.tab === "config"
? renderConfig({
raw: state.configRaw,
originalRaw: state.configRawOriginal,
valid: state.configValid,
issues: state.configIssues,
loading: state.configLoading,
saving: state.configSaving,
applying: state.configApplying,
updating: state.updateRunning,
connected: state.connected,
schema: state.configSchema,
schemaLoading: state.configSchemaLoading,
uiHints: state.configUiHints,
formMode: state.configFormMode,
formValue: state.configForm,
originalValue: state.configFormOriginal,
searchQuery: state.configSearchQuery,
activeSection: state.configActiveSection,
activeSubsection: state.configActiveSubsection,
onRawChange: (next) => {
state.configRaw = next;
},
onFormModeChange: (mode) => (state.configFormMode = mode),
onFormPatch: (path, value) => updateConfigFormValue(state, path, value),
onSearchChange: (query) => (state.configSearchQuery = query),
onSectionChange: (section) => {
state.configActiveSection = section;
state.configActiveSubsection = null;
},
onSubsectionChange: (section) => (state.configActiveSubsection = section),
onReload: () => loadConfig(state),
onSave: () => saveConfig(state),
onApply: () => applyConfig(state),
onUpdate: () => runUpdate(state),
})
: nothing}
? renderConfig({
raw: state.configRaw,
originalRaw: state.configRawOriginal,
valid: state.configValid,
issues: state.configIssues,
loading: state.configLoading,
saving: state.configSaving,
applying: state.configApplying,
updating: state.updateRunning,
connected: state.connected,
schema: state.configSchema,
schemaLoading: state.configSchemaLoading,
uiHints: state.configUiHints,
formMode: state.configFormMode,
formValue: state.configForm,
originalValue: state.configFormOriginal,
searchQuery: state.configSearchQuery,
activeSection: state.configActiveSection,
activeSubsection: state.configActiveSubsection,
onRawChange: (next) => {
state.configRaw = next;
},
onFormModeChange: (mode) => (state.configFormMode = mode),
onFormPatch: (path, value) => updateConfigFormValue(state, path, value),
onSearchChange: (query) => (state.configSearchQuery = query),
onSectionChange: (section) => {
state.configActiveSection = section;
state.configActiveSubsection = null;
},
onSubsectionChange: (section) => (state.configActiveSubsection = section),
onReload: () => loadConfig(state),
onSave: () => saveConfig(state),
onApply: () => applyConfig(state),
onUpdate: () => runUpdate(state),
})
: nothing}
${state.tab === "debug"
? renderDebug({
loading: state.debugLoading,
status: state.debugStatus,
health: state.debugHealth,
models: state.debugModels,
heartbeat: state.debugHeartbeat,
eventLog: state.eventLog,
callMethod: state.debugCallMethod,
callParams: state.debugCallParams,
callResult: state.debugCallResult,
callError: state.debugCallError,
onCallMethodChange: (next) => (state.debugCallMethod = next),
onCallParamsChange: (next) => (state.debugCallParams = next),
onRefresh: () => loadDebug(state),
onCall: () => callDebugMethod(state),
})
: nothing}
? renderDebug({
loading: state.debugLoading,
status: state.debugStatus,
health: state.debugHealth,
models: state.debugModels,
heartbeat: state.debugHeartbeat,
eventLog: state.eventLog,
callMethod: state.debugCallMethod,
callParams: state.debugCallParams,
callResult: state.debugCallResult,
callError: state.debugCallError,
onCallMethodChange: (next) => (state.debugCallMethod = next),
onCallParamsChange: (next) => (state.debugCallParams = next),
onRefresh: () => loadDebug(state),
onCall: () => callDebugMethod(state),
})
: nothing}
${state.tab === "logs"
? renderLogs({
loading: state.logsLoading,
error: state.logsError,
file: state.logsFile,
entries: state.logsEntries,
filterText: state.logsFilterText,
levelFilters: state.logsLevelFilters,
autoFollow: state.logsAutoFollow,
truncated: state.logsTruncated,
onFilterTextChange: (next) => (state.logsFilterText = next),
onLevelToggle: (level, enabled) => {
state.logsLevelFilters = { ...state.logsLevelFilters, [level]: enabled };
},
onToggleAutoFollow: (next) => (state.logsAutoFollow = next),
onRefresh: () => loadLogs(state, { reset: true }),
onExport: (lines, label) => state.exportLogs(lines, label),
onScroll: (event) => state.handleLogsScroll(event),
})
: nothing}
? renderLogs({
loading: state.logsLoading,
error: state.logsError,
file: state.logsFile,
entries: state.logsEntries,
filterText: state.logsFilterText,
levelFilters: state.logsLevelFilters,
autoFollow: state.logsAutoFollow,
truncated: state.logsTruncated,
onFilterTextChange: (next) => (state.logsFilterText = next),
onLevelToggle: (level, enabled) => {
state.logsLevelFilters = { ...state.logsLevelFilters, [level]: enabled };
},
onToggleAutoFollow: (next) => (state.logsAutoFollow = next),
onRefresh: () => loadLogs(state, { reset: true }),
onExport: (lines, label) => state.exportLogs(lines, label),
onScroll: (event) => state.handleLogsScroll(event),
})
: nothing}
</main>
${renderExecApprovalPrompt(state)}
${renderGatewayUrlConfirmation(state)}

View File

@ -200,6 +200,16 @@ export function applyResolvedTheme(host: SettingsHost, resolved: ResolvedTheme)
const root = document.documentElement;
root.dataset.theme = 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) {

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() {
await loadOverviewInternal(
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) {
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() {

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

View File

@ -66,10 +66,6 @@ export async function patchSession(
export async function deleteSession(state: SessionsState, key: string) {
if (!state.client || !state.connected) return;
if (state.sessionsLoading) return;
const confirmed = window.confirm(
`Delete session "${key}"?\n\nDeletes the session entry and archives its transcript.`,
);
if (!confirmed) return;
state.sessionsLoading = true;
state.sessionsError = null;
try {

View File

@ -19,6 +19,7 @@ export const 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>`,
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>`,
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>`,

View File

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

View File

@ -1,5 +1,9 @@
export type ThemeMode = "system" | "light" | "dark";
export type ResolvedTheme = "light" | "dark";
export type ColorTheme = "tangerine" | "ocean" | "forest" | "sunset" | "lavender" | "slate";
const STORAGE_KEY = "clawdbot-theme";
const COLOR_THEME_KEY = "clawdbot-color-theme";
export function getSystemTheme(): ResolvedTheme {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
@ -14,3 +18,107 @@ export function resolveTheme(mode: ThemeMode): ResolvedTheme {
if (mode === "system") return getSystemTheme();
return mode;
}
export function getSavedTheme(): ThemeMode {
if (typeof window === "undefined" || typeof localStorage === "undefined") {
return "system";
}
const saved = localStorage.getItem(STORAGE_KEY);
if (saved === "light" || saved === "dark" || saved === "system") {
return saved;
}
return "system";
}
export function saveTheme(mode: ThemeMode): void {
if (typeof window === "undefined" || typeof localStorage === "undefined") {
return;
}
localStorage.setItem(STORAGE_KEY, mode);
}
export function getSavedColorTheme(): ColorTheme {
if (typeof window === "undefined" || typeof localStorage === "undefined") {
return "tangerine";
}
const saved = localStorage.getItem(COLOR_THEME_KEY);
if (saved === "tangerine" || saved === "ocean" || saved === "forest" ||
saved === "sunset" || saved === "lavender" || saved === "slate") {
return saved;
}
return "tangerine";
}
export function saveColorTheme(colorTheme: ColorTheme): void {
if (typeof window === "undefined" || typeof localStorage === "undefined") {
return;
}
localStorage.setItem(COLOR_THEME_KEY, colorTheme);
}
export function applyColorTheme(colorTheme: ColorTheme): void {
if (typeof document === "undefined") return;
const root = document.documentElement;
root.setAttribute("data-color-theme", colorTheme);
}
export function applyTheme(mode: ThemeMode): void {
if (typeof document === "undefined") return;
const root = document.documentElement;
const resolved = resolveTheme(mode);
if (mode === "system") {
root.removeAttribute("data-theme");
} else {
root.setAttribute("data-theme", mode);
}
// Update meta theme-color for mobile browsers
updateMetaThemeColor(resolved);
}
function updateMetaThemeColor(theme: ResolvedTheme): void {
if (typeof document === "undefined") return;
let metaThemeColor = document.querySelector('meta[name="theme-color"]');
if (!metaThemeColor) {
metaThemeColor = document.createElement("meta");
metaThemeColor.setAttribute("name", "theme-color");
document.head.appendChild(metaThemeColor);
}
// Muted backgrounds from design system
const color = theme === "light" ? "#FAFAF9" : "#1A1816";
metaThemeColor.setAttribute("content", color);
}
export function initTheme(): ThemeMode {
const saved = getSavedTheme();
const colorTheme = getSavedColorTheme();
applyTheme(saved);
applyColorTheme(colorTheme);
// Watch for system theme changes
if (typeof window !== "undefined" && typeof window.matchMedia === "function") {
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
mediaQuery.addEventListener("change", () => {
const current = getSavedTheme();
if (current === "system") {
applyTheme("system");
}
});
}
return saved;
}
export const COLOR_THEMES: Record<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 disabled = props.configSaving || props.configSchemaLoading;
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
? html`<div class="muted">Loading config schema…</div>`
: renderChannelConfigForm({
@ -113,22 +116,6 @@ export function renderChannelConfigSection(params: {
disabled,
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>
`;
}

View File

@ -11,51 +11,71 @@ export function renderDiscordCard(params: {
accountCountLabel: unknown;
}) {
const { props, discord, accountCountLabel } = params;
const configDisabled = props.configSaving || props.configSchemaLoading;
return html`
<div class="card">
<div class="card-title">Discord</div>
<div class="card-sub">Bot status and channel configuration.</div>
${accountCountLabel}
<div class="card card--channel">
<div class="card-content">
<div class="card-title">Discord</div>
<div class="card-sub">Bot status and channel configuration.</div>
${accountCountLabel}
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
<span>${discord?.configured ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Running</span>
<span>${discord?.running ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Last start</span>
<span>${discord?.lastStartAt ? formatAgo(discord.lastStartAt) : "n/a"}</span>
</div>
<div>
<span class="label">Last probe</span>
<span>${discord?.lastProbeAt ? formatAgo(discord.lastProbeAt) : "n/a"}</span>
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
<span>${discord?.configured ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Running</span>
<span>${discord?.running ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Last start</span>
<span>${discord?.lastStartAt ? formatAgo(discord.lastStartAt) : "n/a"}</span>
</div>
<div>
<span class="label">Last probe</span>
<span>${discord?.lastProbeAt ? formatAgo(discord.lastProbeAt) : "n/a"}</span>
</div>
</div>
${discord?.probe
? html`<div class="callout" style="margin-top: 12px;">
Probe ${discord.probe.ok ? "ok" : "failed"} ·
${discord.probe.status ?? ""} ${discord.probe.error ?? ""}
</div>`
: nothing}
${renderChannelConfigSection({ channelId: "discord", props })}
</div>
${discord?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${discord.lastError}
</div>`
: nothing}
<div class="card-footer">
${discord?.lastError
? html`<div class="callout danger" style="margin-bottom: 12px;">
${discord.lastError}
</div>`
: nothing}
${discord?.probe
? html`<div class="callout" style="margin-top: 12px;">
Probe ${discord.probe.ok ? "ok" : "failed"} ·
${discord.probe.status ?? ""} ${discord.probe.error ?? ""}
</div>`
: nothing}
${renderChannelConfigSection({ channelId: "discord", props })}
<div class="row" style="margin-top: 12px;">
<button class="btn" @click=${() => props.onRefresh(true)}>
Probe
</button>
<div class="row" style="gap: 8px; align-items: center; flex-wrap: wrap;">
<ui-button @click=${() => props.onRefresh(true)}>
Probe
</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>
`;

View File

@ -11,63 +11,83 @@ export function renderGoogleChatCard(params: {
accountCountLabel: unknown;
}) {
const { props, googleChat, accountCountLabel } = params;
const configDisabled = props.configSaving || props.configSchemaLoading;
return html`
<div class="card">
<div class="card-title">Google Chat</div>
<div class="card-sub">Chat API webhook status and channel configuration.</div>
${accountCountLabel}
<div class="card card--channel">
<div class="card-content">
<div class="card-title">Google Chat</div>
<div class="card-sub">Chat API webhook status and channel configuration.</div>
${accountCountLabel}
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
<span>${googleChat ? (googleChat.configured ? "Yes" : "No") : "n/a"}</span>
</div>
<div>
<span class="label">Running</span>
<span>${googleChat ? (googleChat.running ? "Yes" : "No") : "n/a"}</span>
</div>
<div>
<span class="label">Credential</span>
<span>${googleChat?.credentialSource ?? "n/a"}</span>
</div>
<div>
<span class="label">Audience</span>
<span>
${googleChat?.audienceType
? `${googleChat.audienceType}${googleChat.audience ? ` · ${googleChat.audience}` : ""}`
: "n/a"}
</span>
</div>
<div>
<span class="label">Last start</span>
<span>${googleChat?.lastStartAt ? formatAgo(googleChat.lastStartAt) : "n/a"}</span>
</div>
<div>
<span class="label">Last probe</span>
<span>${googleChat?.lastProbeAt ? formatAgo(googleChat.lastProbeAt) : "n/a"}</span>
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
<span>${googleChat ? (googleChat.configured ? "Yes" : "No") : "n/a"}</span>
</div>
<div>
<span class="label">Running</span>
<span>${googleChat ? (googleChat.running ? "Yes" : "No") : "n/a"}</span>
</div>
<div>
<span class="label">Credential</span>
<span>${googleChat?.credentialSource ?? "n/a"}</span>
</div>
<div>
<span class="label">Audience</span>
<span>
${googleChat?.audienceType
? `${googleChat.audienceType}${googleChat.audience ? ` · ${googleChat.audience}` : ""}`
: "n/a"}
</span>
</div>
<div>
<span class="label">Last start</span>
<span>${googleChat?.lastStartAt ? formatAgo(googleChat.lastStartAt) : "n/a"}</span>
</div>
<div>
<span class="label">Last probe</span>
<span>${googleChat?.lastProbeAt ? formatAgo(googleChat.lastProbeAt) : "n/a"}</span>
</div>
</div>
${googleChat?.probe
? html`<div class="callout" style="margin-top: 12px;">
Probe ${googleChat.probe.ok ? "ok" : "failed"} ·
${googleChat.probe.status ?? ""} ${googleChat.probe.error ?? ""}
</div>`
: nothing}
${renderChannelConfigSection({ channelId: "googlechat", props })}
</div>
${googleChat?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${googleChat.lastError}
</div>`
: nothing}
<div class="card-footer">
${googleChat?.lastError
? html`<div class="callout danger" style="margin-bottom: 12px;">
${googleChat.lastError}
</div>`
: nothing}
${googleChat?.probe
? html`<div class="callout" style="margin-top: 12px;">
Probe ${googleChat.probe.ok ? "ok" : "failed"} ·
${googleChat.probe.status ?? ""} ${googleChat.probe.error ?? ""}
</div>`
: nothing}
${renderChannelConfigSection({ channelId: "googlechat", props })}
<div class="row" style="margin-top: 12px;">
<button class="btn" @click=${() => props.onRefresh(true)}>
Probe
</button>
<div class="row" style="gap: 8px; align-items: center; flex-wrap: wrap;">
<ui-button @click=${() => props.onRefresh(true)}>
Probe
</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>
`;

View File

@ -11,51 +11,71 @@ export function renderIMessageCard(params: {
accountCountLabel: unknown;
}) {
const { props, imessage, accountCountLabel } = params;
const configDisabled = props.configSaving || props.configSchemaLoading;
return html`
<div class="card">
<div class="card-title">iMessage</div>
<div class="card-sub">macOS bridge status and channel configuration.</div>
${accountCountLabel}
<div class="card card--channel">
<div class="card-content">
<div class="card-title">iMessage</div>
<div class="card-sub">macOS bridge status and channel configuration.</div>
${accountCountLabel}
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
<span>${imessage?.configured ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Running</span>
<span>${imessage?.running ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Last start</span>
<span>${imessage?.lastStartAt ? formatAgo(imessage.lastStartAt) : "n/a"}</span>
</div>
<div>
<span class="label">Last probe</span>
<span>${imessage?.lastProbeAt ? formatAgo(imessage.lastProbeAt) : "n/a"}</span>
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
<span>${imessage?.configured ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Running</span>
<span>${imessage?.running ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Last start</span>
<span>${imessage?.lastStartAt ? formatAgo(imessage.lastStartAt) : "n/a"}</span>
</div>
<div>
<span class="label">Last probe</span>
<span>${imessage?.lastProbeAt ? formatAgo(imessage.lastProbeAt) : "n/a"}</span>
</div>
</div>
${imessage?.probe
? html`<div class="callout" style="margin-top: 12px;">
Probe ${imessage.probe.ok ? "ok" : "failed"} ·
${imessage.probe.error ?? ""}
</div>`
: nothing}
${renderChannelConfigSection({ channelId: "imessage", props })}
</div>
${imessage?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${imessage.lastError}
</div>`
: nothing}
<div class="card-footer">
${imessage?.lastError
? html`<div class="callout danger" style="margin-bottom: 12px;">
${imessage.lastError}
</div>`
: nothing}
${imessage?.probe
? html`<div class="callout" style="margin-top: 12px;">
Probe ${imessage.probe.ok ? "ok" : "failed"} ·
${imessage.probe.error ?? ""}
</div>`
: nothing}
${renderChannelConfigSection({ channelId: "imessage", props })}
<div class="row" style="margin-top: 12px;">
<button class="btn" @click=${() => props.onRefresh(true)}>
Probe
</button>
<div class="row" style="gap: 8px; align-items: center; flex-wrap: wrap;">
<ui-button @click=${() => props.onRefresh(true)}>
Probe
</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>
`;

View File

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

View File

@ -50,6 +50,7 @@ export function renderNostrCard(params: {
const summaryLastError = nostr?.lastError ?? primaryAccount?.lastError ?? null;
const hasMultipleAccounts = nostrAccounts.length > 1;
const showingForm = profileFormState !== null && profileFormState !== undefined;
const configDisabled = props.configSaving || props.configSchemaLoading;
const renderAccountCard = (account: ChannelAccountSnapshot) => {
const publicKey = (account as { publicKey?: string }).publicKey;
@ -120,13 +121,12 @@ export function renderNostrCard(params: {
<div style="font-weight: 500;">Profile</div>
${summaryConfigured
? html`
<button
class="btn btn-sm"
<ui-button
size="sm"
@click=${onEditProfile}
style="font-size: 12px; padding: 4px 8px;"
>
Edit Profile
</button>
</ui-button>
`
: nothing}
</div>
@ -167,50 +167,69 @@ export function renderNostrCard(params: {
};
return html`
<div class="card">
<div class="card-title">Nostr</div>
<div class="card-sub">Decentralized DMs via Nostr relays (NIP-04).</div>
${accountCountLabel}
<div class="card card--channel">
<div class="card-content">
<div class="card-title">Nostr</div>
<div class="card-sub">Decentralized DMs via Nostr relays (NIP-04).</div>
${accountCountLabel}
${hasMultipleAccounts
? html`
<div class="account-card-list">
${nostrAccounts.map((account) => renderAccountCard(account))}
</div>
`
: html`
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
<span>${summaryConfigured ? "Yes" : "No"}</span>
${hasMultipleAccounts
? html`
<div class="account-card-list">
${nostrAccounts.map((account) => renderAccountCard(account))}
</div>
<div>
<span class="label">Running</span>
<span>${summaryRunning ? "Yes" : "No"}</span>
`
: html`
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
<span>${summaryConfigured ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Running</span>
<span>${summaryRunning ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Public Key</span>
<span class="monospace" title="${summaryPublicKey ?? ""}"
>${truncatePubkey(summaryPublicKey)}</span
>
</div>
<div>
<span class="label">Last start</span>
<span>${summaryLastStartAt ? formatAgo(summaryLastStartAt) : "n/a"}</span>
</div>
</div>
<div>
<span class="label">Public Key</span>
<span class="monospace" title="${summaryPublicKey ?? ""}"
>${truncatePubkey(summaryPublicKey)}</span
>
</div>
<div>
<span class="label">Last start</span>
<span>${summaryLastStartAt ? formatAgo(summaryLastStartAt) : "n/a"}</span>
</div>
</div>
`}
`}
${summaryLastError
? html`<div class="callout danger" style="margin-top: 12px;">${summaryLastError}</div>`
: nothing}
${renderProfileSection()}
${renderProfileSection()}
${renderChannelConfigSection({ channelId: "nostr", props })}
</div>
${renderChannelConfigSection({ channelId: "nostr", props })}
<div class="card-footer">
${summaryLastError
? html`<div class="callout danger" style="margin-bottom: 12px;">${summaryLastError}</div>`
: nothing}
<div class="row" style="margin-top: 12px;">
<button class="btn" @click=${() => props.onRefresh(false)}>Refresh</button>
<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>
`;

View File

@ -11,55 +11,75 @@ export function renderSignalCard(params: {
accountCountLabel: unknown;
}) {
const { props, signal, accountCountLabel } = params;
const configDisabled = props.configSaving || props.configSchemaLoading;
return html`
<div class="card">
<div class="card-title">Signal</div>
<div class="card-sub">signal-cli status and channel configuration.</div>
${accountCountLabel}
<div class="card card--channel">
<div class="card-content">
<div class="card-title">Signal</div>
<div class="card-sub">signal-cli status and channel configuration.</div>
${accountCountLabel}
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
<span>${signal?.configured ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Running</span>
<span>${signal?.running ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Base URL</span>
<span>${signal?.baseUrl ?? "n/a"}</span>
</div>
<div>
<span class="label">Last start</span>
<span>${signal?.lastStartAt ? formatAgo(signal.lastStartAt) : "n/a"}</span>
</div>
<div>
<span class="label">Last probe</span>
<span>${signal?.lastProbeAt ? formatAgo(signal.lastProbeAt) : "n/a"}</span>
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
<span>${signal?.configured ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Running</span>
<span>${signal?.running ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Base URL</span>
<span>${signal?.baseUrl ?? "n/a"}</span>
</div>
<div>
<span class="label">Last start</span>
<span>${signal?.lastStartAt ? formatAgo(signal.lastStartAt) : "n/a"}</span>
</div>
<div>
<span class="label">Last probe</span>
<span>${signal?.lastProbeAt ? formatAgo(signal.lastProbeAt) : "n/a"}</span>
</div>
</div>
${signal?.probe
? html`<div class="callout" style="margin-top: 12px;">
Probe ${signal.probe.ok ? "ok" : "failed"} ·
${signal.probe.status ?? ""} ${signal.probe.error ?? ""}
</div>`
: nothing}
${renderChannelConfigSection({ channelId: "signal", props })}
</div>
${signal?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${signal.lastError}
</div>`
: nothing}
<div class="card-footer">
${signal?.lastError
? html`<div class="callout danger" style="margin-bottom: 12px;">
${signal.lastError}
</div>`
: nothing}
${signal?.probe
? html`<div class="callout" style="margin-top: 12px;">
Probe ${signal.probe.ok ? "ok" : "failed"} ·
${signal.probe.status ?? ""} ${signal.probe.error ?? ""}
</div>`
: nothing}
${renderChannelConfigSection({ channelId: "signal", props })}
<div class="row" style="margin-top: 12px;">
<button class="btn" @click=${() => props.onRefresh(true)}>
Probe
</button>
<div class="row" style="gap: 8px; align-items: center; flex-wrap: wrap;">
<ui-button @click=${() => props.onRefresh(true)}>
Probe
</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>
`;

View File

@ -11,51 +11,71 @@ export function renderSlackCard(params: {
accountCountLabel: unknown;
}) {
const { props, slack, accountCountLabel } = params;
const configDisabled = props.configSaving || props.configSchemaLoading;
return html`
<div class="card">
<div class="card-title">Slack</div>
<div class="card-sub">Socket mode status and channel configuration.</div>
${accountCountLabel}
<div class="card card--channel">
<div class="card-content">
<div class="card-title">Slack</div>
<div class="card-sub">Socket mode status and channel configuration.</div>
${accountCountLabel}
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
<span>${slack?.configured ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Running</span>
<span>${slack?.running ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Last start</span>
<span>${slack?.lastStartAt ? formatAgo(slack.lastStartAt) : "n/a"}</span>
</div>
<div>
<span class="label">Last probe</span>
<span>${slack?.lastProbeAt ? formatAgo(slack.lastProbeAt) : "n/a"}</span>
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
<span>${slack?.configured ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Running</span>
<span>${slack?.running ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Last start</span>
<span>${slack?.lastStartAt ? formatAgo(slack.lastStartAt) : "n/a"}</span>
</div>
<div>
<span class="label">Last probe</span>
<span>${slack?.lastProbeAt ? formatAgo(slack.lastProbeAt) : "n/a"}</span>
</div>
</div>
${slack?.probe
? html`<div class="callout" style="margin-top: 12px;">
Probe ${slack.probe.ok ? "ok" : "failed"} ·
${slack.probe.status ?? ""} ${slack.probe.error ?? ""}
</div>`
: nothing}
${renderChannelConfigSection({ channelId: "slack", props })}
</div>
${slack?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${slack.lastError}
</div>`
: nothing}
<div class="card-footer">
${slack?.lastError
? html`<div class="callout danger" style="margin-bottom: 12px;">
${slack.lastError}
</div>`
: nothing}
${slack?.probe
? html`<div class="callout" style="margin-top: 12px;">
Probe ${slack.probe.ok ? "ok" : "failed"} ·
${slack.probe.status ?? ""} ${slack.probe.error ?? ""}
</div>`
: nothing}
${renderChannelConfigSection({ channelId: "slack", props })}
<div class="row" style="margin-top: 12px;">
<button class="btn" @click=${() => props.onRefresh(true)}>
Probe
</button>
<div class="row" style="gap: 8px; align-items: center; flex-wrap: wrap;">
<ui-button @click=${() => props.onRefresh(true)}>
Probe
</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>
`;

View File

@ -13,6 +13,7 @@ export function renderTelegramCard(params: {
}) {
const { props, telegram, telegramAccounts, accountCountLabel } = params;
const hasMultipleAccounts = telegramAccounts.length > 1;
const configDisabled = props.configSaving || props.configSchemaLoading;
const renderAccountCard = (account: ChannelAccountSnapshot) => {
const probe = account.probe as { bot?: { username?: string } } | undefined;
@ -52,61 +53,80 @@ export function renderTelegramCard(params: {
};
return html`
<div class="card">
<div class="card-title">Telegram</div>
<div class="card-sub">Bot status and channel configuration.</div>
${accountCountLabel}
<div class="card card--channel">
<div class="card-content">
<div class="card-title">Telegram</div>
<div class="card-sub">Bot status and channel configuration.</div>
${accountCountLabel}
${hasMultipleAccounts
? html`
<div class="account-card-list">
${telegramAccounts.map((account) => renderAccountCard(account))}
</div>
`
: html`
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
<span>${telegram?.configured ? "Yes" : "No"}</span>
${hasMultipleAccounts
? html`
<div class="account-card-list">
${telegramAccounts.map((account) => renderAccountCard(account))}
</div>
<div>
<span class="label">Running</span>
<span>${telegram?.running ? "Yes" : "No"}</span>
`
: html`
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
<span>${telegram?.configured ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Running</span>
<span>${telegram?.running ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Mode</span>
<span>${telegram?.mode ?? "n/a"}</span>
</div>
<div>
<span class="label">Last start</span>
<span>${telegram?.lastStartAt ? formatAgo(telegram.lastStartAt) : "n/a"}</span>
</div>
<div>
<span class="label">Last probe</span>
<span>${telegram?.lastProbeAt ? formatAgo(telegram.lastProbeAt) : "n/a"}</span>
</div>
</div>
<div>
<span class="label">Mode</span>
<span>${telegram?.mode ?? "n/a"}</span>
</div>
<div>
<span class="label">Last start</span>
<span>${telegram?.lastStartAt ? formatAgo(telegram.lastStartAt) : "n/a"}</span>
</div>
<div>
<span class="label">Last probe</span>
<span>${telegram?.lastProbeAt ? formatAgo(telegram.lastProbeAt) : "n/a"}</span>
</div>
</div>
`}
`}
${telegram?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${telegram.lastError}
</div>`
: nothing}
${telegram?.probe
? html`<div class="callout" style="margin-top: 12px;">
Probe ${telegram.probe.ok ? "ok" : "failed"} ·
${telegram.probe.status ?? ""} ${telegram.probe.error ?? ""}
</div>`
: nothing}
${telegram?.probe
? html`<div class="callout" style="margin-top: 12px;">
Probe ${telegram.probe.ok ? "ok" : "failed"} ·
${telegram.probe.status ?? ""} ${telegram.probe.error ?? ""}
</div>`
: nothing}
${renderChannelConfigSection({ channelId: "telegram", props })}
</div>
${renderChannelConfigSection({ channelId: "telegram", props })}
<div class="card-footer">
${telegram?.lastError
? html`<div class="callout danger" style="margin-bottom: 12px;">
${telegram.lastError}
</div>`
: nothing}
<div class="row" style="margin-top: 12px;">
<button class="btn" @click=${() => props.onRefresh(true)}>
Probe
</button>
<div class="row" style="gap: 8px; align-items: center; flex-wrap: wrap;">
<ui-button @click=${() => props.onRefresh(true)}>
Probe
</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>
`;

View File

@ -5,6 +5,7 @@ import type { WhatsAppStatus } from "../types";
import type { ChannelsProps } from "./channels.types";
import { renderChannelConfigSection } from "./channels.config";
import { formatDuration } from "./channels.shared";
import "../components/dropdown-menu";
export function renderWhatsAppCard(params: {
props: ChannelsProps;
@ -13,106 +14,132 @@ export function renderWhatsAppCard(params: {
}) {
const { props, whatsapp, accountCountLabel } = params;
const menuItems = [
{ label: "Relink", value: "relink", disabled: props.whatsappBusy },
{ label: "Wait for scan", value: "wait", disabled: props.whatsappBusy },
{ label: "Logout", value: "logout", variant: "danger" as const, disabled: props.whatsappBusy },
];
const handleMenuSelect = (e: CustomEvent) => {
const { value } = e.detail;
switch (value) {
case "relink":
props.onWhatsAppStart(true);
break;
case "wait":
props.onWhatsAppWait();
break;
case "logout":
props.onWhatsAppLogout();
break;
}
};
const configDisabled = props.configSaving || props.configSchemaLoading;
return html`
<div class="card">
<div class="card-title">WhatsApp</div>
<div class="card-sub">Link WhatsApp Web and monitor connection health.</div>
${accountCountLabel}
<div class="card card--channel">
<div class="card-content">
<div class="card-title">WhatsApp</div>
<div class="card-sub">Link WhatsApp Web and monitor connection health.</div>
${accountCountLabel}
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
<span>${whatsapp?.configured ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Linked</span>
<span>${whatsapp?.linked ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Running</span>
<span>${whatsapp?.running ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Connected</span>
<span>${whatsapp?.connected ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Last connect</span>
<span>
${whatsapp?.lastConnectedAt
? formatAgo(whatsapp.lastConnectedAt)
: "n/a"}
</span>
</div>
<div>
<span class="label">Last message</span>
<span>
${whatsapp?.lastMessageAt ? formatAgo(whatsapp.lastMessageAt) : "n/a"}
</span>
</div>
<div>
<span class="label">Auth age</span>
<span>
${whatsapp?.authAgeMs != null
? formatDuration(whatsapp.authAgeMs)
: "n/a"}
</span>
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
<span>${whatsapp?.configured ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Linked</span>
<span>${whatsapp?.linked ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Running</span>
<span>${whatsapp?.running ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Connected</span>
<span>${whatsapp?.connected ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Last connect</span>
<span>
${whatsapp?.lastConnectedAt
? formatAgo(whatsapp.lastConnectedAt)
: "n/a"}
</span>
</div>
<div>
<span class="label">Last message</span>
<span>
${whatsapp?.lastMessageAt ? formatAgo(whatsapp.lastMessageAt) : "n/a"}
</span>
</div>
<div>
<span class="label">Auth age</span>
<span>
${whatsapp?.authAgeMs != null
? formatDuration(whatsapp.authAgeMs)
: "n/a"}
</span>
</div>
</div>
${props.whatsappMessage
? html`<div class="callout" style="margin-top: 12px;">
${props.whatsappMessage}
</div>`
: nothing}
${props.whatsappQrDataUrl
? html`<div class="qr-wrap">
<img src=${props.whatsappQrDataUrl} alt="WhatsApp QR" />
</div>`
: nothing}
${renderChannelConfigSection({ channelId: "whatsapp", props })}
</div>
${whatsapp?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${whatsapp.lastError}
</div>`
: nothing}
<div class="card-footer">
${whatsapp?.lastError
? html`<div class="callout danger" style="margin-bottom: 12px;">
${whatsapp.lastError}
</div>`
: nothing}
${props.whatsappMessage
? html`<div class="callout" style="margin-top: 12px;">
${props.whatsappMessage}
</div>`
: nothing}
${props.whatsappQrDataUrl
? html`<div class="qr-wrap">
<img src=${props.whatsappQrDataUrl} alt="WhatsApp QR" />
</div>`
: nothing}
<div class="row" style="margin-top: 14px; flex-wrap: wrap;">
<button
class="btn primary"
?disabled=${props.whatsappBusy}
@click=${() => props.onWhatsAppStart(false)}
>
${props.whatsappBusy ? "Working…" : "Show QR"}
</button>
<button
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
</button>
<div class="row" style="gap: 8px; align-items: center; flex-wrap: wrap;">
<ui-button
variant="primary"
?disabled=${props.whatsappBusy}
@click=${() => props.onWhatsAppStart(false)}
>
${props.whatsappBusy ? "Working…" : "Show QR"}
</ui-button>
<ui-button @click=${() => props.onRefresh(true)}>
Refresh
</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>
${renderChannelConfigSection({ channelId: "whatsapp", props })}
</div>
`;
}

View File

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

View File

@ -27,13 +27,18 @@ function jsonValue(value: unknown): string {
}
// SVG Icons as template literals
const icons = {
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>`,
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>`,
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>`,
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>`,
};
import "../components";
// Helper to wrap generic patch with component events
function handlePatch(
path: Array<string | number>,
onPatch: (path: Array<string | number>, value: unknown) => void
) {
return (e: CustomEvent) => {
const { value, checked } = e.detail;
onPatch(path, checked ?? value);
};
}
export function renderNode(params: {
schema: JsonSchema;
@ -83,22 +88,21 @@ export function renderNode(params: {
if (allLiterals && literals.length > 0 && literals.length <= 5) {
// Use segmented control for small sets
const resolvedValue = value ?? schema.default;
const options = literals.map((lit) => ({ label: String(lit), value: String(lit) }));
return html`
<div class="cfg-field">
${showLabel ? html`<label class="cfg-field__label">${label}</label>` : nothing}
${help ? html`<div class="cfg-field__help">${help}</div>` : nothing}
<div class="cfg-segmented">
${literals.map((lit, idx) => html`
<button
type="button"
class="cfg-segmented__btn ${lit === resolvedValue || String(lit) === String(resolvedValue) ? 'active' : ''}"
?disabled=${disabled}
@click=${() => onPatch(path, lit)}
>
${String(lit)}
</button>
`)}
</div>
<ui-segmented-control
.options=${options}
.value=${String(resolvedValue)}
@change=${(e: CustomEvent) => {
// Find original literal type
const selected = literals.find(l => String(l) === e.detail.value);
onPatch(path, selected);
}}
></ui-segmented-control>
</div>
`;
}
@ -142,22 +146,18 @@ export function renderNode(params: {
const options = schema.enum;
if (options.length <= 5) {
const resolvedValue = value ?? schema.default;
const enumOptions = options.map((opt) => ({ label: String(opt), value: String(opt) }));
return html`
<div class="cfg-field">
${showLabel ? html`<label class="cfg-field__label">${label}</label>` : nothing}
${help ? html`<div class="cfg-field__help">${help}</div>` : nothing}
<div class="cfg-segmented">
${options.map((opt) => html`
<button
type="button"
class="cfg-segmented__btn ${opt === resolvedValue || String(opt) === String(resolvedValue) ? 'active' : ''}"
?disabled=${disabled}
@click=${() => onPatch(path, opt)}
>
${String(opt)}
</button>
`)}
</div>
<ui-segmented-control
.options=${enumOptions}
.value=${String(resolvedValue)}
@change=${(e: CustomEvent) => {
onPatch(path, e.detail.value);
}}
></ui-segmented-control>
</div>
`;
}
@ -178,21 +178,17 @@ export function renderNode(params: {
if (type === "boolean") {
const displayValue = typeof value === "boolean" ? value : typeof schema.default === "boolean" ? schema.default : false;
return html`
<label class="cfg-toggle-row ${disabled ? 'disabled' : ''}">
<div class="cfg-toggle-row ${disabled ? 'disabled' : ''}">
<div class="cfg-toggle-row__content">
<span class="cfg-toggle-row__label">${label}</span>
${help ? html`<span class="cfg-toggle-row__help">${help}</span>` : nothing}
</div>
<div class="cfg-toggle">
<input
type="checkbox"
.checked=${displayValue}
?disabled=${disabled}
@change=${(e: Event) => onPatch(path, (e.target as HTMLInputElement).checked)}
/>
<span class="cfg-toggle__track"></span>
</div>
</label>
<ui-switch
.checked=${displayValue}
?disabled=${disabled}
@change=${handlePatch(path, onPatch)}
></ui-switch>
</div>
`;
}
@ -241,34 +237,34 @@ function renderTextInput(params: {
${showLabel ? html`<label class="cfg-field__label">${label}</label>` : nothing}
${help ? html`<div class="cfg-field__help">${help}</div>` : nothing}
<div class="cfg-input-wrap">
<input
type=${isSensitive ? "password" : inputType}
class="cfg-input"
placeholder=${placeholder}
<ui-input
.type=${isSensitive ? "password" : inputType}
.placeholder=${placeholder}
.value=${displayValue == null ? "" : String(displayValue)}
?disabled=${disabled}
@input=${(e: Event) => {
const raw = (e.target as HTMLInputElement).value;
if (inputType === "number") {
if (raw.trim() === "") {
onPatch(path, undefined);
return;
}
const parsed = Number(raw);
onPatch(path, Number.isNaN(parsed) ? raw : parsed);
return;
}
onPatch(path, raw);
}}
/>
@input=${(e: CustomEvent) => {
const raw = e.detail.value;
if (inputType === "number") {
if (raw.trim() === "") {
onPatch(path, undefined);
return;
}
const parsed = Number(raw);
onPatch(path, Number.isNaN(parsed) ? raw : parsed);
return;
}
onPatch(path, raw);
}}
></ui-input>
${schema.default !== undefined ? html`
<button
type="button"
<ui-button
variant="ghost"
size="icon"
class="cfg-input__reset"
title="Reset to default"
?disabled=${disabled}
@click=${() => onPatch(path, schema.default)}
></button>
></ui-button>
` : nothing}
</div>
</div>
@ -297,29 +293,16 @@ function renderNumberInput(params: {
${showLabel ? html`<label class="cfg-field__label">${label}</label>` : nothing}
${help ? html`<div class="cfg-field__help">${help}</div>` : nothing}
<div class="cfg-number">
<button
type="button"
class="cfg-number__btn"
?disabled=${disabled}
@click=${() => onPatch(path, numValue - 1)}
></button>
<input
<ui-input
type="number"
class="cfg-number__input"
.value=${displayValue == null ? "" : String(displayValue)}
?disabled=${disabled}
@input=${(e: Event) => {
const raw = (e.target as HTMLInputElement).value;
const parsed = raw === "" ? undefined : Number(raw);
onPatch(path, parsed);
}}
/>
<button
type="button"
class="cfg-number__btn"
?disabled=${disabled}
@click=${() => onPatch(path, numValue + 1)}
>+</button>
@input=${(e: CustomEvent) => {
const raw = e.detail.value;
const parsed = raw === "" ? undefined : Number(raw);
onPatch(path, parsed);
}}
></ui-input>
</div>
</div>
`;
@ -350,20 +333,18 @@ function renderSelect(params: {
<div class="cfg-field">
${showLabel ? html`<label class="cfg-field__label">${label}</label>` : nothing}
${help ? html`<div class="cfg-field__help">${help}</div>` : nothing}
<select
class="cfg-select"
<ui-select
.options=${[
{ label: "Select...", value: unset },
...options.map((opt, idx) => ({ label: String(opt), value: String(idx) }))
]}
?disabled=${disabled}
.value=${currentIndex >= 0 ? String(currentIndex) : unset}
@change=${(e: Event) => {
const val = (e.target as HTMLSelectElement).value;
onPatch(path, val === unset ? undefined : options[Number(val)]);
}}
>
<option value=${unset}>Select...</option>
${options.map((opt, idx) => html`
<option value=${String(idx)}>${String(opt)}</option>
`)}
</select>
@change=${(e: CustomEvent) => {
const val = e.detail.value;
onPatch(path, val === unset ? undefined : options[Number(val)]);
}}
></ui-select>
</div>
`;
}
@ -408,26 +389,26 @@ function renderObject(params: {
return html`
<div class="cfg-fields">
${sorted.map(([propKey, node]) =>
renderNode({
schema: node,
value: obj[propKey],
path: [...path, propKey],
hints,
unsupported,
disabled,
onPatch,
})
)}
renderNode({
schema: node,
value: obj[propKey],
path: [...path, propKey],
hints,
unsupported,
disabled,
onPatch,
})
)}
${allowExtra ? renderMapField({
schema: additional as JsonSchema,
value: obj,
path,
hints,
unsupported,
disabled,
reservedKeys: reserved,
onPatch,
}) : nothing}
schema: additional as JsonSchema,
value: obj,
path,
hints,
unsupported,
disabled,
reservedKeys: reserved,
onPatch,
}) : nothing}
</div>
`;
}
@ -437,31 +418,31 @@ function renderObject(params: {
<details class="cfg-object" open>
<summary class="cfg-object__header">
<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>
${help ? html`<div class="cfg-object__help">${help}</div>` : nothing}
<div class="cfg-object__content">
${sorted.map(([propKey, node]) =>
renderNode({
schema: node,
value: obj[propKey],
path: [...path, propKey],
hints,
unsupported,
disabled,
onPatch,
})
)}
renderNode({
schema: node,
value: obj[propKey],
path: [...path, propKey],
hints,
unsupported,
disabled,
onPatch,
})
)}
${allowExtra ? renderMapField({
schema: additional as JsonSchema,
value: obj,
path,
hints,
unsupported,
disabled,
reservedKeys: reserved,
onPatch,
}) : nothing}
schema: additional as JsonSchema,
value: obj,
path,
hints,
unsupported,
disabled,
reservedKeys: reserved,
onPatch,
}) : nothing}
</div>
</details>
`;
@ -499,19 +480,17 @@ function renderArray(params: {
<div class="cfg-array">
<div class="cfg-array__header">
${showLabel ? html`<span class="cfg-array__label">${label}</span>` : nothing}
<span class="cfg-array__count">${arr.length} item${arr.length !== 1 ? 's' : ''}</span>
<button
type="button"
class="cfg-array__add"
<ui-badge variant="secondary">${arr.length} item${arr.length !== 1 ? 's' : ''}</ui-badge>
<ui-button
variant="secondary"
size="sm"
icon="plus"
?disabled=${disabled}
@click=${() => {
const next = [...arr, defaultValue(itemsSchema)];
onPatch(path, next);
}}
>
<span class="cfg-array__add-icon">${icons.plus}</span>
Add
</button>
const next = [...arr, defaultValue(itemsSchema)];
onPatch(path, next);
}}
>Add</ui-button>
</div>
${help ? html`<div class="cfg-array__help">${help}</div>` : nothing}
@ -525,31 +504,30 @@ function renderArray(params: {
<div class="cfg-array__item">
<div class="cfg-array__item-header">
<span class="cfg-array__item-index">#${idx + 1}</span>
<button
type="button"
class="cfg-array__item-remove"
<ui-button
variant="ghost"
size="icon"
icon="trash"
title="Remove item"
?disabled=${disabled}
@click=${() => {
const next = [...arr];
next.splice(idx, 1);
onPatch(path, next);
}}
>
${icons.trash}
</button>
const next = [...arr];
next.splice(idx, 1);
onPatch(path, next);
}}
></ui-button>
</div>
<div class="cfg-array__item-content">
${renderNode({
schema: itemsSchema,
value: item,
path: [...path, idx],
hints,
unsupported,
disabled,
showLabel: false,
onPatch,
})}
schema: itemsSchema,
value: item,
path: [...path, idx],
hints,
unsupported,
disabled,
showLabel: false,
onPatch,
})}
</div>
</div>
`)}
@ -577,25 +555,23 @@ function renderMapField(params: {
<div class="cfg-map">
<div class="cfg-map__header">
<span class="cfg-map__label">Custom entries</span>
<button
type="button"
class="cfg-map__add"
<ui-button
variant="secondary"
size="sm"
icon="plus"
?disabled=${disabled}
@click=${() => {
const next = { ...(value ?? {}) };
let index = 1;
let key = `custom-${index}`;
while (key in next) {
index += 1;
key = `custom-${index}`;
}
next[key] = anySchema ? {} : defaultValue(schema);
onPatch(path, next);
}}
>
<span class="cfg-map__add-icon">${icons.plus}</span>
Add Entry
</button>
const next = { ...(value ?? {}) };
let index = 1;
let key = `custom-${index}`;
while (key in next) {
index += 1;
key = `custom-${index}`;
}
next[key] = anySchema ? {} : defaultValue(schema);
onPatch(path, next);
}}
>Add Entry</ui-button>
</div>
${entries.length === 0 ? html`
@ -603,79 +579,75 @@ function renderMapField(params: {
` : html`
<div class="cfg-map__items">
${entries.map(([key, entryValue]) => {
const valuePath = [...path, key];
const fallback = jsonValue(entryValue);
return html`
const valuePath = [...path, key];
const fallback = jsonValue(entryValue);
return html`
<div class="cfg-map__item">
<div class="cfg-map__item-key">
<input
type="text"
class="cfg-input cfg-input--sm"
<ui-input
placeholder="Key"
.value=${key}
?disabled=${disabled}
@change=${(e: Event) => {
const nextKey = (e.target as HTMLInputElement).value.trim();
if (!nextKey || nextKey === key) return;
const next = { ...(value ?? {}) };
if (nextKey in next) return;
next[nextKey] = next[key];
delete next[key];
onPatch(path, next);
}}
/>
@change=${(e: CustomEvent) => {
const nextKey = e.detail.value.trim();
if (!nextKey || nextKey === key) return;
const next = { ...(value ?? {}) };
if (nextKey in next) return;
next[nextKey] = next[key];
delete next[key];
onPatch(path, next);
}}
></ui-input>
</div>
<div class="cfg-map__item-value">
${anySchema
? html`
<textarea
class="cfg-textarea cfg-textarea--sm"
? html`
<ui-textarea
placeholder="JSON value"
rows="2"
.rows=${2}
.value=${fallback}
?disabled=${disabled}
@change=${(e: Event) => {
const target = e.target as HTMLTextAreaElement;
const raw = target.value.trim();
if (!raw) {
onPatch(valuePath, undefined);
return;
}
try {
onPatch(valuePath, JSON.parse(raw));
} catch {
target.value = fallback;
}
}}
></textarea>
@change=${(e: CustomEvent) => {
const raw = e.detail.value.trim();
if (!raw) {
onPatch(valuePath, undefined);
return;
}
try {
onPatch(valuePath, JSON.parse(raw));
} catch {
// target.value = fallback; // ui-textarea handles its own value
}
}}
></ui-textarea>
`
: renderNode({
schema,
value: entryValue,
path: valuePath,
hints,
unsupported,
disabled,
showLabel: false,
onPatch,
})}
: renderNode({
schema,
value: entryValue,
path: valuePath,
hints,
unsupported,
disabled,
showLabel: false,
onPatch,
})}
</div>
<button
<ui-button
type="button"
class="cfg-map__item-remove"
variant="ghost"
size="icon"
icon="trash"
title="Remove entry"
?disabled=${disabled}
@click=${() => {
const next = { ...(value ?? {}) };
delete next[key];
onPatch(path, next);
}}
>
${icons.trash}
</button>
const next = { ...(value ?? {}) };
delete next[key];
onPatch(path, next);
}}
></ui-button>
</div>
`;
})}
})}
</div>
`}
</div>

View File

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

View File

@ -64,10 +64,10 @@ export function renderCron(props: CronProps) {
<div class="stat-label">Enabled</div>
<div class="stat-value">
${props.status
? props.status.enabled
? "Yes"
: "No"
: "n/a"}
? props.status.enabled
? "Yes"
: "No"
: "n/a"}
</div>
</div>
<div class="stat">
@ -96,7 +96,7 @@ export function renderCron(props: CronProps) {
<input
.value=${props.form.name}
@input=${(e: Event) =>
props.onFormChange({ name: (e.target as HTMLInputElement).value })}
props.onFormChange({ name: (e.target as HTMLInputElement).value })}
/>
</label>
<label class="field">
@ -104,7 +104,7 @@ export function renderCron(props: CronProps) {
<input
.value=${props.form.description}
@input=${(e: Event) =>
props.onFormChange({ description: (e.target as HTMLInputElement).value })}
props.onFormChange({ description: (e.target as HTMLInputElement).value })}
/>
</label>
<label class="field">
@ -112,27 +112,26 @@ export function renderCron(props: CronProps) {
<input
.value=${props.form.agentId}
@input=${(e: Event) =>
props.onFormChange({ agentId: (e.target as HTMLInputElement).value })}
props.onFormChange({ agentId: (e.target as HTMLInputElement).value })}
placeholder="default"
/>
</label>
<label class="field checkbox">
<span>Enabled</span>
<input
type="checkbox"
<div style="display: flex; align-items: flex-end; height: 100%; padding-bottom: 10px;">
<ui-checkbox
label="Enabled"
.checked=${props.form.enabled}
@change=${(e: Event) =>
props.onFormChange({ enabled: (e.target as HTMLInputElement).checked })}
/>
</label>
@change=${(e: CustomEvent) =>
props.onFormChange({ enabled: e.detail.checked })}
></ui-checkbox>
</div>
<label class="field">
<span>Schedule</span>
<select
.value=${props.form.scheduleKind}
@change=${(e: Event) =>
props.onFormChange({
scheduleKind: (e.target as HTMLSelectElement).value as CronFormState["scheduleKind"],
})}
props.onFormChange({
scheduleKind: (e.target as HTMLSelectElement).value as CronFormState["scheduleKind"],
})}
>
<option value="every">Every</option>
<option value="at">At</option>
@ -147,9 +146,9 @@ export function renderCron(props: CronProps) {
<select
.value=${props.form.sessionTarget}
@change=${(e: Event) =>
props.onFormChange({
sessionTarget: (e.target as HTMLSelectElement).value as CronFormState["sessionTarget"],
})}
props.onFormChange({
sessionTarget: (e.target as HTMLSelectElement).value as CronFormState["sessionTarget"],
})}
>
<option value="main">Main</option>
<option value="isolated">Isolated</option>
@ -160,9 +159,9 @@ export function renderCron(props: CronProps) {
<select
.value=${props.form.wakeMode}
@change=${(e: Event) =>
props.onFormChange({
wakeMode: (e.target as HTMLSelectElement).value as CronFormState["wakeMode"],
})}
props.onFormChange({
wakeMode: (e.target as HTMLSelectElement).value as CronFormState["wakeMode"],
})}
>
<option value="next-heartbeat">Next heartbeat</option>
<option value="now">Now</option>
@ -173,9 +172,9 @@ export function renderCron(props: CronProps) {
<select
.value=${props.form.payloadKind}
@change=${(e: Event) =>
props.onFormChange({
payloadKind: (e.target as HTMLSelectElement).value as CronFormState["payloadKind"],
})}
props.onFormChange({
payloadKind: (e.target as HTMLSelectElement).value as CronFormState["payloadKind"],
})}
>
<option value="systemEvent">System event</option>
<option value="agentTurn">Agent turn</option>
@ -187,14 +186,14 @@ export function renderCron(props: CronProps) {
<textarea
.value=${props.form.payloadText}
@input=${(e: Event) =>
props.onFormChange({
payloadText: (e.target as HTMLTextAreaElement).value,
})}
props.onFormChange({
payloadText: (e.target as HTMLTextAreaElement).value,
})}
rows="4"
></textarea>
</label>
${props.form.payloadKind === "agentTurn"
? html`
? html`
<div class="form-grid" style="margin-top: 12px;">
<label class="field checkbox">
<span>Deliver</span>
@ -202,26 +201,26 @@ export function renderCron(props: CronProps) {
type="checkbox"
.checked=${props.form.deliver}
@change=${(e: Event) =>
props.onFormChange({
deliver: (e.target as HTMLInputElement).checked,
})}
props.onFormChange({
deliver: (e.target as HTMLInputElement).checked,
})}
/>
</label>
</label>
<label class="field">
<span>Channel</span>
<select
.value=${props.form.channel || "last"}
@change=${(e: Event) =>
props.onFormChange({
channel: (e.target as HTMLSelectElement).value as CronFormState["channel"],
})}
props.onFormChange({
channel: (e.target as HTMLSelectElement).value as CronFormState["channel"],
})}
>
${channelOptions.map(
(channel) =>
html`<option value=${channel}>
(channel) =>
html`<option value=${channel}>
${resolveChannelLabel(props, channel)}
</option>`,
)}
)}
</select>
</label>
<label class="field">
@ -229,7 +228,7 @@ export function renderCron(props: CronProps) {
<input
.value=${props.form.to}
@input=${(e: Event) =>
props.onFormChange({ to: (e.target as HTMLInputElement).value })}
props.onFormChange({ to: (e.target as HTMLInputElement).value })}
placeholder="+1555… or chat id"
/>
</label>
@ -238,28 +237,28 @@ export function renderCron(props: CronProps) {
<input
.value=${props.form.timeoutSeconds}
@input=${(e: Event) =>
props.onFormChange({
timeoutSeconds: (e.target as HTMLInputElement).value,
})}
props.onFormChange({
timeoutSeconds: (e.target as HTMLInputElement).value,
})}
/>
</label>
${props.form.sessionTarget === "isolated"
? html`
? html`
<label class="field">
<span>Post to main prefix</span>
<input
.value=${props.form.postToMainPrefix}
@input=${(e: Event) =>
props.onFormChange({
postToMainPrefix: (e.target as HTMLInputElement).value,
})}
props.onFormChange({
postToMainPrefix: (e.target as HTMLInputElement).value,
})}
/>
</label>
`
: nothing}
: nothing}
</div>
`
: nothing}
: nothing}
<div class="row" style="margin-top: 14px;">
<button class="btn primary" ?disabled=${props.busy} @click=${props.onAdd}>
${props.busy ? "Saving…" : "Add job"}
@ -272,8 +271,8 @@ export function renderCron(props: CronProps) {
<div class="card-title">Jobs</div>
<div class="card-sub">All scheduled jobs stored in the gateway.</div>
${props.jobs.length === 0
? html`<div class="muted" style="margin-top: 12px;">No jobs yet.</div>`
: html`
? html`<div class="muted" style="margin-top: 12px;">No jobs yet.</div>`
: html`
<div class="list" style="margin-top: 12px;">
${props.jobs.map((job) => renderJob(job, props))}
</div>
@ -284,14 +283,14 @@ export function renderCron(props: CronProps) {
<div class="card-title">Run history</div>
<div class="card-sub">Latest runs for ${props.runsJobId ?? "(select a job)"}.</div>
${props.runsJobId == null
? html`
? html`
<div class="muted" style="margin-top: 12px;">
Select a job to inspect run history.
</div>
`
: props.runs.length === 0
? html`<div class="muted" style="margin-top: 12px;">No runs yet.</div>`
: html`
: props.runs.length === 0
? html`<div class="muted" style="margin-top: 12px;">No runs yet.</div>`
: html`
<div class="list" style="margin-top: 12px;">
${props.runs.map((entry) => renderRun(entry))}
</div>
@ -310,9 +309,9 @@ function renderScheduleFields(props: CronProps) {
type="datetime-local"
.value=${form.scheduleAt}
@input=${(e: Event) =>
props.onFormChange({
scheduleAt: (e.target as HTMLInputElement).value,
})}
props.onFormChange({
scheduleAt: (e.target as HTMLInputElement).value,
})}
/>
</label>
`;
@ -325,9 +324,9 @@ function renderScheduleFields(props: CronProps) {
<input
.value=${form.everyAmount}
@input=${(e: Event) =>
props.onFormChange({
everyAmount: (e.target as HTMLInputElement).value,
})}
props.onFormChange({
everyAmount: (e.target as HTMLInputElement).value,
})}
/>
</label>
<label class="field">
@ -335,9 +334,9 @@ function renderScheduleFields(props: CronProps) {
<select
.value=${form.everyUnit}
@change=${(e: Event) =>
props.onFormChange({
everyUnit: (e.target as HTMLSelectElement).value as CronFormState["everyUnit"],
})}
props.onFormChange({
everyUnit: (e.target as HTMLSelectElement).value as CronFormState["everyUnit"],
})}
>
<option value="minutes">Minutes</option>
<option value="hours">Hours</option>
@ -354,7 +353,7 @@ function renderScheduleFields(props: CronProps) {
<input
.value=${form.cronExpr}
@input=${(e: Event) =>
props.onFormChange({ cronExpr: (e.target as HTMLInputElement).value })}
props.onFormChange({ cronExpr: (e.target as HTMLInputElement).value })}
/>
</label>
<label class="field">
@ -362,7 +361,7 @@ function renderScheduleFields(props: CronProps) {
<input
.value=${form.cronTz}
@input=${(e: Event) =>
props.onFormChange({ cronTz: (e.target as HTMLInputElement).value })}
props.onFormChange({ cronTz: (e.target as HTMLInputElement).value })}
/>
</label>
</div>
@ -392,9 +391,9 @@ function renderJob(job: CronJob, props: CronProps) {
class="btn"
?disabled=${props.busy}
@click=${(event: Event) => {
event.stopPropagation();
props.onToggle(job, !job.enabled);
}}
event.stopPropagation();
props.onToggle(job, !job.enabled);
}}
>
${job.enabled ? "Disable" : "Enable"}
</button>
@ -402,9 +401,9 @@ function renderJob(job: CronJob, props: CronProps) {
class="btn"
?disabled=${props.busy}
@click=${(event: Event) => {
event.stopPropagation();
props.onRun(job);
}}
event.stopPropagation();
props.onRun(job);
}}
>
Run
</button>
@ -412,9 +411,9 @@ function renderJob(job: CronJob, props: CronProps) {
class="btn"
?disabled=${props.busy}
@click=${(event: Event) => {
event.stopPropagation();
props.onLoadRuns(job.id);
}}
event.stopPropagation();
props.onLoadRuns(job.id);
}}
>
Runs
</button>
@ -422,9 +421,9 @@ function renderJob(job: CronJob, props: CronProps) {
class="btn danger"
?disabled=${props.busy}
@click=${(event: Event) => {
event.stopPropagation();
props.onRemove(job);
}}
event.stopPropagation();
props.onRemove(job);
}}
>
Remove
</button>

View File

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

View File

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

View File

@ -12,6 +12,12 @@ import type {
PairedDevice,
PendingDevice,
} from "../controllers/devices";
import "../components/button";
import "../components/badge";
import "../components/input";
import "../components/card";
import "../components/checkbox";
import "../components/select";
export type NodesProps = {
loading: boolean;
@ -57,22 +63,24 @@ export function renderNodes(props: NodesProps) {
${renderExecApprovals(approvalsState)}
${renderBindings(bindingState)}
${renderDevices(props)}
<section class="card">
<div class="row" style="justify-content: space-between;">
<div>
<div class="card-title">Nodes</div>
<div class="card-sub">Paired devices and live links.</div>
<ui-card>
<div slot="header">
<div class="row" style="justify-content: space-between; width: 100%;">
<div>
<div class="card-title">Nodes</div>
<div class="card-desc">Paired devices and live links.</div>
</div>
<ui-button ?disabled=${props.loading} @click=${props.onRefresh}>
${props.loading ? "Loading…" : "Refresh"}
</ui-button>
</div>
<button class="btn" ?disabled=${props.loading} @click=${props.onRefresh}>
${props.loading ? "Loading…" : "Refresh"}
</button>
</div>
<div class="list" style="margin-top: 16px;">
<div class="list">
${props.nodes.length === 0
? html`<div class="muted">No nodes found.</div>`
: props.nodes.map((n) => renderNode(n))}
? html`<div class="muted">No nodes found.</div>`
: props.nodes.map((n) => renderNode(n))}
</div>
</section>
</ui-card>
`;
}
@ -81,37 +89,39 @@ function renderDevices(props: NodesProps) {
const pending = Array.isArray(list.pending) ? list.pending : [];
const paired = Array.isArray(list.paired) ? list.paired : [];
return html`
<section class="card">
<div class="row" style="justify-content: space-between;">
<div>
<div class="card-title">Devices</div>
<div class="card-sub">Pairing requests + role tokens.</div>
<ui-card>
<div slot="header">
<div class="row" style="justify-content: space-between; width: 100%;">
<div>
<div class="card-title">Devices</div>
<div class="card-desc">Pairing requests + role tokens.</div>
</div>
<ui-button ?disabled=${props.devicesLoading} @click=${props.onDevicesRefresh}>
${props.devicesLoading ? "Loading…" : "Refresh"}
</ui-button>
</div>
<button class="btn" ?disabled=${props.devicesLoading} @click=${props.onDevicesRefresh}>
${props.devicesLoading ? "Loading…" : "Refresh"}
</button>
</div>
${props.devicesError
? html`<div class="callout danger" style="margin-top: 12px;">${props.devicesError}</div>`
: nothing}
<div class="list" style="margin-top: 16px;">
? html`<div class="callout danger" style="margin-bottom: 12px;">${props.devicesError}</div>`
: nothing}
<div class="list">
${pending.length > 0
? html`
? html`
<div class="muted" style="margin-bottom: 8px;">Pending</div>
${pending.map((req) => renderPendingDevice(req, props))}
`
: nothing}
: nothing}
${paired.length > 0
? html`
? html`
<div class="muted" style="margin-top: 12px; margin-bottom: 8px;">Paired</div>
${paired.map((device) => renderPairedDevice(device, props))}
`
: nothing}
: nothing}
${pending.length === 0 && paired.length === 0
? html`<div class="muted">No paired devices.</div>`
: nothing}
? html`<div class="muted">No paired devices.</div>`
: nothing}
</div>
</section>
</ui-card>
`;
}
@ -132,12 +142,12 @@ function renderPendingDevice(req: PendingDevice, props: NodesProps) {
</div>
<div class="list-meta">
<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
</button>
<button class="btn btn--sm" @click=${() => props.onDeviceReject(req.requestId)}>
</ui-button>
<ui-button size="sm" @click=${() => props.onDeviceReject(req.requestId)}>
Reject
</button>
</ui-button>
</div>
</div>
</div>
@ -157,8 +167,8 @@ function renderPairedDevice(device: PairedDevice, props: NodesProps) {
<div class="list-sub">${device.deviceId}${ip}</div>
<div class="muted" style="margin-top: 6px;">${roles} · ${scopes}</div>
${tokens.length === 0
? html`<div class="muted" style="margin-top: 6px;">Tokens: none</div>`
: html`
? html`<div class="muted" style="margin-top: 6px;">Tokens: none</div>`
: html`
<div class="muted" style="margin-top: 10px;">Tokens</div>
<div style="display: flex; flex-direction: column; gap: 8px; margin-top: 6px;">
${tokens.map((token) => renderTokenRow(device.deviceId, token, props))}
@ -177,21 +187,22 @@ function renderTokenRow(deviceId: string, token: DeviceTokenSummary, props: Node
<div class="row" style="justify-content: space-between; gap: 8px;">
<div class="list-sub">${token.role} · ${status} · ${scopes} · ${when}</div>
<div class="row" style="justify-content: flex-end; gap: 6px; flex-wrap: wrap;">
<button
class="btn btn--sm"
<ui-button
size="sm"
@click=${() => props.onDeviceRotate(deviceId, token.role, token.scopes)}
>
Rotate
</button>
</ui-button>
${token.revokedAtMs
? nothing
: html`
<button
class="btn btn--sm danger"
? nothing
: html`
<ui-button
size="sm"
variant="danger"
@click=${() => props.onDeviceRevoke(deviceId, token.role)}
>
Revoke
</button>
</ui-button>
`}
</div>
</div>
@ -399,11 +410,11 @@ function resolveExecApprovalsState(props: NodesProps): ExecApprovalsState {
const selectedAgent =
selectedScope !== EXEC_APPROVALS_DEFAULT_SCOPE
? ((form?.agents ?? {})[selectedScope] as Record<string, unknown> | undefined) ??
null
null
: null;
const allowlist = Array.isArray((selectedAgent as { allowlist?: unknown })?.allowlist)
? ((selectedAgent as { allowlist?: ExecApprovalsAllowlistEntry[] }).allowlist ??
[])
[])
: [];
return {
ready,
@ -433,80 +444,71 @@ function renderBindings(state: BindingState) {
const supportsBinding = state.nodes.length > 0;
const defaultValue = state.defaultBinding ?? "";
return html`
<section class="card">
<div class="row" style="justify-content: space-between; align-items: center;">
<ui-card>
<div slot="header" style="display: contents;">
<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>.
</div>
</div>
<button
class="btn"
<ui-button
variant="primary"
?disabled=${state.disabled || !state.configDirty}
@click=${state.onSave}
>
${state.configSaving ? "Saving…" : "Save"}
</button>
</ui-button>
</div>
${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.
</div>`
: nothing}
: nothing}
${!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>
<button class="btn" ?disabled=${state.configLoading} @click=${state.onLoadConfig}>
<ui-button ?disabled=${state.configLoading} @click=${state.onLoadConfig}>
${state.configLoading ? "Loading…" : "Load config"}
</button>
</ui-button>
</div>`
: html`
<div class="list" style="margin-top: 16px;">
: html`
<div class="list">
<div class="list-item">
<div class="list-main">
<div class="list-title">Default binding</div>
<div class="list-sub">Used when agents do not override a node binding.</div>
</div>
<div class="list-meta">
<label class="field">
<span>Node</span>
<select
?disabled=${state.disabled || !supportsBinding}
@change=${(event: Event) => {
const target = event.target as HTMLSelectElement;
const value = target.value.trim();
state.onBindDefault(value ? value : null);
}}
>
<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>
<ui-select
label="Node"
?disabled=${state.disabled || !supportsBinding}
.value=${defaultValue}
.options=${[
{ 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);
}}
></ui-select>
${!supportsBinding
? html`<div class="muted">No nodes with system.run available.</div>`
: nothing}
? html`<div class="muted" style="margin-top: 4px; font-size: 11px;">No nodes with system.run available.</div>`
: nothing}
</div>
</div>
${state.agents.length === 0
? html`<div class="muted">No agents found.</div>`
: state.agents.map((agent) =>
renderAgentBinding(agent, state),
)}
? html`<div class="muted">No agents found.</div>`
: state.agents.map((agent) =>
renderAgentBinding(agent, state),
)}
</div>
`}
</section>
</ui-card>
`;
}
@ -514,40 +516,40 @@ function renderExecApprovals(state: ExecApprovalsState) {
const ready = state.ready;
const targetReady = state.target !== "node" || Boolean(state.targetNodeId);
return html`
<section class="card">
<div class="row" style="justify-content: space-between; align-items: center;">
<ui-card>
<div slot="header" style="display: contents;">
<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>.
</div>
</div>
<button
class="btn"
<ui-button
variant="primary"
?disabled=${state.disabled || !state.dirty || !targetReady}
@click=${state.onSave}
>
${state.saving ? "Saving…" : "Save"}
</button>
</ui-button>
</div>
${renderExecApprovalsTarget(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 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"}
</button>
</ui-button>
</div>`
: html`
: html`
${renderExecApprovalsTabs(state)}
${renderExecApprovalsPolicy(state)}
${state.selectedScope === EXEC_APPROVALS_DEFAULT_SCOPE
? nothing
: renderExecApprovalsAllowlist(state)}
? nothing
: renderExecApprovalsAllowlist(state)}
`}
</section>
</ui-card>
`;
}
@ -555,7 +557,7 @@ function renderExecApprovalsTarget(state: ExecApprovalsState) {
const hasNodes = state.targetNodes.length > 0;
const nodeValue = state.targetNodeId ?? "";
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-main">
<div class="list-title">Target</div>
@ -564,56 +566,46 @@ function renderExecApprovalsTarget(state: ExecApprovalsState) {
</div>
</div>
<div class="list-meta">
<label class="field">
<span>Host</span>
<select
?disabled=${state.disabled}
@change=${(event: Event) => {
const target = event.target as HTMLSelectElement;
const value = target.value;
if (value === "node") {
const first = state.targetNodes[0]?.id ?? null;
state.onSelectTarget("node", nodeValue || first);
} else {
state.onSelectTarget("gateway", null);
}
}}
>
<option value="gateway" ?selected=${state.target === "gateway"}>Gateway</option>
<option value="node" ?selected=${state.target === "node"}>Node</option>
</select>
</label>
<ui-select
label="Host"
?disabled=${state.disabled}
.value=${state.target}
.options=${[
{ label: "Gateway", value: "gateway" },
{ label: "Node", value: "node" }
]}
@change=${(event: CustomEvent) => {
const value = event.detail.value;
if (value === "node") {
const first = state.targetNodes[0]?.id ?? null;
state.onSelectTarget("node", nodeValue || first);
} else {
state.onSelectTarget("gateway", null);
}
}}
></ui-select>
${state.target === "node"
? html`
<label class="field">
<span>Node</span>
<select
?disabled=${state.disabled || !hasNodes}
@change=${(event: Event) => {
const target = event.target as HTMLSelectElement;
const value = target.value.trim();
state.onSelectTarget("node", value ? value : null);
}}
>
<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>
? html`
<ui-select
label="Node"
?disabled=${state.disabled || !hasNodes}
.value=${nodeValue}
.options=${[
{ 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);
}}
></ui-select>
`
: nothing}
: nothing}
</div>
</div>
${state.target === "node" && !hasNodes
? html`<div class="muted">No nodes advertise exec approvals yet.</div>`
: nothing}
? html`<div class="muted">No nodes advertise exec approvals yet.</div>`
: nothing}
</div>
`;
}
@ -623,23 +615,25 @@ function renderExecApprovalsTabs(state: ExecApprovalsState) {
<div class="row" style="margin-top: 12px; gap: 8px; flex-wrap: wrap;">
<span class="label">Scope</span>
<div class="row" style="gap: 8px; flex-wrap: wrap;">
<button
class="btn btn--sm ${state.selectedScope === EXEC_APPROVALS_DEFAULT_SCOPE ? "active" : ""}"
<ui-button
size="sm"
variant=${state.selectedScope === EXEC_APPROVALS_DEFAULT_SCOPE ? "primary" : "secondary"}
@click=${() => state.onSelectScope(EXEC_APPROVALS_DEFAULT_SCOPE)}
>
Defaults
</button>
</ui-button>
${state.agents.map((agent) => {
const label = agent.name?.trim() ? `${agent.name} (${agent.id})` : agent.id;
return html`
<button
class="btn btn--sm ${state.selectedScope === agent.id ? "active" : ""}"
const label = agent.name?.trim() ? `${agent.name} (${agent.id})` : agent.id;
return html`
<ui-button
size="sm"
variant=${state.selectedScope === agent.id ? "primary" : "secondary"}
@click=${() => state.onSelectScope(agent.id)}
>
${label}
</button>
</ui-button>
`;
})}
})}
</div>
</div>
`;
@ -671,8 +665,8 @@ function renderExecApprovalsPolicy(state: ExecApprovalsState) {
<div class="list-title">Security</div>
<div class="list-sub">
${isDefaults
? "Default security mode."
: `Default: ${defaults.security}.`}
? "Default security mode."
: `Default: ${defaults.security}.`}
</div>
</div>
<div class="list-meta">
@ -681,29 +675,29 @@ function renderExecApprovalsPolicy(state: ExecApprovalsState) {
<select
?disabled=${state.disabled}
@change=${(event: Event) => {
const target = event.target as HTMLSelectElement;
const value = target.value;
if (!isDefaults && value === "__default__") {
state.onRemove([...basePath, "security"]);
} else {
state.onPatch([...basePath, "security"], value);
}
}}
const target = event.target as HTMLSelectElement;
const value = target.value;
if (!isDefaults && value === "__default__") {
state.onRemove([...basePath, "security"]);
} else {
state.onPatch([...basePath, "security"], value);
}
}}
>
${!isDefaults
? html`<option value="__default__" ?selected=${securityValue === "__default__"}>
? html`<option value="__default__" ?selected=${securityValue === "__default__"}>
Use default (${defaults.security})
</option>`
: nothing}
: nothing}
${SECURITY_OPTIONS.map(
(option) =>
html`<option
(option) =>
html`<option
value=${option.value}
?selected=${securityValue === option.value}
>
${option.label}
</option>`,
)}
)}
</select>
</label>
</div>
@ -722,29 +716,29 @@ function renderExecApprovalsPolicy(state: ExecApprovalsState) {
<select
?disabled=${state.disabled}
@change=${(event: Event) => {
const target = event.target as HTMLSelectElement;
const value = target.value;
if (!isDefaults && value === "__default__") {
state.onRemove([...basePath, "ask"]);
} else {
state.onPatch([...basePath, "ask"], value);
}
}}
const target = event.target as HTMLSelectElement;
const value = target.value;
if (!isDefaults && value === "__default__") {
state.onRemove([...basePath, "ask"]);
} else {
state.onPatch([...basePath, "ask"], value);
}
}}
>
${!isDefaults
? html`<option value="__default__" ?selected=${askValue === "__default__"}>
? html`<option value="__default__" ?selected=${askValue === "__default__"}>
Use default (${defaults.ask})
</option>`
: nothing}
: nothing}
${ASK_OPTIONS.map(
(option) =>
html`<option
(option) =>
html`<option
value=${option.value}
?selected=${askValue === option.value}
>
${option.label}
</option>`,
)}
)}
</select>
</label>
</div>
@ -755,8 +749,8 @@ function renderExecApprovalsPolicy(state: ExecApprovalsState) {
<div class="list-title">Ask fallback</div>
<div class="list-sub">
${isDefaults
? "Applied when the UI prompt is unavailable."
: `Default: ${defaults.askFallback}.`}
? "Applied when the UI prompt is unavailable."
: `Default: ${defaults.askFallback}.`}
</div>
</div>
<div class="list-meta">
@ -765,29 +759,29 @@ function renderExecApprovalsPolicy(state: ExecApprovalsState) {
<select
?disabled=${state.disabled}
@change=${(event: Event) => {
const target = event.target as HTMLSelectElement;
const value = target.value;
if (!isDefaults && value === "__default__") {
state.onRemove([...basePath, "askFallback"]);
} else {
state.onPatch([...basePath, "askFallback"], value);
}
}}
const target = event.target as HTMLSelectElement;
const value = target.value;
if (!isDefaults && value === "__default__") {
state.onRemove([...basePath, "askFallback"]);
} else {
state.onPatch([...basePath, "askFallback"], value);
}
}}
>
${!isDefaults
? html`<option value="__default__" ?selected=${askFallbackValue === "__default__"}>
? html`<option value="__default__" ?selected=${askFallbackValue === "__default__"}>
Use default (${defaults.askFallback})
</option>`
: nothing}
: nothing}
${SECURITY_OPTIONS.map(
(option) =>
html`<option
(option) =>
html`<option
value=${option.value}
?selected=${askFallbackValue === option.value}
>
${option.label}
</option>`,
)}
)}
</select>
</label>
</div>
@ -798,10 +792,10 @@ function renderExecApprovalsPolicy(state: ExecApprovalsState) {
<div class="list-title">Auto-allow skill CLIs</div>
<div class="list-sub">
${isDefaults
? "Allow skill executables listed by the Gateway."
: autoIsDefault
? `Using default (${defaults.autoAllowSkills ? "on" : "off"}).`
: `Override (${autoEffective ? "on" : "off"}).`}
? "Allow skill executables listed by the Gateway."
: autoIsDefault
? `Using default (${defaults.autoAllowSkills ? "on" : "off"}).`
: `Override (${autoEffective ? "on" : "off"}).`}
</div>
</div>
<div class="list-meta">
@ -812,20 +806,20 @@ function renderExecApprovalsPolicy(state: ExecApprovalsState) {
?disabled=${state.disabled}
.checked=${autoEffective}
@change=${(event: Event) => {
const target = event.target as HTMLInputElement;
state.onPatch([...basePath, "autoAllowSkills"], target.checked);
}}
const target = event.target as HTMLInputElement;
state.onPatch([...basePath, "autoAllowSkills"], target.checked);
}}
/>
</label>
${!isDefaults && !autoIsDefault
? html`<button
? html`<button
class="btn btn--sm"
?disabled=${state.disabled}
@click=${() => state.onRemove([...basePath, "autoAllowSkills"])}
>
Use default
</button>`
: nothing}
: nothing}
</div>
</div>
</div>
@ -845,19 +839,19 @@ function renderExecApprovalsAllowlist(state: ExecApprovalsState) {
class="btn btn--sm"
?disabled=${state.disabled}
@click=${() => {
const next = [...entries, { pattern: "" }];
state.onPatch(allowlistPath, next);
}}
const next = [...entries, { pattern: "" }];
state.onPatch(allowlistPath, next);
}}
>
Add pattern
</button>
</div>
<div class="list" style="margin-top: 12px;">
${entries.length === 0
? html`<div class="muted">No allowlist entries yet.</div>`
: entries.map((entry, index) =>
renderAllowlistEntry(state, entry, index),
)}
? html`<div class="muted">No allowlist entries yet.</div>`
: entries.map((entry, index) =>
renderAllowlistEntry(state, entry, index),
)}
</div>
`;
}
@ -890,24 +884,24 @@ function renderAllowlistEntry(
.value=${entry.pattern ?? ""}
?disabled=${state.disabled}
@input=${(event: Event) => {
const target = event.target as HTMLInputElement;
state.onPatch(
["agents", state.selectedScope, "allowlist", index, "pattern"],
target.value,
);
}}
const target = event.target as HTMLInputElement;
state.onPatch(
["agents", state.selectedScope, "allowlist", index, "pattern"],
target.value,
);
}}
/>
</label>
<button
class="btn btn--sm danger"
?disabled=${state.disabled}
@click=${() => {
if (state.allowlist.length <= 1) {
state.onRemove(["agents", state.selectedScope, "allowlist"]);
return;
}
state.onRemove(["agents", state.selectedScope, "allowlist", index]);
}}
if (state.allowlist.length <= 1) {
state.onRemove(["agents", state.selectedScope, "allowlist"]);
return;
}
state.onRemove(["agents", state.selectedScope, "allowlist", index]);
}}
>
Remove
</button>
@ -927,8 +921,8 @@ function renderAgentBinding(agent: BindingAgent, state: BindingState) {
<div class="list-sub">
${agent.isDefault ? "default agent" : "agent"} ·
${bindingValue === "__default__"
? `uses default (${state.defaultBinding ?? "any"})`
: `override: ${agent.binding}`}
? `uses default (${state.defaultBinding ?? "any"})`
: `override: ${agent.binding}`}
</div>
</div>
<div class="list-meta">
@ -937,23 +931,23 @@ function renderAgentBinding(agent: BindingAgent, state: BindingState) {
<select
?disabled=${state.disabled || !supportsBinding}
@change=${(event: Event) => {
const target = event.target as HTMLSelectElement;
const value = target.value.trim();
state.onBindAgent(agent.index, value === "__default__" ? null : value);
}}
const target = event.target as HTMLSelectElement;
const value = target.value.trim();
state.onBindAgent(agent.index, value === "__default__" ? null : value);
}}
>
<option value="__default__" ?selected=${bindingValue === "__default__"}>
Use default
</option>
${state.nodes.map(
(node) =>
html`<option
(node) =>
html`<option
value=${node.id}
?selected=${bindingValue === node.id}
>
${node.label}
</option>`,
)}
)}
</select>
</label>
</div>
@ -1078,8 +1072,8 @@ function renderNode(node: Record<string, unknown>) {
</span>
${caps.slice(0, 12).map((c) => html`<span class="chip">${String(c)}</span>`)}
${commands
.slice(0, 8)
.map((c) => html`<span class="chip">${String(c)}</span>`)}
.slice(0, 8)
.map((c) => html`<span class="chip">${String(c)}</span>`)}
</div>
</div>
</div>

View File

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

View File

@ -4,6 +4,9 @@ import { formatAgo } from "../format";
import { formatSessionTokens } from "../presenter";
import { pathForTab } from "../navigation";
import type { GatewaySessionRow, SessionsListResult } from "../types";
import "../components/button";
import "../components/input";
import "../components/checkbox";
export type SessionsProps = {
loading: boolean;
@ -79,71 +82,65 @@ export function renderSessions(props: SessionsProps) {
<div class="card-title">Sessions</div>
<div class="card-sub">Active session keys and per-session overrides.</div>
</div>
<button class="btn" ?disabled=${props.loading} @click=${props.onRefresh}>
<ui-button ?disabled=${props.loading} @click=${props.onRefresh}>
${props.loading ? "Loading…" : "Refresh"}
</button>
</ui-button>
</div>
<div class="filters" style="margin-top: 14px;">
<label class="field">
<span>Active within (minutes)</span>
<input
.value=${props.activeMinutes}
@input=${(e: Event) =>
props.onFiltersChange({
activeMinutes: (e.target as HTMLInputElement).value,
limit: props.limit,
includeGlobal: props.includeGlobal,
includeUnknown: props.includeUnknown,
})}
/>
</label>
<label class="field">
<span>Limit</span>
<input
.value=${props.limit}
@input=${(e: Event) =>
props.onFiltersChange({
activeMinutes: props.activeMinutes,
limit: (e.target as HTMLInputElement).value,
includeGlobal: props.includeGlobal,
includeUnknown: props.includeUnknown,
})}
/>
</label>
<label class="field checkbox">
<span>Include global</span>
<input
type="checkbox"
<div style="margin-top: 16px; display: flex; gap: 16px; align-items: center; flex-wrap: wrap;">
<ui-input
label="Active within (minutes)"
.value=${props.activeMinutes}
@input=${(e: CustomEvent) =>
props.onFiltersChange({
activeMinutes: e.detail.value,
limit: props.limit,
includeGlobal: props.includeGlobal,
includeUnknown: props.includeUnknown,
})}
style="width: 200px;"
></ui-input>
<ui-input
label="Limit"
.value=${props.limit}
@input=${(e: CustomEvent) =>
props.onFiltersChange({
activeMinutes: props.activeMinutes,
limit: e.detail.value,
includeGlobal: props.includeGlobal,
includeUnknown: props.includeUnknown,
})}
style="width: 120px;"
></ui-input>
<div style="display: flex; gap: 16px; align-items: center; margin-top: 19px;">
<ui-checkbox
label="Include global"
.checked=${props.includeGlobal}
@change=${(e: Event) =>
props.onFiltersChange({
activeMinutes: props.activeMinutes,
limit: props.limit,
includeGlobal: (e.target as HTMLInputElement).checked,
includeUnknown: props.includeUnknown,
})}
/>
</label>
<label class="field checkbox">
<span>Include unknown</span>
<input
type="checkbox"
@change=${(e: CustomEvent) =>
props.onFiltersChange({
activeMinutes: props.activeMinutes,
limit: props.limit,
includeGlobal: e.detail.checked,
includeUnknown: props.includeUnknown,
})}
></ui-checkbox>
<ui-checkbox
label="Include unknown"
.checked=${props.includeUnknown}
@change=${(e: Event) =>
props.onFiltersChange({
activeMinutes: props.activeMinutes,
limit: props.limit,
includeGlobal: props.includeGlobal,
includeUnknown: (e.target as HTMLInputElement).checked,
})}
/>
</label>
@change=${(e: CustomEvent) =>
props.onFiltersChange({
activeMinutes: props.activeMinutes,
limit: props.limit,
includeGlobal: props.includeGlobal,
includeUnknown: e.detail.checked,
})}
></ui-checkbox>
</div>
</div>
${props.error
? html`<div class="callout danger" style="margin-top: 12px;">${props.error}</div>`
: nothing}
? html`<div class="callout danger" style="margin-top: 12px;">${props.error}</div>`
: nothing}
<div class="muted" style="margin-top: 12px;">
${props.result ? `Store: ${props.result.path}` : ""}
@ -162,10 +159,10 @@ export function renderSessions(props: SessionsProps) {
<div>Actions</div>
</div>
${rows.length === 0
? html`<div class="muted">No sessions found.</div>`
: rows.map((row) =>
renderRow(row, props.basePath, props.onPatch, props.onDelete, props.loading),
)}
? html`<div class="muted">No sessions found.</div>`
: rows.map((row) =>
renderRow(row, props.basePath, props.onPatch, props.onDelete, props.loading),
)}
</div>
</section>
`;
@ -194,17 +191,17 @@ function renderRow(
return html`
<div class="table-row">
<div class="mono">${canLink
? html`<a href=${chatUrl} class="session-link">${displayName}</a>`
: displayName}</div>
? html`<a href=${chatUrl} class="session-link">${displayName}</a>`
: displayName}</div>
<div>
<input
.value=${row.label ?? ""}
?disabled=${disabled}
placeholder="(optional)"
@change=${(e: Event) => {
const value = (e.target as HTMLInputElement).value.trim();
onPatch(row.key, { label: value || null });
}}
const value = (e.target as HTMLInputElement).value.trim();
onPatch(row.key, { label: value || null });
}}
/>
</div>
<div>${row.kind}</div>
@ -215,15 +212,15 @@ function renderRow(
.value=${thinking}
?disabled=${disabled}
@change=${(e: Event) => {
const value = (e.target as HTMLSelectElement).value;
onPatch(row.key, {
thinkingLevel: resolveThinkLevelPatchValue(value, isBinaryThinking),
});
}}
const value = (e.target as HTMLSelectElement).value;
onPatch(row.key, {
thinkingLevel: resolveThinkLevelPatchValue(value, isBinaryThinking),
});
}}
>
${thinkLevels.map((level) =>
html`<option value=${level}>${level || "inherit"}</option>`,
)}
html`<option value=${level}>${level || "inherit"}</option>`,
)}
</select>
</div>
<div>
@ -231,13 +228,13 @@ function renderRow(
.value=${verbose}
?disabled=${disabled}
@change=${(e: Event) => {
const value = (e.target as HTMLSelectElement).value;
onPatch(row.key, { verboseLevel: value || null });
}}
const value = (e.target as HTMLSelectElement).value;
onPatch(row.key, { verboseLevel: value || null });
}}
>
${VERBOSE_LEVELS.map(
(level) => html`<option value=${level.value}>${level.label}</option>`,
)}
(level) => html`<option value=${level.value}>${level.label}</option>`,
)}
</select>
</div>
<div>
@ -245,19 +242,19 @@ function renderRow(
.value=${reasoning}
?disabled=${disabled}
@change=${(e: Event) => {
const value = (e.target as HTMLSelectElement).value;
onPatch(row.key, { reasoningLevel: value || null });
}}
const value = (e.target as HTMLSelectElement).value;
onPatch(row.key, { reasoningLevel: value || null });
}}
>
${REASONING_LEVELS.map((level) =>
html`<option value=${level}>${level || "inherit"}</option>`,
)}
html`<option value=${level}>${level || "inherit"}</option>`,
)}
</select>
</div>
<div>
<button class="btn danger" ?disabled=${disabled} @click=${() => onDelete(row.key)}>
<ui-button variant="danger" ?disabled=${disabled} @click=${() => onDelete(row.key)}>
Delete
</button>
</ui-button>
</div>
</div>
`;