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

6
.gitignore vendored
View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,134 @@
/* ===========================================
COLOR THEMES
Each theme has dark and light variants
=========================================== */
/* Default: Tangerine (already in base.css) */
:root[data-color-theme="tangerine"] {
/* Already defined in base.css */
}
/* Ocean Theme - Blue tones */
:root[data-color-theme="ocean"] {
--color-accent-primary: #0EA5E9;
--color-accent-primary-hover: #0284C7;
--color-accent-primary-active: #0369A1;
--accent: var(--color-accent-primary);
--accent-hover: var(--color-accent-primary-hover);
--accent-subtle: rgba(14, 165, 233, 0.15);
--accent-glow: rgba(14, 165, 233, 0.25);
--primary: var(--color-accent-primary);
--ring: var(--color-accent-primary);
}
:root[data-color-theme="ocean"][data-theme="light"] {
--color-accent-primary: #0284C7;
--color-accent-primary-hover: #0369A1;
--color-accent-primary-active: #075985;
--accent: var(--color-accent-primary);
--accent-hover: var(--color-accent-primary-hover);
--accent-subtle: rgba(2, 132, 199, 0.12);
--accent-glow: rgba(2, 132, 199, 0.15);
}
/* Forest Theme - Green tones */
:root[data-color-theme="forest"] {
--color-accent-primary: #10B981;
--color-accent-primary-hover: #059669;
--color-accent-primary-active: #047857;
--accent: var(--color-accent-primary);
--accent-hover: var(--color-accent-primary-hover);
--accent-subtle: rgba(16, 185, 129, 0.15);
--accent-glow: rgba(16, 185, 129, 0.25);
--primary: var(--color-accent-primary);
--ring: var(--color-accent-primary);
}
:root[data-color-theme="forest"][data-theme="light"] {
--color-accent-primary: #059669;
--color-accent-primary-hover: #047857;
--color-accent-primary-active: #065F46;
--accent: var(--color-accent-primary);
--accent-hover: var(--color-accent-primary-hover);
--accent-subtle: rgba(5, 150, 105, 0.12);
--accent-glow: rgba(5, 150, 105, 0.15);
}
/* Sunset Theme - Pink/Rose tones */
:root[data-color-theme="sunset"] {
--color-accent-primary: #F43F5E;
--color-accent-primary-hover: #E11D48;
--color-accent-primary-active: #BE123C;
--accent: var(--color-accent-primary);
--accent-hover: var(--color-accent-primary-hover);
--accent-subtle: rgba(244, 63, 94, 0.15);
--accent-glow: rgba(244, 63, 94, 0.25);
--primary: var(--color-accent-primary);
--ring: var(--color-accent-primary);
}
:root[data-color-theme="sunset"][data-theme="light"] {
--color-accent-primary: #E11D48;
--color-accent-primary-hover: #BE123C;
--color-accent-primary-active: #9F1239;
--accent: var(--color-accent-primary);
--accent-hover: var(--color-accent-primary-hover);
--accent-subtle: rgba(225, 29, 72, 0.12);
--accent-glow: rgba(225, 29, 72, 0.15);
}
/* Lavender Theme - Purple tones */
:root[data-color-theme="lavender"] {
--color-accent-primary: #A78BFA;
--color-accent-primary-hover: #8B5CF6;
--color-accent-primary-active: #7C3AED;
--accent: var(--color-accent-primary);
--accent-hover: var(--color-accent-primary-hover);
--accent-subtle: rgba(167, 139, 250, 0.15);
--accent-glow: rgba(167, 139, 250, 0.25);
--primary: var(--color-accent-primary);
--ring: var(--color-accent-primary);
}
:root[data-color-theme="lavender"][data-theme="light"] {
--color-accent-primary: #8B5CF6;
--color-accent-primary-hover: #7C3AED;
--color-accent-primary-active: #6D28D9;
--accent: var(--color-accent-primary);
--accent-hover: var(--color-accent-primary-hover);
--accent-subtle: rgba(139, 92, 246, 0.12);
--accent-glow: rgba(139, 92, 246, 0.15);
}
/* Slate Theme - Gray/Neutral tones */
:root[data-color-theme="slate"] {
--color-accent-primary: #64748B;
--color-accent-primary-hover: #475569;
--color-accent-primary-active: #334155;
--accent: var(--color-accent-primary);
--accent-hover: var(--color-accent-primary-hover);
--accent-subtle: rgba(100, 116, 139, 0.15);
--accent-glow: rgba(100, 116, 139, 0.25);
--primary: var(--color-accent-primary);
--ring: var(--color-accent-primary);
}
:root[data-color-theme="slate"][data-theme="light"] {
--color-accent-primary: #475569;
--color-accent-primary-hover: #334155;
--color-accent-primary-active: #1E293B;
--accent: var(--color-accent-primary);
--accent-hover: var(--color-accent-primary-hover);
--accent-subtle: rgba(71, 85, 105, 0.12);
--accent-glow: rgba(71, 85, 105, 0.15);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,110 @@
import { LitElement, html, css } from "lit";
import { customElement } from "lit/decorators.js";
@customElement("ui-card")
export class UiCard extends LitElement {
static styles = css`
:host {
display: block;
}
.card {
border: 1px solid var(--border, #3E3B38);
background: var(--card, #1A1816);
border-radius: var(--radius-lg, 12px);
box-shadow: var(--shadow-sm, 0 1px 2px rgba(0,0,0,0.1));
overflow: hidden;
transition: all 0.2s ease;
}
.card:hover {
border-color: var(--border-strong, #57534E);
box-shadow: var(--shadow-md, 0 4px 6px -1px rgba(0,0,0,0.1));
}
.card-header {
padding: 16px 20px;
border-bottom: 1px solid var(--border, #3E3B38);
display: flex;
align-items: center;
justify-content: space-between;
}
.card-header:not(:has(*)) {
display: none;
}
.card-title {
font-size: 16px;
font-weight: 600;
color: var(--foreground, #EDE9E6);
margin: 0;
}
.card-desc {
font-size: 13px;
color: var(--muted-foreground, #A8A29D);
margin-top: 4px;
}
.card-content {
padding: 20px;
}
.card-footer {
padding: 16px 20px;
border-top: 1px solid var(--border, #3E3B38);
background: var(--bg-muted, #1F1D1B);
display: flex;
align-items: center;
}
/* Light mode styles */
:host-context([data-theme="light"]) .card {
background: var(--card, #FFFFFF);
border-color: var(--border, #e4e4e7);
}
:host-context([data-theme="light"]) .card:hover {
border-color: var(--border-strong, #d4d4d8);
}
:host-context([data-theme="light"]) .card-header {
border-bottom-color: var(--border, #e4e4e7);
}
:host-context([data-theme="light"]) .card-title {
color: var(--foreground, #1A1410);
}
:host-context([data-theme="light"]) .card-desc {
color: var(--muted-foreground, #71717a);
}
:host-context([data-theme="light"]) .card-footer {
border-top-color: var(--border, #e4e4e7);
background: var(--bg-muted, #EFEEE9);
}
`;
render() {
return html`
<div class="card">
<div class="card-header">
<slot name="header"></slot>
</div>
<div class="card-content">
<slot></slot>
</div>
<slot name="footer"></slot>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ui-card": UiCard;
}
}

View File

@ -0,0 +1,106 @@
import { LitElement, html, css } from "lit";
import { customElement, property } from "lit/decorators.js";
import "./icon";
@customElement("ui-checkbox")
export class UiCheckbox extends LitElement {
@property({ type: Boolean }) checked = false;
@property({ type: Boolean }) disabled = false;
@property({ type: String }) label = "";
static styles = css`
:host {
display: inline-flex;
vertical-align: middle;
}
.wrapper {
display: inline-flex;
align-items: center;
gap: 10px;
cursor: pointer;
user-select: none;
}
.wrapper.disabled {
opacity: 0.5;
cursor: not-allowed;
}
.checkbox {
width: 18px;
height: 18px;
border: 1px solid var(--input-border, #3E3B38);
border-radius: var(--radius-sm, 4px);
background: var(--input-bg, #1A1816);
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
color: white;
}
.wrapper:hover .checkbox {
border-color: var(--accent, #FF9F40);
background: rgba(255, 159, 64, 0.1);
}
.wrapper.checked .checkbox {
background: var(--accent, #FF9F40);
border-color: var(--accent, #FF9F40);
}
/* Light mode */
:host-context([data-theme="light"]) .checkbox {
background: white;
}
:host-context([data-theme="light"]) .wrapper:hover .checkbox {
background: rgba(255, 159, 64, 0.1);
}
:host-context([data-theme="light"]) .wrapper.checked .checkbox {
background: var(--accent, #FF9F40);
}
.label {
font-size: 14px;
color: var(--foreground, #EDE9E6);
}
:host-context([data-theme="light"]) .label {
color: var(--foreground, #1A1410);
}
`;
toggle() {
if (this.disabled) return;
this.checked = !this.checked;
this.dispatchEvent(new CustomEvent("change", {
detail: { checked: this.checked },
bubbles: true,
composed: true
}));
}
render() {
return html`
<div
class="wrapper ${this.checked ? 'checked' : ''} ${this.disabled ? 'disabled' : ''}"
@click=${this.toggle}
>
<div class="checkbox">
${this.checked ? html`<ui-icon name="check" .size=${12} strokeWidth="3"></ui-icon>` : null}
</div>
${this.label ? html`<span class="label">${this.label}</span>` : null}
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ui-checkbox": UiCheckbox;
}
}

View File

@ -0,0 +1,200 @@
import { LitElement, html, css } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import "./icon";
export interface DropdownMenuItem {
label: string;
value: string;
variant?: "default" | "danger";
disabled?: boolean;
}
@customElement("ui-dropdown-menu")
export class UiDropdownMenu extends LitElement {
@property({ type: Array }) items: DropdownMenuItem[] = [];
@property({ type: Boolean }) disabled = false;
@state() private open = false;
static styles = css`
:host {
position: relative;
display: inline-block;
}
.trigger {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
border: 1px solid var(--border, #3E3B38);
background: var(--bg-elevated, #1F1D1B);
padding: 9px 12px;
border-radius: var(--radius-md, 8px);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
color: var(--text, #EDE9E6);
}
.trigger:hover:not(:disabled) {
background: var(--bg-hover, #2A2826);
border-color: var(--border-strong, #57534E);
transform: translateY(-1px);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.trigger:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.menu {
position: absolute;
top: calc(100% + 4px);
right: 0;
min-width: 160px;
background: var(--card, #1A1816);
border: 1px solid var(--border, #3E3B38);
border-radius: var(--radius-md, 8px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
padding: 4px;
z-index: 100;
opacity: 0;
transform: translateY(-4px);
pointer-events: none;
transition: all 0.15s ease;
}
.menu.open {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
.menu-item {
display: flex;
align-items: center;
padding: 8px 12px;
border-radius: var(--radius-sm, 6px);
font-size: 13px;
cursor: pointer;
transition: background 0.1s ease;
color: var(--text, #EDE9E6);
border: none;
background: transparent;
width: 100%;
text-align: left;
}
.menu-item:hover:not(:disabled) {
background: var(--bg-hover, #2A2826);
}
.menu-item:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.menu-item.danger {
color: var(--danger, #EF4444);
}
.menu-item.danger:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.1);
}
/* Light mode */
:host-context([data-theme="light"]) .trigger {
background: white;
color: var(--foreground);
}
:host-context([data-theme="light"]) .trigger:hover:not(:disabled) {
background: var(--bg-hover);
}
:host-context([data-theme="light"]) .menu {
background: white;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
:host-context([data-theme="light"]) .menu-item:hover:not(:disabled) {
background: var(--bg-hover);
}
`;
private handleClickOutside = (e: MouseEvent) => {
if (!this.contains(e.target as Node)) {
this.open = false;
}
};
connectedCallback() {
super.connectedCallback();
document.addEventListener("click", this.handleClickOutside);
}
disconnectedCallback() {
super.disconnectedCallback();
document.removeEventListener("click", this.handleClickOutside);
}
private toggleMenu(e: Event) {
e.stopPropagation();
if (!this.disabled) {
this.open = !this.open;
}
}
private handleItemClick(item: DropdownMenuItem, e: Event) {
e.stopPropagation();
if (!item.disabled) {
this.open = false;
this.dispatchEvent(
new CustomEvent("select", {
detail: { value: item.value },
bubbles: true,
composed: true,
})
);
}
}
render() {
return html`
<button
class="trigger"
@click=${this.toggleMenu}
?disabled=${this.disabled}
aria-haspopup="true"
aria-expanded=${this.open}
>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="1"/>
<circle cx="12" cy="5" r="1"/>
<circle cx="12" cy="19" r="1"/>
</svg>
</button>
<div class="menu ${this.open ? "open" : ""}">
${this.items.map(
(item) => html`
<button
class="menu-item ${item.variant === "danger" ? "danger" : ""}"
@click=${(e: Event) => this.handleItemClick(item, e)}
?disabled=${item.disabled}
>
${item.label}
</button>
`
)}
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ui-dropdown-menu": UiDropdownMenu;
}
}

View File

@ -0,0 +1,51 @@
import { LitElement, html, css, type TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
import { icons, type IconName } from "../icons";
@customElement("ui-icon")
export class UiIcon extends LitElement {
@property({ type: String }) name?: IconName;
@property({ type: Number }) size = 16;
@property({ type: String }) color = "currentColor";
static styles = css`
:host {
display: inline-flex;
align-items: center;
justify-content: center;
vertical-align: middle;
}
svg {
width: 100%;
height: 100%;
stroke: currentColor;
fill: none;
stroke-width: 1.5px;
stroke-linecap: round;
stroke-linejoin: round;
}
`;
render() {
const iconTemplate = this.name ? icons[this.name] : undefined;
if (!iconTemplate) return html``;
return html`
<style>
:host {
width: ${this.size}px;
height: ${this.size}px;
color: ${this.color};
}
</style>
${iconTemplate}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ui-icon": UiIcon;
}
}

View File

@ -0,0 +1,14 @@
export * from "./icon";
export * from "./button";
export * from "./badge";
export * from "./card";
export * from "./input";
export * from "./select";
export * from "./switch";
export * from "./tooltip";
export * from "./segmented-control";
export * from "./checkbox";
export * from "./resizable-divider";
export * from "./textarea";
export * from "./theme-selector";

View File

@ -0,0 +1,148 @@
import { LitElement, html, css } from "lit";
import { customElement, property, query } from "lit/decorators.js";
import { classMap } from "lit/directives/class-map.js";
import "./icon";
@customElement("ui-input")
export class UiInput extends LitElement {
@property({ type: String }) type = "text";
@property({ type: String }) placeholder = "";
@property({ type: String }) value = "";
@property({ type: String }) label = "";
@property({ type: String }) error = "";
@property({ type: Boolean }) disabled = false;
@property({ type: String }) icon?: string;
@query("input") inputElement!: HTMLInputElement;
static styles = css`
:host {
display: block;
width: 100%;
}
.input-wrapper {
display: flex;
flex-direction: column;
gap: 6px;
}
.label {
font-size: 13px;
font-weight: 500;
color: var(--muted-foreground, #A8A29D);
}
.input-container {
position: relative;
display: flex;
align-items: center;
}
input {
width: 100%;
height: 36px;
padding: 0 12px;
padding-left: var(--input-padding-left, 12px);
background: var(--input-bg, #1A1816);
border: 1px solid var(--input-border, #3E3B38);
border-radius: var(--radius-md, 6px);
color: var(--foreground, #EDE9E6);
font-family: inherit;
font-size: 14px;
outline: none;
transition: all 0.2s ease;
}
input:focus {
border-color: var(--accent, #FF9F40);
box-shadow: 0 0 0 2px var(--accent-subtle, rgba(255, 159, 64, 0.1));
}
input:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Light mode */
:host-context([data-theme="light"]) input {
background: white;
color: var(--foreground);
}
:host-context([data-theme="light"]) input:focus {
background: white;
}
.has-icon input {
padding-left: 36px;
}
.icon {
position: absolute;
left: 10px;
color: var(--muted-foreground, #A8A29D);
pointer-events: none;
display: flex;
align-items: center;
}
.error-msg {
font-size: 12px;
color: var(--danger, #EF4444);
}
.input-error input {
border-color: var(--danger, #EF4444);
}
`;
handleInput(e: Event) {
const target = e.target as HTMLInputElement;
this.value = target.value;
this.dispatchEvent(new CustomEvent("input", {
detail: { value: this.value },
bubbles: true,
composed: true
}));
}
handleChange(e: Event) {
const target = e.target as HTMLInputElement;
this.value = target.value;
this.dispatchEvent(new CustomEvent("change", {
detail: { value: this.value },
bubbles: true,
composed: true
}));
}
render() {
return html`
<div class="input-wrapper ${this.error ? 'input-error' : ''}">
${this.label ? html`<label class="label">${this.label}</label>` : null}
<div class="input-container ${this.icon ? 'has-icon' : ''}">
${this.icon ? html`<ui-icon class="icon" .name=${this.icon} .size=${16}></ui-icon>` : null}
<input
.type=${this.type}
.value=${this.value}
.placeholder=${this.placeholder}
?disabled=${this.disabled}
@input=${this.handleInput}
@change=${this.handleChange}
/>
</div>
${this.error ? html`<span class="error-msg">${this.error}</span>` : null}
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ui-input": UiInput;
}
}

View File

@ -41,6 +41,19 @@ export class ResizableDivider extends LitElement {
:host(.dragging) { :host(.dragging) {
background: var(--accent, #007bff); background: var(--accent, #007bff);
} }
/* Light mode styles */
:host-context([data-theme="light"]) {
background: var(--border, #e4e4e7);
}
:host-context([data-theme="light"]):hover {
background: var(--accent, #FF8C42);
}
:host-context([data-theme="light"]).dragging {
background: var(--accent, #FF8C42);
}
`; `;
render() { render() {

View File

@ -0,0 +1,98 @@
import { LitElement, html, css } from "lit";
import { customElement, property } from "lit/decorators.js";
export interface SegmentonedOption {
label: string;
value: string;
}
@customElement("ui-segmented-control")
export class UiSegmentedControl extends LitElement {
@property({ type: Array }) options: SegmentonedOption[] = [];
@property({ type: String }) value = "";
static styles = css`
:host {
display: inline-flex;
background: var(--input-bg, #2A2723);
padding: 3px; // slightly smaller padding for tighter look
border-radius: var(--radius-md, 8px);
border: 1px solid var(--border, #3E3B38);
}
.segment {
padding: 6px 14px;
font-size: 13px;
font-weight: 500;
color: var(--muted-foreground, #A8A29D);
border-radius: var(--radius-sm, 6px);
cursor: pointer;
transition: all 0.2s ease;
user-select: none;
text-align: center;
flex: 1;
}
.segment:hover {
color: var(--foreground, #EDE9E6);
background: rgba(255, 255, 255, 0.03);
}
.segment.active {
background: var(--card, #1A1816);
color: var(--foreground, #EDE9E6);
box-shadow: 0 1px 2px rgba(0,0,0,0.2);
}
/* Light mode styles */
:host-context([data-theme="light"]) {
background: var(--input-bg, #F5F4F2);
border-color: var(--border, #e4e4e7);
}
:host-context([data-theme="light"]) .segment {
color: var(--muted-foreground, #71717a);
}
:host-context([data-theme="light"]) .segment:hover {
color: var(--foreground, #1A1410);
background: rgba(0, 0, 0, 0.03);
}
:host-context([data-theme="light"]) .segment.active {
background: var(--card, #FFFFFF);
color: var(--foreground, #1A1410);
box-shadow: 0 1px 2px rgba(0,0,0,0.06);
}
`;
select(val: string) {
if (this.value === val) return;
this.value = val;
this.dispatchEvent(new CustomEvent("change", {
detail: { value: this.value },
bubbles: true,
composed: true
}));
}
render() {
return html`
${this.options.map(opt => html`
<div
class="segment ${opt.value === this.value ? 'active' : ''}"
@click=${() => this.select(opt.value)}
>
${opt.label}
</div>
`)}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ui-segmented-control": UiSegmentedControl;
}
}

View File

@ -0,0 +1,124 @@
import { LitElement, html, css } from "lit";
import { customElement, property } from "lit/decorators.js";
import "./icon";
export interface SelectOption {
label: string;
value: string;
}
@customElement("ui-select")
export class UiSelect extends LitElement {
@property({ type: String }) label = "";
@property({ type: String }) value = "";
@property({ type: Array }) options: SelectOption[] = [];
@property({ type: Boolean }) disabled = false;
static styles = css`
:host {
display: block;
width: 100%;
}
.wrapper {
display: flex;
flex-direction: column;
gap: 6px;
}
.label {
font-size: 13px;
font-weight: 500;
color: var(--muted-foreground, #A8A29D);
}
.select-container {
position: relative;
display: flex;
align-items: center;
}
select {
width: 100%;
height: 36px;
padding: 0 12px;
padding-right: 36px;
background: var(--input-bg, #1A1816);
border: 1px solid var(--input-border, #3E3B38);
border-radius: var(--radius-md, 6px);
color: var(--foreground, #EDE9E6);
font-family: inherit;
font-size: 14px;
outline: none;
appearance: none;
cursor: pointer;
transition: all 0.2s ease;
}
select:focus {
border-color: var(--accent, #FF9F40);
box-shadow: 0 0 0 2px var(--accent-subtle, rgba(255, 159, 64, 0.1));
}
select:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Light mode */
:host-context([data-theme="light"]) select {
background: white;
color: var(--foreground);
}
:host-context([data-theme="light"]) select:focus {
background: white;
}
.chevron {
position: absolute;
right: 12px;
pointer-events: none;
color: var(--muted-foreground, #A8A29D);
display: flex;
align-items: center;
}
`;
handleChange(e: Event) {
const target = e.target as HTMLSelectElement;
this.value = target.value;
this.dispatchEvent(new CustomEvent("change", {
detail: { value: this.value },
bubbles: true,
composed: true
}));
}
render() {
return html`
<div class="wrapper">
${this.label ? html`<label class="label">${this.label}</label>` : null}
<div class="select-container">
<select
.value=${this.value}
?disabled=${this.disabled}
@change=${this.handleChange}
>
${this.options.map(opt => html`
<option value=${opt.value} ?selected=${opt.value === this.value}>${opt.label}</option>
`)}
</select>
<ui-icon class="chevron" name="chevronDown" .size=${16}></ui-icon>
</div>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ui-select": UiSelect;
}
}

View File

@ -0,0 +1,100 @@
import { LitElement, html, css } from "lit";
import { customElement, property } from "lit/decorators.js";
@customElement("ui-switch")
export class UiSwitch extends LitElement {
@property({ type: Boolean }) checked = false;
@property({ type: Boolean }) disabled = false;
static styles = css`
:host {
display: inline-block;
vertical-align: middle;
}
input {
display: none;
}
.track {
width: 36px;
height: 20px;
background: var(--input-bg, #2A2723);
border: 1px solid var(--input-border, #3E3B38);
border-radius: 999px;
position: relative;
cursor: pointer;
transition: all 0.2s ease;
}
.track.checked {
background: var(--accent, #FF9F40);
border-color: var(--accent, #FF9F40);
}
.thumb {
width: 16px;
height: 16px;
background: #A8A29D;
border-radius: 50%;
position: absolute;
top: 1px;
left: 1px;
transition: transform 0.2s cubic-bezier(0.4, 0.0, 0.2, 1);
box-shadow: 0 1px 2px rgba(0,0,0,0.2);
}
.track.checked .thumb {
background: white;
transform: translateX(16px);
}
.track.disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Light mode styles */
:host-context([data-theme="light"]) .track {
background: var(--input-bg, #F5F4F2);
border-color: var(--input-border, #e4e4e7);
}
:host-context([data-theme="light"]) .track.checked {
background: var(--accent, #FF8C42);
border-color: var(--accent, #FF8C42);
}
:host-context([data-theme="light"]) .thumb {
background: #71717a;
}
`;
toggle() {
if (this.disabled) return;
this.checked = !this.checked;
this.dispatchEvent(new CustomEvent("change", {
detail: { checked: this.checked },
bubbles: true,
composed: true
}));
}
render() {
return html`
<div
class="track ${this.checked ? 'checked' : ''} ${this.disabled ? 'disabled' : ''}"
@click=${this.toggle}
>
<div class="thumb"></div>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ui-switch": UiSwitch;
}
}

View File

@ -0,0 +1,124 @@
import { LitElement, html, css } from "lit";
import { customElement, property, query } from "lit/decorators.js";
@customElement("ui-textarea")
export class UiTextarea extends LitElement {
@property({ type: String }) placeholder = "";
@property({ type: String }) value = "";
@property({ type: String }) label = "";
@property({ type: String }) error = "";
@property({ type: Boolean }) disabled = false;
@property({ type: Number }) rows = 4;
@query("textarea") textareaElement!: HTMLTextAreaElement;
static styles = css`
:host {
display: block;
width: 100%;
}
.textarea-wrapper {
display: flex;
flex-direction: column;
gap: 6px;
}
.label {
font-size: 13px;
font-weight: 500;
color: var(--muted-foreground, #A8A29D);
}
textarea {
width: 100%;
padding: 12px 14px;
background: var(--input-bg, #1A1816);
border: 1px solid var(--input-border, #3E3B38);
border-radius: var(--radius-md, 8px);
color: var(--foreground, #EDE9E6);
font-family: var(--font-mono, "JetBrains Mono", monospace);
font-size: 13px;
line-height: 1.55;
outline: none;
transition: all 0.2s ease;
resize: vertical;
min-height: 80px;
}
textarea:focus {
border-color: var(--accent, #FF9F40);
box-shadow: 0 0 0 2px var(--accent-subtle, rgba(255, 159, 64, 0.1));
}
textarea:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Light mode */
:host-context([data-theme="light"]) textarea {
background: white;
color: var(--foreground);
}
:host-context([data-theme="light"]) textarea:focus {
background: white;
}
.error-msg {
font-size: 12px;
color: var(--danger, #EF4444);
}
.textarea-error textarea {
border-color: var(--danger, #EF4444);
}
`;
handleInput(e: Event) {
const target = e.target as HTMLTextAreaElement;
this.value = target.value;
this.dispatchEvent(new CustomEvent("input", {
detail: { value: this.value },
bubbles: true,
composed: true
}));
}
handleChange(e: Event) {
const target = e.target as HTMLTextAreaElement;
this.value = target.value;
this.dispatchEvent(new CustomEvent("change", {
detail: { value: this.value },
bubbles: true,
composed: true
}));
}
render() {
return html`
<div class="textarea-wrapper ${this.error ? 'textarea-error' : ''}">
${this.label ? html`<label class="label">${this.label}</label>` : null}
<textarea
.value=${this.value}
.placeholder=${this.placeholder}
.rows=${this.rows}
?disabled=${this.disabled}
@input=${this.handleInput}
@change=${this.handleChange}
></textarea>
${this.error ? html`<span class="error-msg">${this.error}</span>` : null}
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ui-textarea": UiTextarea;
}
}

View File

@ -0,0 +1,213 @@
import { LitElement, html, css } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { COLOR_THEMES, type ColorTheme } from "../theme";
import "./icon";
@customElement("theme-selector")
export class ThemeSelector extends LitElement {
@property({ type: String }) colorTheme: ColorTheme = "tangerine";
@state() private isOpen = false;
static styles = css`
:host {
position: relative;
display: inline-block;
}
.trigger {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid var(--border);
border-radius: var(--radius-full);
background: var(--secondary);
color: var(--muted);
cursor: pointer;
transition: all 0.2s ease;
}
.trigger:hover {
color: var(--text);
background: var(--bg-hover);
border-color: var(--border-strong);
}
.dropdown {
position: absolute;
top: calc(100% + 8px);
right: 0;
min-width: 180px;
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
padding: 6px;
z-index: 100;
opacity: 0;
transform: translateY(-4px);
pointer-events: none;
transition: all 0.2s ease;
}
.dropdown.open {
opacity: 1;
transform: translateY(0);
pointer-events: all;
}
.dropdown-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border-radius: var(--radius-sm);
cursor: pointer;
transition: background 0.15s ease;
border: none;
background: transparent;
width: 100%;
text-align: left;
color: var(--text);
font-size: 13px;
font-weight: 500;
}
.dropdown-item:hover {
background: var(--bg-hover);
}
.dropdown-item.active {
background: var(--accent-subtle);
color: var(--accent);
}
.color-preview {
width: 16px;
height: 16px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
flex-shrink: 0;
}
/* Light mode */
:host-context([data-theme="light"]) .trigger {
background: white;
border-color: var(--border);
}
:host-context([data-theme="light"]) .trigger:hover {
background: var(--bg-hover);
}
:host-context([data-theme="light"]) .dropdown {
background: white;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
:host-context([data-theme="light"]) .dropdown-item:hover {
background: var(--bg-hover);
}
/* Mobile optimization */
@media (max-width: 600px) {
.trigger {
width: 26px;
height: 26px;
}
.dropdown {
min-width: 160px;
padding: 4px;
}
.dropdown-item {
padding: 6px 8px;
font-size: 12px;
}
.color-preview {
width: 14px;
height: 14px;
}
}
@media (max-width: 400px) {
.trigger {
width: 24px;
height: 24px;
}
.dropdown {
min-width: 140px;
}
.dropdown-item {
padding: 5px 7px;
font-size: 11px;
gap: 8px;
}
.color-preview {
width: 12px;
height: 12px;
}
}
`;
connectedCallback() {
super.connectedCallback();
document.addEventListener("click", this.handleOutsideClick);
}
disconnectedCallback() {
super.disconnectedCallback();
document.removeEventListener("click", this.handleOutsideClick);
}
private handleOutsideClick = (e: MouseEvent) => {
if (!this.contains(e.target as Node)) {
this.isOpen = false;
}
};
private toggleDropdown(e: Event) {
e.stopPropagation();
this.isOpen = !this.isOpen;
}
private selectTheme(theme: ColorTheme) {
this.dispatchEvent(new CustomEvent("change", {
detail: { colorTheme: theme },
bubbles: true,
composed: true
}));
this.isOpen = false;
}
render() {
return html`
<button class="trigger" @click=${this.toggleDropdown} title="Change color theme">
<ui-icon name="moreVertical" .size=${14}></ui-icon>
</button>
<div class="dropdown ${this.isOpen ? 'open' : ''}">
${Object.entries(COLOR_THEMES).map(([key, { name, preview }]) => html`
<button
class="dropdown-item ${key === this.colorTheme ? 'active' : ''}"
@click=${() => this.selectTheme(key as ColorTheme)}
>
<div class="color-preview" style="background-color: ${preview};"></div>
<span>${name}</span>
</button>
`)}
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"theme-selector": ThemeSelector;
}
}

View File

@ -0,0 +1,75 @@
import { LitElement, html, css } from "lit";
import { customElement, property } from "lit/decorators.js";
@customElement("ui-tooltip")
export class UiTooltip extends LitElement {
@property({ type: String }) content = "";
static styles = css`
:host {
position: relative;
display: inline-block;
}
.tooltip {
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%) translateY(8px) scale(0.95);
background: var(--tooltip-bg, #1A1816);
color: var(--tooltip-fg, #EDE9E6);
padding: 6px 10px;
border-radius: 6px;
font-size: 12px;
white-space: nowrap;
opacity: 0;
pointer-events: none;
transition: all 0.15s ease;
border: 1px solid var(--border, #3E3B38);
box-shadow: var(--shadow-md, 0 4px 6px -1px rgba(0,0,0,0.1));
z-index: 50;
}
/* Arrow */
.tooltip::after {
content: "";
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border-width: 4px;
border-style: solid;
border-color: var(--tooltip-bg, #1A1816) transparent transparent transparent;
}
:host(:hover) .tooltip {
opacity: 1;
transform: translateX(-50%) translateY(-8px) scale(1);
}
/* Light mode styles */
:host-context([data-theme="light"]) .tooltip {
background: var(--tooltip-bg, #FFFFFF);
color: var(--tooltip-fg, #1A1410);
border-color: var(--border, #e4e4e7);
}
:host-context([data-theme="light"]) .tooltip::after {
border-color: var(--tooltip-bg, #FFFFFF) transparent transparent transparent;
}
`;
render() {
return html`
<slot></slot>
<div class="tooltip">${this.content}</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ui-tooltip": UiTooltip;
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,9 @@
export type ThemeMode = "system" | "light" | "dark"; export type ThemeMode = "system" | "light" | "dark";
export type ResolvedTheme = "light" | "dark"; export type ResolvedTheme = "light" | "dark";
export type ColorTheme = "tangerine" | "ocean" | "forest" | "sunset" | "lavender" | "slate";
const STORAGE_KEY = "clawdbot-theme";
const COLOR_THEME_KEY = "clawdbot-color-theme";
export function getSystemTheme(): ResolvedTheme { export function getSystemTheme(): ResolvedTheme {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") { if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
@ -14,3 +18,107 @@ export function resolveTheme(mode: ThemeMode): ResolvedTheme {
if (mode === "system") return getSystemTheme(); if (mode === "system") return getSystemTheme();
return mode; return mode;
} }
export function getSavedTheme(): ThemeMode {
if (typeof window === "undefined" || typeof localStorage === "undefined") {
return "system";
}
const saved = localStorage.getItem(STORAGE_KEY);
if (saved === "light" || saved === "dark" || saved === "system") {
return saved;
}
return "system";
}
export function saveTheme(mode: ThemeMode): void {
if (typeof window === "undefined" || typeof localStorage === "undefined") {
return;
}
localStorage.setItem(STORAGE_KEY, mode);
}
export function getSavedColorTheme(): ColorTheme {
if (typeof window === "undefined" || typeof localStorage === "undefined") {
return "tangerine";
}
const saved = localStorage.getItem(COLOR_THEME_KEY);
if (saved === "tangerine" || saved === "ocean" || saved === "forest" ||
saved === "sunset" || saved === "lavender" || saved === "slate") {
return saved;
}
return "tangerine";
}
export function saveColorTheme(colorTheme: ColorTheme): void {
if (typeof window === "undefined" || typeof localStorage === "undefined") {
return;
}
localStorage.setItem(COLOR_THEME_KEY, colorTheme);
}
export function applyColorTheme(colorTheme: ColorTheme): void {
if (typeof document === "undefined") return;
const root = document.documentElement;
root.setAttribute("data-color-theme", colorTheme);
}
export function applyTheme(mode: ThemeMode): void {
if (typeof document === "undefined") return;
const root = document.documentElement;
const resolved = resolveTheme(mode);
if (mode === "system") {
root.removeAttribute("data-theme");
} else {
root.setAttribute("data-theme", mode);
}
// Update meta theme-color for mobile browsers
updateMetaThemeColor(resolved);
}
function updateMetaThemeColor(theme: ResolvedTheme): void {
if (typeof document === "undefined") return;
let metaThemeColor = document.querySelector('meta[name="theme-color"]');
if (!metaThemeColor) {
metaThemeColor = document.createElement("meta");
metaThemeColor.setAttribute("name", "theme-color");
document.head.appendChild(metaThemeColor);
}
// Muted backgrounds from design system
const color = theme === "light" ? "#FAFAF9" : "#1A1816";
metaThemeColor.setAttribute("content", color);
}
export function initTheme(): ThemeMode {
const saved = getSavedTheme();
const colorTheme = getSavedColorTheme();
applyTheme(saved);
applyColorTheme(colorTheme);
// Watch for system theme changes
if (typeof window !== "undefined" && typeof window.matchMedia === "function") {
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
mediaQuery.addEventListener("change", () => {
const current = getSavedTheme();
if (current === "system") {
applyTheme("system");
}
});
}
return saved;
}
export const COLOR_THEMES: Record<ColorTheme, { name: string; preview: string }> = {
tangerine: { name: "Tangerine", preview: "#FF9F40" },
ocean: { name: "Ocean", preview: "#0EA5E9" },
forest: { name: "Forest", preview: "#10B981" },
sunset: { name: "Sunset", preview: "#F43F5E" },
lavender: { name: "Lavender", preview: "#A78BFA" },
slate: { name: "Slate", preview: "#64748B" },
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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