From 716546824ff36cf3b3d1c54f6d0eecf5c1f9e565 Mon Sep 17 00:00:00 2001 From: Maude Bot Date: Tue, 20 Jan 2026 10:56:44 -0500 Subject: [PATCH 1/5] feat(ui): improve config page with collapsible sections - Group config settings into logical sections (Core, Agents, Communication, etc.) - Add collapsible accordion UI for each section group - Add icons and labels for each config category - Improve mobile responsiveness with better button layout - Style improvements for nested fieldsets and arrays --- ui/src/styles/components.css | 250 ++++++++++++++++++++++++++ ui/src/ui/views/config-form.render.ts | 128 ++++++++++--- ui/src/ui/views/config.ts | 35 ++-- 3 files changed, 375 insertions(+), 38 deletions(-) diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index 435fd59bc..18055eadc 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -1274,3 +1274,253 @@ flex-wrap: wrap; gap: 10px; } + +/* Config Form Sections */ +.config-form--sectioned { + display: grid; + gap: 12px; +} + +.config-section { + border: 1px solid var(--border); + border-radius: 14px; + background: linear-gradient(160deg, rgba(255, 255, 255, 0.03), transparent 70%), + rgba(0, 0, 0, 0.15); + overflow: hidden; +} + +:root[data-theme="light"] .config-section { + background: linear-gradient(160deg, rgba(255, 255, 255, 0.8), rgba(255, 255, 255, 0.5) 70%); +} + +.config-section__header { + display: flex; + align-items: center; + gap: 12px; + padding: 14px 16px; + cursor: pointer; + user-select: none; + border-bottom: 1px solid transparent; + transition: background 150ms ease, border-color 150ms ease; +} + +.config-section__header:hover { + background: rgba(255, 255, 255, 0.04); +} + +:root[data-theme="light"] .config-section__header:hover { + background: rgba(0, 0, 0, 0.03); +} + +.config-section[open] .config-section__header { + border-bottom-color: var(--border); + background: rgba(255, 255, 255, 0.02); +} + +:root[data-theme="light"] .config-section[open] .config-section__header { + background: rgba(0, 0, 0, 0.02); +} + +.config-section__title { + font-family: var(--font-display); + font-size: 13px; + font-weight: 600; + letter-spacing: 0.5px; + text-transform: uppercase; + flex: 1; +} + +.config-section__count { + font-size: 11px; + color: var(--muted); + padding: 3px 8px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.06); + border: 1px solid var(--border); +} + +:root[data-theme="light"] .config-section__count { + background: rgba(0, 0, 0, 0.05); +} + +.config-section__chevron { + width: 18px; + height: 18px; + color: var(--muted); + transition: transform 200ms ease; + flex-shrink: 0; +} + +.config-section[open] .config-section__chevron { + transform: rotate(180deg); +} + +.config-section__content { + padding: 12px 16px 16px; + display: grid; + gap: 16px; +} + +.config-field-group { + border: 1px solid var(--border); + border-radius: 12px; + background: rgba(0, 0, 0, 0.12); + overflow: hidden; +} + +:root[data-theme="light"] .config-field-group { + background: rgba(255, 255, 255, 0.6); +} + +.config-field-group__header { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 14px; + background: rgba(255, 255, 255, 0.03); + border-bottom: 1px solid var(--border); +} + +:root[data-theme="light"] .config-field-group__header { + background: rgba(0, 0, 0, 0.03); +} + +.config-field-group__icon { + font-size: 16px; + line-height: 1; +} + +.config-field-group__label { + font-weight: 600; + font-size: 13px; + letter-spacing: 0.3px; +} + +.config-field-group__content { + padding: 14px; +} + +.config-field-group__content .fieldset { + border: none; + padding: 0; + margin: 0; +} + +.config-field-group__content .fieldset > .legend { + display: none; +} + +/* Config form field improvements */ +.config-form .fieldset { + border: 1px solid var(--border); + border-radius: 12px; + padding: 12px; + margin-top: 8px; + background: rgba(0, 0, 0, 0.08); +} + +:root[data-theme="light"] .config-form .fieldset { + background: rgba(255, 255, 255, 0.5); +} + +.config-form .fieldset .legend { + font-weight: 600; + font-size: 12px; + letter-spacing: 0.3px; + color: var(--text); + margin-bottom: 10px; + padding-bottom: 8px; + border-bottom: 1px dashed var(--border); +} + +.config-form .fieldset .fieldset { + margin-top: 12px; +} + +.config-form .array { + display: grid; + gap: 8px; +} + +.config-form .array-item { + display: flex; + gap: 10px; + align-items: flex-start; + padding: 10px; + border: 1px solid var(--border); + border-radius: 10px; + background: rgba(0, 0, 0, 0.1); +} + +:root[data-theme="light"] .config-form .array-item { + background: rgba(255, 255, 255, 0.6); +} + +/* Mobile responsiveness */ +@media (max-width: 640px) { + .config-section__header { + padding: 12px 14px; + } + + .config-section__content { + padding: 10px 12px 14px; + } + + .config-field-group__content { + padding: 12px; + } + + .config-form .array-item { + flex-direction: column; + } + + .config-form .array-item > div:first-child { + width: 100%; + } + + .config-form .array-item .btn { + align-self: flex-end; + } +} + +/* Config Header Layout */ +.config-header { + display: flex; + flex-direction: column; + gap: 12px; +} + +.config-header__top { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} + +.config-header__actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +@media (max-width: 640px) { + .config-header__top { + flex-direction: column; + align-items: stretch; + } + + .config-header__top .row { + justify-content: space-between; + } + + .config-header__actions { + justify-content: flex-end; + } + + .config-header__actions .btn { + flex: 1; + min-width: 70px; + text-align: center; + } +} diff --git a/ui/src/ui/views/config-form.render.ts b/ui/src/ui/views/config-form.render.ts index c21c276fc..bcb7e53bf 100644 --- a/ui/src/ui/views/config-form.render.ts +++ b/ui/src/ui/views/config-form.render.ts @@ -1,4 +1,4 @@ -import { html } from "lit"; +import { html, nothing } from "lit"; import type { ConfigUiHints } from "../types"; import { hintForPath, schemaType, type JsonSchema } from "./config-form.shared"; import { renderNode } from "./config-form.node"; @@ -12,6 +12,43 @@ export type ConfigFormProps = { onPatch: (path: Array, value: unknown) => void; }; +// Define logical section groupings +const SECTION_CONFIG: Record = { + // Core + env: { label: "Environment", icon: "🔧", order: 0 }, + update: { label: "Updates", icon: "📦", order: 1 }, + + // Identity & Agents + agents: { label: "Agents", icon: "🤖", order: 10 }, + auth: { label: "Authentication", icon: "🔐", order: 11 }, + + // Communication + channels: { label: "Channels", icon: "💬", order: 20 }, + messages: { label: "Messages", icon: "📨", order: 21 }, + + // Automation + commands: { label: "Commands", icon: "⌨️", order: 30 }, + hooks: { label: "Hooks", icon: "🪝", order: 31 }, + skills: { label: "Skills", icon: "✨", order: 32 }, + + // Tools & Gateway + tools: { label: "Tools", icon: "🛠️", order: 40 }, + gateway: { label: "Gateway", icon: "🌐", order: 41 }, + + // System + wizard: { label: "Setup Wizard", icon: "🧙", order: 50 }, +}; + +// Logical groupings for the accordion layout +const SECTION_GROUPS: Array<{ title: string; keys: string[] }> = [ + { title: "Core Settings", keys: ["env", "update"] }, + { title: "Identity & Agents", keys: ["agents", "auth"] }, + { title: "Communication", keys: ["channels", "messages"] }, + { title: "Automation", keys: ["commands", "hooks", "skills"] }, + { title: "Tools & Gateway", keys: ["tools", "gateway"] }, + { title: "System", keys: ["wizard"] }, +]; + export function renderConfigForm(props: ConfigFormProps) { if (!props.schema) { return html`
Schema unavailable.
`; @@ -22,28 +59,77 @@ export function renderConfigForm(props: ConfigFormProps) { return html`
Unsupported schema. Use Raw.
`; } const unsupported = new Set(props.unsupportedPaths ?? []); - const entries = Object.entries(schema.properties); - const sorted = entries.sort((a, b) => { - const orderA = hintForPath([a[0]], props.uiHints)?.order ?? 0; - const orderB = hintForPath([b[0]], props.uiHints)?.order ?? 0; - if (orderA !== orderB) return orderA - orderB; - return a[0].localeCompare(b[0]); - }); + const properties = schema.properties; + const allKeys = new Set(Object.keys(properties)); + + // Collect any keys not in our defined groups + const groupedKeys = new Set(SECTION_GROUPS.flatMap(g => g.keys)); + const ungroupedKeys = [...allKeys].filter(k => !groupedKeys.has(k)); + + // Build the groups with their entries + const groups = SECTION_GROUPS.map(group => { + const entries = group.keys + .filter(key => allKeys.has(key)) + .map(key => ({ key, node: properties[key] })); + return { ...group, entries }; + }).filter(group => group.entries.length > 0); + + // Add ungrouped keys as "Other" if any exist + if (ungroupedKeys.length > 0) { + const sortedUngrouped = ungroupedKeys.sort((a, b) => { + const orderA = hintForPath([a], props.uiHints)?.order ?? 100; + const orderB = hintForPath([b], props.uiHints)?.order ?? 100; + if (orderA !== orderB) return orderA - orderB; + return a.localeCompare(b); + }); + groups.push({ + title: "Other", + keys: sortedUngrouped, + entries: sortedUngrouped.map(key => ({ key, node: properties[key] })), + }); + } return html` -
- ${sorted.map(([key, node]) => - renderNode({ - schema: node, - value: (value as Record)[key], - path: [key], - hints: props.uiHints, - unsupported, - disabled: props.disabled ?? false, - onPatch: props.onPatch, - }), - )} +
+ ${groups.map((group, groupIndex) => html` +
+ + ${group.title} + ${group.entries.length} ${group.entries.length === 1 ? 'setting' : 'settings'} + + + + +
+ ${group.entries.map(({ key, node }) => { + const sectionInfo = SECTION_CONFIG[key]; + const icon = sectionInfo?.icon ?? "📄"; + const label = sectionInfo?.label ?? key; + + return html` +
+
+ ${icon} + ${label} +
+
+ ${renderNode({ + schema: node, + value: (value as Record)[key], + path: [key], + hints: props.uiHints, + unsupported, + disabled: props.disabled ?? false, + showLabel: false, + onPatch: props.onPatch, + })} +
+
+ `; + })} +
+
+ `)}
`; } - diff --git a/ui/src/ui/views/config.ts b/ui/src/ui/views/config.ts index 0930e9920..a87c01b91 100644 --- a/ui/src/ui/views/config.ts +++ b/ui/src/ui/views/config.ts @@ -47,58 +47,59 @@ export function renderConfig(props: ConfigProps) { return html`
-
-
-
Config
- ${validity} -
-
+
+
+
+
Config
+ ${validity} +
-
+
+
-
+
Writes to ~/.clawdbot/clawdbot.json. Apply & - Update restart the gateway and will ping the last active session when it - comes back. + Update restart the gateway.
From 929d50b7d155c8ee96be91945beaa02a666a90b8 Mon Sep 17 00:00:00 2001 From: Maude Bot Date: Tue, 20 Jan 2026 11:28:41 -0500 Subject: [PATCH 2/5] feat(ui): complete config page overhaul with sidebar nav, search, toggles, and diff view Major redesign of the config page: Layout: - Sidebar navigation with section list - Search input to filter settings - Section cards with icons and descriptions - Responsive design for mobile (stacked layout) Fields: - New toggle switches for booleans (replaces checkboxes) - Improved field-row layout with label, help text, and control - Better fieldset and array styling Features: - Diff view showing pending changes before save - Original value tracking for comparison - Section filtering via sidebar nav - Search across setting names, descriptions, and nested properties Styling: - Dedicated config.css with all new styles - Dark and light theme support - Smooth animations and transitions - Mobile-first responsive breakpoints --- ui/src/styles.css | 1 + ui/src/styles/config.css | 708 ++++++++++++++++++++++++++ ui/src/ui/app-render.ts | 5 + ui/src/ui/app.ts | 3 + ui/src/ui/controllers/config.ts | 4 + ui/src/ui/views/config-form.node.ts | 36 +- ui/src/ui/views/config-form.render.ts | 260 ++++++---- ui/src/ui/views/config.ts | 318 +++++++++--- 8 files changed, 1145 insertions(+), 190 deletions(-) create mode 100644 ui/src/styles/config.css diff --git a/ui/src/styles.css b/ui/src/styles.css index f3740b0c0..16b327f3a 100644 --- a/ui/src/styles.css +++ b/ui/src/styles.css @@ -2,3 +2,4 @@ @import "./styles/layout.css"; @import "./styles/layout.mobile.css"; @import "./styles/components.css"; +@import "./styles/config.css"; diff --git a/ui/src/styles/config.css b/ui/src/styles/config.css new file mode 100644 index 000000000..aff3e88b4 --- /dev/null +++ b/ui/src/styles/config.css @@ -0,0 +1,708 @@ +/* =========================================== + Config Page - Modern Layout + =========================================== */ + +/* Layout Container */ +.config-layout { + display: grid; + grid-template-columns: 260px minmax(0, 1fr); + gap: 0; + min-height: calc(100vh - 140px); + margin: -16px; + border-radius: 16px; + overflow: hidden; + border: 1px solid var(--border); + background: var(--panel); +} + +/* Sidebar */ +.config-sidebar { + display: flex; + flex-direction: column; + background: linear-gradient(180deg, rgba(0, 0, 0, 0.15), rgba(0, 0, 0, 0.25)); + border-right: 1px solid var(--border); +} + +:root[data-theme="light"] .config-sidebar { + background: linear-gradient(180deg, rgba(255, 255, 255, 0.8), rgba(255, 255, 255, 0.6)); +} + +.config-sidebar__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px; + border-bottom: 1px solid var(--border); +} + +.config-sidebar__title { + font-family: var(--font-display); + font-size: 14px; + font-weight: 600; + letter-spacing: 0.5px; + text-transform: uppercase; +} + +.config-sidebar__footer { + margin-top: auto; + padding: 12px; + border-top: 1px solid var(--border); +} + +/* Search */ +.config-search { + position: relative; + padding: 12px; +} + +.config-search__icon { + position: absolute; + left: 22px; + top: 50%; + transform: translateY(-50%); + width: 16px; + height: 16px; + color: var(--muted); + pointer-events: none; +} + +.config-search__input { + width: 100%; + padding: 10px 12px 10px 38px; + border: 1px solid var(--border); + border-radius: 10px; + background: rgba(0, 0, 0, 0.2); + font-size: 13px; + outline: none; + transition: border-color 150ms ease, box-shadow 150ms ease; +} + +.config-search__input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--focus); +} + +:root[data-theme="light"] .config-search__input { + background: rgba(255, 255, 255, 0.9); +} + +.config-search__clear { + position: absolute; + right: 20px; + top: 50%; + transform: translateY(-50%); + width: 20px; + height: 20px; + border: none; + border-radius: 50%; + background: rgba(255, 255, 255, 0.1); + color: var(--muted); + font-size: 14px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 150ms ease, color 150ms ease; +} + +.config-search__clear:hover { + background: rgba(255, 255, 255, 0.2); + color: var(--text); +} + +/* Navigation */ +.config-nav { + flex: 1; + overflow-y: auto; + padding: 8px; +} + +.config-nav__item { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 10px 12px; + border: none; + border-radius: 10px; + background: transparent; + color: var(--text); + font-size: 13px; + text-align: left; + cursor: pointer; + transition: background 150ms ease, transform 100ms ease; +} + +.config-nav__item:hover { + background: rgba(255, 255, 255, 0.06); +} + +:root[data-theme="light"] .config-nav__item:hover { + background: rgba(0, 0, 0, 0.05); +} + +.config-nav__item.active { + background: rgba(245, 159, 74, 0.15); + color: var(--accent); +} + +.config-nav__item:active { + transform: scale(0.98); +} + +.config-nav__icon { + font-size: 16px; + width: 24px; + text-align: center; +} + +.config-nav__label { + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Mode Toggle */ +.config-mode-toggle { + display: flex; + gap: 4px; + padding: 4px; + background: rgba(0, 0, 0, 0.2); + border-radius: 10px; +} + +:root[data-theme="light"] .config-mode-toggle { + background: rgba(0, 0, 0, 0.08); +} + +.config-mode-toggle__btn { + flex: 1; + padding: 8px 12px; + border: none; + border-radius: 8px; + background: transparent; + color: var(--muted); + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: background 150ms ease, color 150ms ease; +} + +.config-mode-toggle__btn:hover { + color: var(--text); +} + +.config-mode-toggle__btn.active { + background: rgba(255, 255, 255, 0.1); + color: var(--text); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); +} + +:root[data-theme="light"] .config-mode-toggle__btn.active { + background: rgba(255, 255, 255, 0.9); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +/* Main Content */ +.config-main { + display: flex; + flex-direction: column; + min-width: 0; + background: var(--panel); +} + +/* Actions Bar */ +.config-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 16px; + background: rgba(0, 0, 0, 0.1); + border-bottom: 1px solid var(--border); +} + +:root[data-theme="light"] .config-actions { + background: rgba(0, 0, 0, 0.03); +} + +.config-actions__left, +.config-actions__right { + display: flex; + align-items: center; + gap: 8px; +} + +.config-changes-badge { + padding: 4px 10px; + border-radius: 999px; + background: rgba(245, 159, 74, 0.2); + border: 1px solid rgba(245, 159, 74, 0.4); + color: var(--accent); + font-size: 12px; + font-weight: 500; +} + +.config-status { + font-size: 12px; +} + +/* Diff Panel */ +.config-diff { + margin: 12px 16px; + border: 1px solid var(--border); + border-radius: 12px; + background: rgba(0, 0, 0, 0.15); + overflow: hidden; +} + +:root[data-theme="light"] .config-diff { + background: rgba(245, 159, 74, 0.05); +} + +.config-diff__summary { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 14px; + cursor: pointer; + font-size: 13px; + font-weight: 500; + color: var(--accent); + list-style: none; +} + +.config-diff__summary::-webkit-details-marker { + display: none; +} + +.config-diff__chevron { + width: 16px; + height: 16px; + transition: transform 200ms ease; +} + +.config-diff[open] .config-diff__chevron { + transform: rotate(180deg); +} + +.config-diff__content { + padding: 0 14px 14px; + display: grid; + gap: 8px; +} + +.config-diff__item { + padding: 8px 10px; + border-radius: 8px; + background: rgba(0, 0, 0, 0.15); + font-size: 12px; +} + +:root[data-theme="light"] .config-diff__item { + background: rgba(255, 255, 255, 0.8); +} + +.config-diff__path { + font-family: var(--mono); + font-weight: 600; + margin-bottom: 4px; + color: var(--text); +} + +.config-diff__values { + display: flex; + align-items: center; + gap: 8px; + font-family: var(--mono); + color: var(--muted); +} + +.config-diff__from { + color: var(--danger); + text-decoration: line-through; + opacity: 0.7; +} + +.config-diff__arrow { + color: var(--muted); +} + +.config-diff__to { + color: var(--ok); +} + +/* Content Area */ +.config-content { + flex: 1; + overflow-y: auto; + padding: 16px; +} + +.config-raw-field textarea { + min-height: 400px; + font-family: var(--mono); + font-size: 13px; +} + +/* Loading State */ +.config-loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + padding: 60px 20px; + color: var(--muted); +} + +.config-loading__spinner { + width: 32px; + height: 32px; + border: 3px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* Empty State */ +.config-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + padding: 60px 20px; + text-align: center; +} + +.config-empty__icon { + font-size: 48px; + opacity: 0.5; +} + +.config-empty__text { + color: var(--muted); + font-size: 14px; +} + +/* Section Cards */ +.config-form--modern { + display: grid; + gap: 16px; +} + +.config-section-card { + border: 1px solid var(--border); + border-radius: 14px; + background: linear-gradient(160deg, rgba(255, 255, 255, 0.03), transparent 70%), + rgba(0, 0, 0, 0.12); + overflow: hidden; +} + +:root[data-theme="light"] .config-section-card { + background: linear-gradient(160deg, rgba(255, 255, 255, 0.9), rgba(255, 255, 255, 0.7) 70%); +} + +.config-section-card__header { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 16px; + background: rgba(255, 255, 255, 0.02); + border-bottom: 1px solid var(--border); +} + +:root[data-theme="light"] .config-section-card__header { + background: rgba(0, 0, 0, 0.02); +} + +.config-section-card__icon { + font-size: 24px; + line-height: 1; + padding-top: 2px; +} + +.config-section-card__titles { + flex: 1; + min-width: 0; +} + +.config-section-card__title { + margin: 0; + font-size: 16px; + font-weight: 600; + letter-spacing: 0.2px; +} + +.config-section-card__desc { + margin: 4px 0 0; + font-size: 13px; + color: var(--muted); + line-height: 1.4; +} + +.config-section-card__content { + padding: 16px; +} + +/* Toggle Switch */ +.toggle-switch { + position: relative; + display: inline-flex; + cursor: pointer; +} + +.toggle-switch input { + position: absolute; + opacity: 0; + width: 0; + height: 0; +} + +.toggle-switch__track { + position: relative; + width: 44px; + height: 24px; + background: rgba(255, 255, 255, 0.15); + border: 1px solid var(--border); + border-radius: 999px; + transition: background 200ms ease, border-color 200ms ease; +} + +:root[data-theme="light"] .toggle-switch__track { + background: rgba(0, 0, 0, 0.1); +} + +.toggle-switch__thumb { + position: absolute; + top: 2px; + left: 2px; + width: 18px; + height: 18px; + background: var(--text); + border-radius: 50%; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); + transition: transform 200ms ease, background 200ms ease; +} + +.toggle-switch input:checked + .toggle-switch__track { + background: rgba(43, 217, 127, 0.3); + border-color: rgba(43, 217, 127, 0.5); +} + +.toggle-switch input:checked + .toggle-switch__track .toggle-switch__thumb { + transform: translateX(20px); + background: var(--ok); +} + +.toggle-switch input:focus + .toggle-switch__track { + box-shadow: 0 0 0 3px var(--focus); +} + +.toggle-switch input:disabled + .toggle-switch__track { + opacity: 0.5; + cursor: not-allowed; +} + +/* Field Row (for toggles) */ +.field-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 12px 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} + +:root[data-theme="light"] .field-row { + border-bottom-color: rgba(0, 0, 0, 0.06); +} + +.field-row:last-child { + border-bottom: none; +} + +.field-row__info { + flex: 1; + min-width: 0; +} + +.field-row__label { + display: block; + font-size: 14px; + font-weight: 500; + color: var(--text); +} + +.field-row__help { + display: block; + margin-top: 2px; + font-size: 12px; + color: var(--muted); + line-height: 1.4; +} + +/* Pills */ +.pill--sm { + padding: 3px 8px; + font-size: 10px; +} + +.pill--ok { + border-color: rgba(43, 217, 127, 0.5); + color: var(--ok); +} + +.pill--danger { + border-color: rgba(255, 92, 92, 0.5); + color: var(--danger); +} + +/* Improved Fieldset */ +.config-form--modern .fieldset { + border: 1px solid var(--border); + border-radius: 12px; + padding: 14px; + margin-top: 12px; + background: rgba(0, 0, 0, 0.08); +} + +:root[data-theme="light"] .config-form--modern .fieldset { + background: rgba(255, 255, 255, 0.5); +} + +.config-form--modern .fieldset > .legend { + font-weight: 600; + font-size: 13px; + color: var(--text); + margin-bottom: 12px; + padding-bottom: 10px; + border-bottom: 1px dashed var(--border); +} + +.config-form--modern .field { + margin-bottom: 12px; +} + +.config-form--modern .field:last-child { + margin-bottom: 0; +} + +.config-form--modern .field span { + font-size: 12px; + font-weight: 500; + color: var(--muted); +} + +.config-form--modern .field input, +.config-form--modern .field select, +.config-form--modern .field textarea { + font-size: 14px; +} + +/* Array improvements */ +.config-form--modern .array { + display: grid; + gap: 10px; +} + +.config-form--modern .array-item { + display: flex; + gap: 12px; + align-items: flex-start; + padding: 12px; + border: 1px solid var(--border); + border-radius: 10px; + background: rgba(0, 0, 0, 0.1); +} + +:root[data-theme="light"] .config-form--modern .array-item { + background: rgba(255, 255, 255, 0.6); +} + +/* Mobile Responsiveness */ +@media (max-width: 768px) { + .config-layout { + grid-template-columns: 1fr; + min-height: auto; + } + + .config-sidebar { + border-right: none; + border-bottom: 1px solid var(--border); + } + + .config-nav { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 12px; + max-height: none; + overflow: visible; + } + + .config-nav__item { + flex: 0 0 auto; + padding: 8px 12px; + } + + .config-nav__icon { + width: auto; + } + + .config-sidebar__footer { + display: none; + } + + .config-actions { + flex-wrap: wrap; + } + + .config-actions__left { + width: 100%; + justify-content: center; + } + + .config-actions__right { + width: 100%; + justify-content: center; + } + + .config-diff__values { + flex-wrap: wrap; + } + + .field-row { + flex-direction: column; + align-items: flex-start; + gap: 10px; + } + + .config-form--modern .array-item { + flex-direction: column; + } +} + +@media (max-width: 480px) { + .config-nav__label { + display: none; + } + + .config-nav__item { + padding: 10px; + } + + .config-section-card__header { + padding: 12px; + } + + .config-section-card__content { + padding: 12px; + } +} diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 2be9d1a16..63d44300a 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -472,9 +472,14 @@ export function renderApp(state: AppViewState) { uiHints: state.configUiHints, formMode: state.configFormMode, formValue: state.configForm, + originalValue: state.configFormOriginal, + searchQuery: state.configSearchQuery, + activeSection: state.configActiveSection, onRawChange: (next) => (state.configRaw = next), onFormModeChange: (mode) => (state.configFormMode = mode), onFormPatch: (path, value) => updateConfigFormValue(state, path, value), + onSearchChange: (query) => (state.configSearchQuery = query), + onSectionChange: (section) => (state.configActiveSection = section), onReload: () => loadConfig(state), onSave: () => saveConfig(state), onApply: () => applyConfig(state), diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 82ce65d06..63c2ed13d 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -139,8 +139,11 @@ export class ClawdbotApp extends LitElement { @state() configSchemaLoading = false; @state() configUiHints: ConfigUiHints = {}; @state() configForm: Record | null = null; + @state() configFormOriginal: Record | null = null; @state() configFormDirty = false; @state() configFormMode: "form" | "raw" = "form"; + @state() configSearchQuery = ""; + @state() configActiveSection: string | null = null; @state() channelsLoading = false; @state() channelsSnapshot: ChannelsStatusSnapshot | null = null; diff --git a/ui/src/ui/controllers/config.ts b/ui/src/ui/controllers/config.ts index ab6668318..38dc3b0fd 100644 --- a/ui/src/ui/controllers/config.ts +++ b/ui/src/ui/controllers/config.ts @@ -28,8 +28,11 @@ export type ConfigState = { configSchemaLoading: boolean; configUiHints: ConfigUiHints; configForm: Record | null; + configFormOriginal: Record | null; configFormDirty: boolean; configFormMode: "form" | "raw"; + configSearchQuery: string; + configActiveSection: string | null; lastError: string | null; }; @@ -93,6 +96,7 @@ export function applyConfigSnapshot(state: ConfigState, snapshot: ConfigSnapshot if (!state.configFormDirty) { state.configForm = cloneConfigObject(snapshot.config ?? {}); + state.configFormOriginal = cloneConfigObject(snapshot.config ?? {}); } } diff --git a/ui/src/ui/views/config-form.node.ts b/ui/src/ui/views/config-form.node.ts index 8550d8125..76a6e6195 100644 --- a/ui/src/ui/views/config-form.node.ts +++ b/ui/src/ui/views/config-form.node.ts @@ -34,9 +34,10 @@ export function renderNode(params: { unsupported: Set; disabled: boolean; showLabel?: boolean; + searchQuery?: string; onPatch: (path: Array, value: unknown) => void; }): TemplateResult | typeof nothing { - const { schema, value, path, hints, unsupported, disabled, onPatch } = params; + const { schema, value, path, hints, unsupported, disabled, onPatch, searchQuery } = params; const showLabel = params.showLabel ?? true; const type = schemaType(schema); const hint = hintForPath(path, hints); @@ -303,21 +304,24 @@ export function renderNode(params: { ? schema.default : false; return html` - +
+
+ ${showLabel ? html`${label}` : nothing} + ${help ? html`${help}` : nothing} +
+ +
`; } diff --git a/ui/src/ui/views/config-form.render.ts b/ui/src/ui/views/config-form.render.ts index bcb7e53bf..21c13c96c 100644 --- a/ui/src/ui/views/config-form.render.ts +++ b/ui/src/ui/views/config-form.render.ts @@ -9,45 +9,104 @@ export type ConfigFormProps = { value: Record | null; disabled?: boolean; unsupportedPaths?: string[]; + searchQuery?: string; + activeSection?: string | null; onPatch: (path: Array, value: unknown) => void; }; -// Define logical section groupings -const SECTION_CONFIG: Record = { - // Core - env: { label: "Environment", icon: "🔧", order: 0 }, - update: { label: "Updates", icon: "📦", order: 1 }, - - // Identity & Agents - agents: { label: "Agents", icon: "🤖", order: 10 }, - auth: { label: "Authentication", icon: "🔐", order: 11 }, - - // Communication - channels: { label: "Channels", icon: "💬", order: 20 }, - messages: { label: "Messages", icon: "📨", order: 21 }, - - // Automation - commands: { label: "Commands", icon: "⌨️", order: 30 }, - hooks: { label: "Hooks", icon: "🪝", order: 31 }, - skills: { label: "Skills", icon: "✨", order: 32 }, - - // Tools & Gateway - tools: { label: "Tools", icon: "🛠️", order: 40 }, - gateway: { label: "Gateway", icon: "🌐", order: 41 }, - - // System - wizard: { label: "Setup Wizard", icon: "🧙", order: 50 }, +// Section metadata +const SECTION_META: Record = { + env: { + label: "Environment Variables", + icon: "🔧", + description: "Environment variables passed to the gateway process" + }, + update: { + label: "Updates", + icon: "📦", + description: "Auto-update settings and release channel" + }, + agents: { + label: "Agents", + icon: "🤖", + description: "Agent configurations, models, and identities" + }, + auth: { + label: "Authentication", + icon: "🔐", + description: "API keys and authentication profiles" + }, + channels: { + label: "Channels", + icon: "💬", + description: "Messaging channels (Telegram, Discord, Slack, etc.)" + }, + messages: { + label: "Messages", + icon: "📨", + description: "Message handling and routing settings" + }, + commands: { + label: "Commands", + icon: "⌨️", + description: "Custom slash commands" + }, + hooks: { + label: "Hooks", + icon: "🪝", + description: "Webhooks and event hooks" + }, + skills: { + label: "Skills", + icon: "✨", + description: "Skill packs and capabilities" + }, + tools: { + label: "Tools", + icon: "🛠️", + description: "Tool configurations (browser, search, etc.)" + }, + gateway: { + label: "Gateway", + icon: "🌐", + description: "Gateway server settings (port, auth, binding)" + }, + wizard: { + label: "Setup Wizard", + icon: "🧙", + description: "Setup wizard state and history" + }, }; -// Logical groupings for the accordion layout -const SECTION_GROUPS: Array<{ title: string; keys: string[] }> = [ - { title: "Core Settings", keys: ["env", "update"] }, - { title: "Identity & Agents", keys: ["agents", "auth"] }, - { title: "Communication", keys: ["channels", "messages"] }, - { title: "Automation", keys: ["commands", "hooks", "skills"] }, - { title: "Tools & Gateway", keys: ["tools", "gateway"] }, - { title: "System", keys: ["wizard"] }, -]; +function matchesSearch(key: string, schema: JsonSchema, query: string): boolean { + if (!query) return true; + const q = query.toLowerCase(); + const meta = SECTION_META[key]; + + // Check key name + if (key.toLowerCase().includes(q)) return true; + + // Check label and description + if (meta) { + if (meta.label.toLowerCase().includes(q)) return true; + if (meta.description.toLowerCase().includes(q)) return true; + } + + // Check schema title/description + if (schema.title?.toLowerCase().includes(q)) return true; + if (schema.description?.toLowerCase().includes(q)) return true; + + // Deep search in properties + if (schema.properties) { + for (const [propKey, propSchema] of Object.entries(schema.properties)) { + if (propKey.toLowerCase().includes(q)) return true; + if (propSchema.title?.toLowerCase().includes(q)) return true; + if (propSchema.description?.toLowerCase().includes(q)) return true; + } + } + + return false; +} export function renderConfigForm(props: ConfigFormProps) { if (!props.schema) { @@ -60,76 +119,79 @@ export function renderConfigForm(props: ConfigFormProps) { } const unsupported = new Set(props.unsupportedPaths ?? []); const properties = schema.properties; - const allKeys = new Set(Object.keys(properties)); + const searchQuery = props.searchQuery ?? ""; + const activeSection = props.activeSection; - // Collect any keys not in our defined groups - const groupedKeys = new Set(SECTION_GROUPS.flatMap(g => g.keys)); - const ungroupedKeys = [...allKeys].filter(k => !groupedKeys.has(k)); + // Filter and sort entries + let entries = Object.entries(properties); + + // Filter by active section + if (activeSection) { + entries = entries.filter(([key]) => key === activeSection); + } + + // Filter by search + if (searchQuery) { + entries = entries.filter(([key, node]) => matchesSearch(key, node, searchQuery)); + } + + // Sort by hint order, then alphabetically + entries.sort((a, b) => { + const orderA = hintForPath([a[0]], props.uiHints)?.order ?? 50; + const orderB = hintForPath([b[0]], props.uiHints)?.order ?? 50; + if (orderA !== orderB) return orderA - orderB; + return a[0].localeCompare(b[0]); + }); - // Build the groups with their entries - const groups = SECTION_GROUPS.map(group => { - const entries = group.keys - .filter(key => allKeys.has(key)) - .map(key => ({ key, node: properties[key] })); - return { ...group, entries }; - }).filter(group => group.entries.length > 0); - - // Add ungrouped keys as "Other" if any exist - if (ungroupedKeys.length > 0) { - const sortedUngrouped = ungroupedKeys.sort((a, b) => { - const orderA = hintForPath([a], props.uiHints)?.order ?? 100; - const orderB = hintForPath([b], props.uiHints)?.order ?? 100; - if (orderA !== orderB) return orderA - orderB; - return a.localeCompare(b); - }); - groups.push({ - title: "Other", - keys: sortedUngrouped, - entries: sortedUngrouped.map(key => ({ key, node: properties[key] })), - }); + if (entries.length === 0) { + return html` +
+
🔍
+
+ ${searchQuery + ? `No settings match "${searchQuery}"` + : "No settings in this section"} +
+
+ `; } return html` -
- ${groups.map((group, groupIndex) => html` -
- - ${group.title} - ${group.entries.length} ${group.entries.length === 1 ? 'setting' : 'settings'} - - - - -
- ${group.entries.map(({ key, node }) => { - const sectionInfo = SECTION_CONFIG[key]; - const icon = sectionInfo?.icon ?? "📄"; - const label = sectionInfo?.label ?? key; - - return html` -
-
- ${icon} - ${label} -
-
- ${renderNode({ - schema: node, - value: (value as Record)[key], - path: [key], - hints: props.uiHints, - unsupported, - disabled: props.disabled ?? false, - showLabel: false, - onPatch: props.onPatch, - })} -
-
- `; - })} -
-
- `)} +
+ ${entries.map(([key, node]) => { + const meta = SECTION_META[key] ?? { + label: key.charAt(0).toUpperCase() + key.slice(1), + icon: "📄", + description: node.description ?? "" + }; + + return html` +
+
+ ${meta.icon} +
+

${meta.label}

+ ${meta.description ? html` +

${meta.description}

+ ` : nothing} +
+
+
+ ${renderNode({ + schema: node, + value: (value as Record)[key], + path: [key], + hints: props.uiHints, + unsupported, + disabled: props.disabled ?? false, + showLabel: false, + onPatch: props.onPatch, + searchQuery, + })} +
+
+ `; + })}
`; } diff --git a/ui/src/ui/views/config.ts b/ui/src/ui/views/config.ts index a87c01b91..6fa9410e6 100644 --- a/ui/src/ui/views/config.ts +++ b/ui/src/ui/views/config.ts @@ -16,15 +16,79 @@ export type ConfigProps = { uiHints: ConfigUiHints; formMode: "form" | "raw"; formValue: Record | null; + originalValue: Record | null; + searchQuery: string; + activeSection: string | null; onRawChange: (next: string) => void; onFormModeChange: (mode: "form" | "raw") => void; onFormPatch: (path: Array, value: unknown) => void; + onSearchChange: (query: string) => void; + onSectionChange: (section: string | null) => void; onReload: () => void; onSave: () => void; onApply: () => void; onUpdate: () => void; }; +// Section definitions with icons +const SECTIONS: Array<{ key: string; label: string; icon: string }> = [ + { key: "env", label: "Environment", icon: "🔧" }, + { key: "update", label: "Updates", icon: "📦" }, + { key: "agents", label: "Agents", icon: "🤖" }, + { key: "auth", label: "Authentication", icon: "🔐" }, + { key: "channels", label: "Channels", icon: "💬" }, + { key: "messages", label: "Messages", icon: "📨" }, + { key: "commands", label: "Commands", icon: "⌨️" }, + { key: "hooks", label: "Hooks", icon: "🪝" }, + { key: "skills", label: "Skills", icon: "✨" }, + { key: "tools", label: "Tools", icon: "🛠️" }, + { key: "gateway", label: "Gateway", icon: "🌐" }, + { key: "wizard", label: "Setup Wizard", icon: "🧙" }, +]; + +function computeDiff( + original: Record | null, + current: Record | null +): Array<{ path: string; from: unknown; to: unknown }> { + if (!original || !current) return []; + const changes: Array<{ path: string; from: unknown; to: unknown }> = []; + + function compare(orig: unknown, curr: unknown, path: string) { + if (orig === curr) return; + if (typeof orig !== typeof curr) { + changes.push({ path, from: orig, to: curr }); + return; + } + if (typeof orig !== "object" || orig === null || curr === null) { + if (orig !== curr) { + changes.push({ path, from: orig, to: curr }); + } + return; + } + if (Array.isArray(orig) && Array.isArray(curr)) { + if (JSON.stringify(orig) !== JSON.stringify(curr)) { + changes.push({ path, from: orig, to: curr }); + } + return; + } + const origObj = orig as Record; + const currObj = curr as Record; + const allKeys = new Set([...Object.keys(origObj), ...Object.keys(currObj)]); + for (const key of allKeys) { + compare(origObj[key], currObj[key], path ? `${path}.${key}` : key); + } + } + + compare(original, current, ""); + return changes; +} + +function truncateValue(value: unknown, maxLen = 40): string { + const str = JSON.stringify(value); + if (str.length <= maxLen) return str; + return str.slice(0, maxLen - 3) + "..."; +} + export function renderConfig(props: ConfigProps) { const validity = props.valid == null ? "unknown" : props.valid ? "valid" : "invalid"; @@ -45,97 +109,201 @@ export function renderConfig(props: ConfigProps) { (props.formMode === "raw" ? true : canSaveForm); const canUpdate = props.connected && !props.applying && !props.updating; + // Get available sections from schema + const schemaProps = analysis.schema?.properties ?? {}; + const availableSections = SECTIONS.filter(s => s.key in schemaProps); + + // Add any sections in schema but not in our list + const knownKeys = new Set(SECTIONS.map(s => s.key)); + const extraSections = Object.keys(schemaProps) + .filter(k => !knownKeys.has(k)) + .map(k => ({ key: k, label: k.charAt(0).toUpperCase() + k.slice(1), icon: "📄" })); + + const allSections = [...availableSections, ...extraSections]; + + // Compute diff for showing changes + const diff = props.formMode === "form" + ? computeDiff(props.originalValue, props.formValue) + : []; + const hasChanges = diff.length > 0; + return html` -
-
-
-
-
Config
- ${validity} -
-
+
+ + + + +
+ +
+
+ ${hasChanges ? html` + ${diff.length} unsaved change${diff.length !== 1 ? "s" : ""} + ` : html` + No changes + `} +
+
+ + + + +
-
+ + + ${hasChanges ? html` +
+ + View ${diff.length} pending change${diff.length !== 1 ? "s" : ""} + + + + +
+ ${diff.map(change => html` +
+
${change.path}
+
+ ${truncateValue(change.from)} + + ${truncateValue(change.to)} +
+
+ `)} +
+
+ ` : nothing} -
- Writes to ~/.clawdbot/clawdbot.json. Apply & - Update restart the gateway. -
+ +
+ ${props.formMode === "form" + ? html` + ${props.schemaLoading + ? html`
+
+ Loading schema… +
` + : renderConfigForm({ + schema: analysis.schema, + uiHints: props.uiHints, + value: props.formValue, + disabled: props.loading || !props.formValue, + unsupportedPaths: analysis.unsupportedPaths, + onPatch: props.onFormPatch, + searchQuery: props.searchQuery, + activeSection: props.activeSection, + })} + ${formUnsafe + ? html`
+ Form view can't safely edit some fields. + Use Raw to avoid losing config entries. +
` + : nothing} + ` + : html` + + `} +
- - ${props.formMode === "form" - ? html`
- ${props.schemaLoading - ? html`
Loading schema…
` - : renderConfigForm({ - schema: analysis.schema, - uiHints: props.uiHints, - value: props.formValue, - disabled: props.loading || !props.formValue, - unsupportedPaths: analysis.unsupportedPaths, - onPatch: props.onFormPatch, - })} - ${formUnsafe - ? html`
- Form view can’t safely edit some fields. - Use Raw to avoid losing config entries. -
` - : nothing} -
` - : html``} - - ${props.issues.length > 0 - ? html`
-
${JSON.stringify(props.issues, null, 2)}
-
` - : nothing} -
+ ${props.issues.length > 0 + ? html`
+
${JSON.stringify(props.issues, null, 2)}
+
` + : nothing} + +
`; } From bd8f4b052d17bf0bf560fa34609ea01327681d6c Mon Sep 17 00:00:00 2001 From: Maude Bot Date: Tue, 20 Jan 2026 11:29:19 -0500 Subject: [PATCH 3/5] chore: remove duplicate config styles from components.css --- ui/src/styles/components.css | 250 ----------------------------------- 1 file changed, 250 deletions(-) diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index 18055eadc..435fd59bc 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -1274,253 +1274,3 @@ flex-wrap: wrap; gap: 10px; } - -/* Config Form Sections */ -.config-form--sectioned { - display: grid; - gap: 12px; -} - -.config-section { - border: 1px solid var(--border); - border-radius: 14px; - background: linear-gradient(160deg, rgba(255, 255, 255, 0.03), transparent 70%), - rgba(0, 0, 0, 0.15); - overflow: hidden; -} - -:root[data-theme="light"] .config-section { - background: linear-gradient(160deg, rgba(255, 255, 255, 0.8), rgba(255, 255, 255, 0.5) 70%); -} - -.config-section__header { - display: flex; - align-items: center; - gap: 12px; - padding: 14px 16px; - cursor: pointer; - user-select: none; - border-bottom: 1px solid transparent; - transition: background 150ms ease, border-color 150ms ease; -} - -.config-section__header:hover { - background: rgba(255, 255, 255, 0.04); -} - -:root[data-theme="light"] .config-section__header:hover { - background: rgba(0, 0, 0, 0.03); -} - -.config-section[open] .config-section__header { - border-bottom-color: var(--border); - background: rgba(255, 255, 255, 0.02); -} - -:root[data-theme="light"] .config-section[open] .config-section__header { - background: rgba(0, 0, 0, 0.02); -} - -.config-section__title { - font-family: var(--font-display); - font-size: 13px; - font-weight: 600; - letter-spacing: 0.5px; - text-transform: uppercase; - flex: 1; -} - -.config-section__count { - font-size: 11px; - color: var(--muted); - padding: 3px 8px; - border-radius: 999px; - background: rgba(255, 255, 255, 0.06); - border: 1px solid var(--border); -} - -:root[data-theme="light"] .config-section__count { - background: rgba(0, 0, 0, 0.05); -} - -.config-section__chevron { - width: 18px; - height: 18px; - color: var(--muted); - transition: transform 200ms ease; - flex-shrink: 0; -} - -.config-section[open] .config-section__chevron { - transform: rotate(180deg); -} - -.config-section__content { - padding: 12px 16px 16px; - display: grid; - gap: 16px; -} - -.config-field-group { - border: 1px solid var(--border); - border-radius: 12px; - background: rgba(0, 0, 0, 0.12); - overflow: hidden; -} - -:root[data-theme="light"] .config-field-group { - background: rgba(255, 255, 255, 0.6); -} - -.config-field-group__header { - display: flex; - align-items: center; - gap: 8px; - padding: 10px 14px; - background: rgba(255, 255, 255, 0.03); - border-bottom: 1px solid var(--border); -} - -:root[data-theme="light"] .config-field-group__header { - background: rgba(0, 0, 0, 0.03); -} - -.config-field-group__icon { - font-size: 16px; - line-height: 1; -} - -.config-field-group__label { - font-weight: 600; - font-size: 13px; - letter-spacing: 0.3px; -} - -.config-field-group__content { - padding: 14px; -} - -.config-field-group__content .fieldset { - border: none; - padding: 0; - margin: 0; -} - -.config-field-group__content .fieldset > .legend { - display: none; -} - -/* Config form field improvements */ -.config-form .fieldset { - border: 1px solid var(--border); - border-radius: 12px; - padding: 12px; - margin-top: 8px; - background: rgba(0, 0, 0, 0.08); -} - -:root[data-theme="light"] .config-form .fieldset { - background: rgba(255, 255, 255, 0.5); -} - -.config-form .fieldset .legend { - font-weight: 600; - font-size: 12px; - letter-spacing: 0.3px; - color: var(--text); - margin-bottom: 10px; - padding-bottom: 8px; - border-bottom: 1px dashed var(--border); -} - -.config-form .fieldset .fieldset { - margin-top: 12px; -} - -.config-form .array { - display: grid; - gap: 8px; -} - -.config-form .array-item { - display: flex; - gap: 10px; - align-items: flex-start; - padding: 10px; - border: 1px solid var(--border); - border-radius: 10px; - background: rgba(0, 0, 0, 0.1); -} - -:root[data-theme="light"] .config-form .array-item { - background: rgba(255, 255, 255, 0.6); -} - -/* Mobile responsiveness */ -@media (max-width: 640px) { - .config-section__header { - padding: 12px 14px; - } - - .config-section__content { - padding: 10px 12px 14px; - } - - .config-field-group__content { - padding: 12px; - } - - .config-form .array-item { - flex-direction: column; - } - - .config-form .array-item > div:first-child { - width: 100%; - } - - .config-form .array-item .btn { - align-self: flex-end; - } -} - -/* Config Header Layout */ -.config-header { - display: flex; - flex-direction: column; - gap: 12px; -} - -.config-header__top { - display: flex; - justify-content: space-between; - align-items: center; - gap: 12px; - flex-wrap: wrap; -} - -.config-header__actions { - display: flex; - gap: 8px; - flex-wrap: wrap; -} - -@media (max-width: 640px) { - .config-header__top { - flex-direction: column; - align-items: stretch; - } - - .config-header__top .row { - justify-content: space-between; - } - - .config-header__actions { - justify-content: flex-end; - } - - .config-header__actions .btn { - flex: 1; - min-width: 70px; - text-align: center; - } -} From f6abe62e5f336f476189e92cbee61afbe15517e4 Mon Sep 17 00:00:00 2001 From: Maude Bot Date: Tue, 20 Jan 2026 11:40:13 -0500 Subject: [PATCH 4/5] feat(ui): major config form UX overhaul MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sidebar: - SVG icons instead of emoji (consistent rendering) - Clean navigation with active states Form fields completely redesigned: - Toggle rows: full-width clickable with label + description - Segmented controls: for enum values with ≤5 options - Number inputs: with +/- stepper buttons - Text inputs: with reset-to-default button - Select dropdowns: clean styling with custom arrow - Arrays: card-based with clear add/remove, item numbering - Objects: collapsible sections with chevron animation - Maps: key-value editor with inline editing Visual improvements: - Consistent border radius and spacing - Better color contrast for labels vs help text - Hover and focus states throughout - Icons for common actions (add, remove, reset) Mobile: - Horizontal scrolling nav on small screens - Stacked layouts for complex fields --- ui/src/styles/config.css | 1078 +++++++++++++++++++------ ui/src/ui/views/config-form.node.ts | 845 +++++++++++-------- ui/src/ui/views/config-form.render.ts | 38 +- ui/src/ui/views/config.ts | 56 +- 4 files changed, 1414 insertions(+), 603 deletions(-) diff --git a/ui/src/styles/config.css b/ui/src/styles/config.css index aff3e88b4..641617b30 100644 --- a/ui/src/styles/config.css +++ b/ui/src/styles/config.css @@ -5,7 +5,7 @@ /* Layout Container */ .config-layout { display: grid; - grid-template-columns: 260px minmax(0, 1fr); + grid-template-columns: 240px minmax(0, 1fr); gap: 0; min-height: calc(100vh - 140px); margin: -16px; @@ -15,16 +15,18 @@ background: var(--panel); } -/* Sidebar */ +/* =========================================== + Sidebar + =========================================== */ .config-sidebar { display: flex; flex-direction: column; - background: linear-gradient(180deg, rgba(0, 0, 0, 0.15), rgba(0, 0, 0, 0.25)); + background: rgba(0, 0, 0, 0.2); border-right: 1px solid var(--border); } :root[data-theme="light"] .config-sidebar { - background: linear-gradient(180deg, rgba(255, 255, 255, 0.8), rgba(255, 255, 255, 0.6)); + background: rgba(0, 0, 0, 0.03); } .config-sidebar__header { @@ -36,11 +38,9 @@ } .config-sidebar__title { - font-family: var(--font-display); - font-size: 14px; font-weight: 600; - letter-spacing: 0.5px; - text-transform: uppercase; + font-size: 14px; + letter-spacing: 0.3px; } .config-sidebar__footer { @@ -53,11 +53,12 @@ .config-search { position: relative; padding: 12px; + border-bottom: 1px solid var(--border); } .config-search__icon { position: absolute; - left: 22px; + left: 24px; top: 50%; transform: translateY(-50%); width: 16px; @@ -68,22 +69,31 @@ .config-search__input { width: 100%; - padding: 10px 12px 10px 38px; + padding: 10px 32px 10px 40px; border: 1px solid var(--border); - border-radius: 10px; - background: rgba(0, 0, 0, 0.2); + border-radius: 8px; + background: rgba(0, 0, 0, 0.15); font-size: 13px; outline: none; - transition: border-color 150ms ease, box-shadow 150ms ease; + transition: border-color 150ms ease, box-shadow 150ms ease, background 150ms ease; +} + +.config-search__input::placeholder { + color: var(--muted); } .config-search__input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--focus); + background: rgba(0, 0, 0, 0.2); } :root[data-theme="light"] .config-search__input { - background: rgba(255, 255, 255, 0.9); + background: rgba(255, 255, 255, 0.8); +} + +:root[data-theme="light"] .config-search__input:focus { + background: #fff; } .config-search__clear { @@ -97,7 +107,8 @@ border-radius: 50%; background: rgba(255, 255, 255, 0.1); color: var(--muted); - font-size: 14px; + font-size: 16px; + line-height: 1; cursor: pointer; display: flex; align-items: center; @@ -120,21 +131,23 @@ .config-nav__item { display: flex; align-items: center; - gap: 10px; + gap: 12px; width: 100%; padding: 10px 12px; border: none; - border-radius: 10px; + border-radius: 8px; background: transparent; - color: var(--text); + color: var(--muted); font-size: 13px; + font-weight: 500; text-align: left; cursor: pointer; - transition: background 150ms ease, transform 100ms ease; + transition: background 150ms ease, color 150ms ease; } .config-nav__item:hover { - background: rgba(255, 255, 255, 0.06); + background: rgba(255, 255, 255, 0.05); + color: var(--text); } :root[data-theme="light"] .config-nav__item:hover { @@ -142,18 +155,24 @@ } .config-nav__item.active { - background: rgba(245, 159, 74, 0.15); + background: rgba(245, 159, 74, 0.12); color: var(--accent); } -.config-nav__item:active { - transform: scale(0.98); +.config-nav__icon { + width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; } -.config-nav__icon { - font-size: 16px; - width: 24px; - text-align: center; +.config-nav__icon svg { + width: 18px; + height: 18px; + stroke: currentColor; + fill: none; } .config-nav__label { @@ -166,27 +185,27 @@ /* Mode Toggle */ .config-mode-toggle { display: flex; - gap: 4px; - padding: 4px; + padding: 3px; background: rgba(0, 0, 0, 0.2); - border-radius: 10px; + border-radius: 8px; + border: 1px solid var(--border); } :root[data-theme="light"] .config-mode-toggle { - background: rgba(0, 0, 0, 0.08); + background: rgba(0, 0, 0, 0.06); } .config-mode-toggle__btn { flex: 1; padding: 8px 12px; border: none; - border-radius: 8px; + border-radius: 6px; background: transparent; color: var(--muted); font-size: 12px; - font-weight: 500; + font-weight: 600; cursor: pointer; - transition: background 150ms ease, color 150ms ease; + transition: background 150ms ease, color 150ms ease, box-shadow 150ms ease; } .config-mode-toggle__btn:hover { @@ -196,15 +215,17 @@ .config-mode-toggle__btn.active { background: rgba(255, 255, 255, 0.1); color: var(--text); - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); } :root[data-theme="light"] .config-mode-toggle__btn.active { - background: rgba(255, 255, 255, 0.9); - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + background: #fff; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); } -/* Main Content */ +/* =========================================== + Main Content + =========================================== */ .config-main { display: flex; flex-direction: column; @@ -218,13 +239,13 @@ align-items: center; justify-content: space-between; gap: 12px; - padding: 12px 16px; - background: rgba(0, 0, 0, 0.1); + padding: 12px 20px; + background: rgba(0, 0, 0, 0.08); border-bottom: 1px solid var(--border); } :root[data-theme="light"] .config-actions { - background: rgba(0, 0, 0, 0.03); + background: rgba(0, 0, 0, 0.02); } .config-actions__left, @@ -235,40 +256,37 @@ } .config-changes-badge { - padding: 4px 10px; + padding: 5px 12px; border-radius: 999px; - background: rgba(245, 159, 74, 0.2); - border: 1px solid rgba(245, 159, 74, 0.4); + background: rgba(245, 159, 74, 0.15); + border: 1px solid rgba(245, 159, 74, 0.3); color: var(--accent); font-size: 12px; - font-weight: 500; + font-weight: 600; } .config-status { - font-size: 12px; + font-size: 13px; + color: var(--muted); } /* Diff Panel */ .config-diff { - margin: 12px 16px; - border: 1px solid var(--border); - border-radius: 12px; - background: rgba(0, 0, 0, 0.15); - overflow: hidden; -} - -:root[data-theme="light"] .config-diff { + margin: 16px 20px 0; + border: 1px solid rgba(245, 159, 74, 0.3); + border-radius: 10px; background: rgba(245, 159, 74, 0.05); + overflow: hidden; } .config-diff__summary { display: flex; align-items: center; justify-content: space-between; - padding: 10px 14px; + padding: 12px 16px; cursor: pointer; font-size: 13px; - font-weight: 500; + font-weight: 600; color: var(--accent); list-style: none; } @@ -283,46 +301,53 @@ transition: transform 200ms ease; } +.config-diff__chevron svg { + width: 100%; + height: 100%; +} + .config-diff[open] .config-diff__chevron { transform: rotate(180deg); } .config-diff__content { - padding: 0 14px 14px; + padding: 0 16px 16px; display: grid; gap: 8px; } .config-diff__item { - padding: 8px 10px; - border-radius: 8px; - background: rgba(0, 0, 0, 0.15); + display: flex; + align-items: baseline; + gap: 12px; + padding: 8px 12px; + border-radius: 6px; + background: rgba(0, 0, 0, 0.1); font-size: 12px; + font-family: var(--mono); } :root[data-theme="light"] .config-diff__item { - background: rgba(255, 255, 255, 0.8); + background: rgba(255, 255, 255, 0.6); } .config-diff__path { - font-family: var(--mono); font-weight: 600; - margin-bottom: 4px; color: var(--text); + flex-shrink: 0; } .config-diff__values { display: flex; - align-items: center; + align-items: baseline; gap: 8px; - font-family: var(--mono); - color: var(--muted); + min-width: 0; + flex-wrap: wrap; } .config-diff__from { color: var(--danger); - text-decoration: line-through; - opacity: 0.7; + opacity: 0.8; } .config-diff__arrow { @@ -337,13 +362,14 @@ .config-content { flex: 1; overflow-y: auto; - padding: 16px; + padding: 20px; } .config-raw-field textarea { - min-height: 400px; + min-height: 500px; font-family: var(--mono); font-size: 13px; + line-height: 1.5; } /* Loading State */ @@ -352,14 +378,14 @@ flex-direction: column; align-items: center; justify-content: center; - gap: 12px; - padding: 60px 20px; + gap: 16px; + padding: 80px 20px; color: var(--muted); } .config-loading__spinner { - width: 32px; - height: 32px; + width: 36px; + height: 36px; border: 3px solid var(--border); border-top-color: var(--accent); border-radius: 50%; @@ -376,45 +402,46 @@ flex-direction: column; align-items: center; justify-content: center; - gap: 12px; - padding: 60px 20px; + gap: 16px; + padding: 80px 20px; text-align: center; } .config-empty__icon { - font-size: 48px; - opacity: 0.5; + font-size: 56px; + opacity: 0.4; } .config-empty__text { color: var(--muted); - font-size: 14px; + font-size: 15px; } -/* Section Cards */ +/* =========================================== + Section Cards + =========================================== */ .config-form--modern { display: grid; - gap: 16px; + gap: 24px; } .config-section-card { border: 1px solid var(--border); - border-radius: 14px; - background: linear-gradient(160deg, rgba(255, 255, 255, 0.03), transparent 70%), - rgba(0, 0, 0, 0.12); + border-radius: 12px; + background: rgba(255, 255, 255, 0.02); overflow: hidden; } :root[data-theme="light"] .config-section-card { - background: linear-gradient(160deg, rgba(255, 255, 255, 0.9), rgba(255, 255, 255, 0.7) 70%); + background: rgba(255, 255, 255, 0.5); } .config-section-card__header { display: flex; align-items: flex-start; - gap: 12px; - padding: 16px; - background: rgba(255, 255, 255, 0.02); + gap: 14px; + padding: 18px 20px; + background: rgba(0, 0, 0, 0.06); border-bottom: 1px solid var(--border); } @@ -423,9 +450,15 @@ } .config-section-card__icon { - font-size: 24px; - line-height: 1; - padding-top: 2px; + width: 32px; + height: 32px; + color: var(--accent); + flex-shrink: 0; +} + +.config-section-card__icon svg { + width: 100%; + height: 100%; } .config-section-card__titles { @@ -435,9 +468,8 @@ .config-section-card__title { margin: 0; - font-size: 16px; + font-size: 17px; font-weight: 600; - letter-spacing: 0.2px; } .config-section-card__desc { @@ -448,99 +480,312 @@ } .config-section-card__content { - padding: 16px; + padding: 20px; } -/* Toggle Switch */ -.toggle-switch { - position: relative; - display: inline-flex; - cursor: pointer; +/* =========================================== + Form Fields + =========================================== */ +.cfg-fields { + display: grid; + gap: 20px; } -.toggle-switch input { - position: absolute; - opacity: 0; - width: 0; - height: 0; +.cfg-field { + display: grid; + gap: 6px; } -.toggle-switch__track { - position: relative; - width: 44px; - height: 24px; - background: rgba(255, 255, 255, 0.15); +.cfg-field--error { + padding: 12px; + border-radius: 8px; + background: rgba(255, 92, 92, 0.1); + border: 1px solid rgba(255, 92, 92, 0.3); +} + +.cfg-field__label { + font-size: 13px; + font-weight: 600; + color: var(--text); +} + +.cfg-field__help { + font-size: 12px; + color: var(--muted); + line-height: 1.4; +} + +.cfg-field__error { + font-size: 12px; + color: var(--danger); +} + +/* Text Input */ +.cfg-input-wrap { + display: flex; + gap: 8px; +} + +.cfg-input { + flex: 1; + padding: 10px 12px; border: 1px solid var(--border); - border-radius: 999px; - transition: background 200ms ease, border-color 200ms ease; + border-radius: 8px; + background: rgba(0, 0, 0, 0.12); + font-size: 14px; + outline: none; + transition: border-color 150ms ease, box-shadow 150ms ease, background 150ms ease; } -:root[data-theme="light"] .toggle-switch__track { - background: rgba(0, 0, 0, 0.1); +.cfg-input::placeholder { + color: var(--muted); + opacity: 0.7; } -.toggle-switch__thumb { - position: absolute; - top: 2px; - left: 2px; - width: 18px; - height: 18px; - background: var(--text); - border-radius: 50%; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); - transition: transform 200ms ease, background 200ms ease; -} - -.toggle-switch input:checked + .toggle-switch__track { - background: rgba(43, 217, 127, 0.3); - border-color: rgba(43, 217, 127, 0.5); -} - -.toggle-switch input:checked + .toggle-switch__track .toggle-switch__thumb { - transform: translateX(20px); - background: var(--ok); -} - -.toggle-switch input:focus + .toggle-switch__track { +.cfg-input:focus { + border-color: var(--accent); box-shadow: 0 0 0 3px var(--focus); + background: rgba(0, 0, 0, 0.18); } -.toggle-switch input:disabled + .toggle-switch__track { +:root[data-theme="light"] .cfg-input { + background: #fff; +} + +:root[data-theme="light"] .cfg-input:focus { + background: #fff; +} + +.cfg-input--sm { + padding: 8px 10px; + font-size: 13px; +} + +.cfg-input__reset { + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: 8px; + background: rgba(255, 255, 255, 0.05); + color: var(--muted); + font-size: 14px; + cursor: pointer; + transition: background 150ms ease, color 150ms ease; +} + +.cfg-input__reset:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.1); + color: var(--text); +} + +.cfg-input__reset:disabled { opacity: 0.5; cursor: not-allowed; } -/* Field Row (for toggles) */ -.field-row { +/* Textarea */ +.cfg-textarea { + width: 100%; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: 8px; + background: rgba(0, 0, 0, 0.12); + font-family: var(--mono); + font-size: 13px; + line-height: 1.5; + resize: vertical; + outline: none; + transition: border-color 150ms ease, box-shadow 150ms ease; +} + +.cfg-textarea:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--focus); +} + +:root[data-theme="light"] .cfg-textarea { + background: #fff; +} + +.cfg-textarea--sm { + padding: 8px 10px; + font-size: 12px; +} + +/* Number Input */ +.cfg-number { + display: inline-flex; + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; + background: rgba(0, 0, 0, 0.12); +} + +:root[data-theme="light"] .cfg-number { + background: #fff; +} + +.cfg-number__btn { + width: 40px; + border: none; + background: rgba(255, 255, 255, 0.05); + color: var(--text); + font-size: 18px; + font-weight: 300; + cursor: pointer; + transition: background 150ms ease; +} + +.cfg-number__btn:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.1); +} + +.cfg-number__btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +:root[data-theme="light"] .cfg-number__btn { + background: rgba(0, 0, 0, 0.03); +} + +:root[data-theme="light"] .cfg-number__btn:hover:not(:disabled) { + background: rgba(0, 0, 0, 0.06); +} + +.cfg-number__input { + width: 80px; + padding: 10px; + 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: 10px 36px 10px 12px; + border: 1px solid var(--border); + border-radius: 8px; + background-color: rgba(0, 0, 0, 0.12); + 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 10px center; + font-size: 14px; + cursor: pointer; + outline: none; + appearance: none; + transition: border-color 150ms ease, box-shadow 150ms ease; +} + +.cfg-select:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--focus); +} + +:root[data-theme="light"] .cfg-select { + background-color: #fff; +} + +/* Segmented Control */ +.cfg-segmented { + display: inline-flex; + padding: 3px; + border: 1px solid var(--border); + border-radius: 8px; + background: rgba(0, 0, 0, 0.12); +} + +:root[data-theme="light"] .cfg-segmented { + background: rgba(0, 0, 0, 0.04); +} + +.cfg-segmented__btn { + padding: 8px 16px; + border: none; + border-radius: 6px; + background: transparent; + color: var(--muted); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: background 150ms ease, color 150ms ease, box-shadow 150ms ease; +} + +.cfg-segmented__btn:hover:not(:disabled):not(.active) { + color: var(--text); +} + +.cfg-segmented__btn.active { + background: rgba(255, 255, 255, 0.12); + color: var(--text); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); +} + +:root[data-theme="light"] .cfg-segmented__btn.active { + background: #fff; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +.cfg-segmented__btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Toggle Row */ +.cfg-toggle-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; - padding: 12px 0; - border-bottom: 1px solid rgba(255, 255, 255, 0.06); + padding: 14px 16px; + border: 1px solid var(--border); + border-radius: 10px; + background: rgba(0, 0, 0, 0.06); + cursor: pointer; + transition: background 150ms ease, border-color 150ms ease; } -:root[data-theme="light"] .field-row { - border-bottom-color: rgba(0, 0, 0, 0.06); +.cfg-toggle-row:hover:not(.disabled) { + background: rgba(0, 0, 0, 0.1); + border-color: var(--border-strong); } -.field-row:last-child { - border-bottom: none; +.cfg-toggle-row.disabled { + opacity: 0.6; + cursor: not-allowed; } -.field-row__info { +:root[data-theme="light"] .cfg-toggle-row { + background: rgba(255, 255, 255, 0.5); +} + +:root[data-theme="light"] .cfg-toggle-row:hover:not(.disabled) { + background: rgba(255, 255, 255, 0.8); +} + +.cfg-toggle-row__content { flex: 1; min-width: 0; } -.field-row__label { +.cfg-toggle-row__label { display: block; font-size: 14px; font-weight: 500; color: var(--text); } -.field-row__help { +.cfg-toggle-row__help { display: block; margin-top: 2px; font-size: 12px; @@ -548,89 +793,411 @@ line-height: 1.4; } -/* Pills */ -.pill--sm { +/* 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: 48px; + height: 28px; + background: rgba(255, 255, 255, 0.12); + border: 1px solid var(--border); + border-radius: 999px; + position: relative; + transition: background 200ms ease, border-color 200ms ease; +} + +:root[data-theme="light"] .cfg-toggle__track { + background: rgba(0, 0, 0, 0.1); +} + +.cfg-toggle__track::after { + content: ""; + position: absolute; + top: 3px; + left: 3px; + width: 20px; + height: 20px; + background: var(--text); + border-radius: 50%; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); + transition: transform 200ms ease, background 200ms ease; +} + +.cfg-toggle input:checked + .cfg-toggle__track { + background: rgba(43, 217, 127, 0.25); + border-color: rgba(43, 217, 127, 0.5); +} + +.cfg-toggle input:checked + .cfg-toggle__track::after { + transform: translateX(20px); + background: var(--ok); +} + +.cfg-toggle input:focus + .cfg-toggle__track { + box-shadow: 0 0 0 3px var(--focus); +} + +/* Object (collapsible) */ +.cfg-object { + border: 1px solid var(--border); + border-radius: 10px; + background: rgba(0, 0, 0, 0.04); + overflow: hidden; +} + +:root[data-theme="light"] .cfg-object { + background: rgba(255, 255, 255, 0.4); +} + +.cfg-object__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + cursor: pointer; + list-style: none; + transition: background 150ms ease; +} + +.cfg-object__header:hover { + background: rgba(255, 255, 255, 0.03); +} + +:root[data-theme="light"] .cfg-object__header:hover { + background: rgba(0, 0, 0, 0.02); +} + +.cfg-object__header::-webkit-details-marker { + display: none; +} + +.cfg-object__title { + font-size: 14px; + font-weight: 600; + color: var(--text); +} + +.cfg-object__chevron { + width: 18px; + height: 18px; + color: var(--muted); + transition: transform 200ms ease; +} + +.cfg-object__chevron svg { + width: 100%; + height: 100%; +} + +.cfg-object[open] .cfg-object__chevron { + transform: rotate(180deg); +} + +.cfg-object__help { + padding: 0 16px 12px; + font-size: 12px; + color: var(--muted); + border-bottom: 1px solid var(--border); +} + +.cfg-object__content { + padding: 16px; + display: grid; + gap: 16px; +} + +/* Array */ +.cfg-array { + border: 1px solid var(--border); + border-radius: 10px; + overflow: hidden; +} + +.cfg-array__header { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + background: rgba(0, 0, 0, 0.06); + border-bottom: 1px solid var(--border); +} + +:root[data-theme="light"] .cfg-array__header { + background: rgba(0, 0, 0, 0.02); +} + +.cfg-array__label { + flex: 1; + font-size: 14px; + font-weight: 600; + color: var(--text); +} + +.cfg-array__count { + font-size: 12px; + color: var(--muted); padding: 3px 8px; - font-size: 10px; + background: rgba(255, 255, 255, 0.06); + border-radius: 999px; +} + +:root[data-theme="light"] .cfg-array__count { + background: rgba(0, 0, 0, 0.06); +} + +.cfg-array__add { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border: 1px solid var(--border); + border-radius: 6px; + background: rgba(255, 255, 255, 0.05); + color: var(--text); + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: background 150ms ease; +} + +.cfg-array__add:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.1); +} + +.cfg-array__add:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.cfg-array__add-icon { + width: 14px; + height: 14px; +} + +.cfg-array__add-icon svg { + width: 100%; + height: 100%; +} + +.cfg-array__help { + padding: 10px 16px; + font-size: 12px; + color: var(--muted); + border-bottom: 1px solid var(--border); +} + +.cfg-array__empty { + padding: 32px 16px; + text-align: center; + color: var(--muted); + font-size: 13px; +} + +.cfg-array__items { + display: grid; + gap: 1px; + background: var(--border); +} + +.cfg-array__item { + background: var(--panel); +} + +.cfg-array__item-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 16px; + background: rgba(0, 0, 0, 0.04); + border-bottom: 1px solid var(--border); +} + +:root[data-theme="light"] .cfg-array__item-header { + background: rgba(0, 0, 0, 0.02); +} + +.cfg-array__item-index { + font-size: 11px; + font-weight: 600; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.cfg-array__item-remove { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + border: none; + border-radius: 6px; + background: transparent; + color: var(--muted); + cursor: pointer; + transition: background 150ms ease, color 150ms ease; +} + +.cfg-array__item-remove svg { + width: 16px; + height: 16px; +} + +.cfg-array__item-remove:hover:not(:disabled) { + background: rgba(255, 92, 92, 0.15); + color: var(--danger); +} + +.cfg-array__item-remove:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.cfg-array__item-content { + padding: 16px; +} + +/* Map (custom entries) */ +.cfg-map { + border: 1px solid var(--border); + border-radius: 10px; + overflow: hidden; +} + +.cfg-map__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 16px; + background: rgba(0, 0, 0, 0.06); + border-bottom: 1px solid var(--border); +} + +:root[data-theme="light"] .cfg-map__header { + background: rgba(0, 0, 0, 0.02); +} + +.cfg-map__label { + font-size: 13px; + font-weight: 600; + color: var(--muted); +} + +.cfg-map__add { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border: 1px solid var(--border); + border-radius: 6px; + background: rgba(255, 255, 255, 0.05); + color: var(--text); + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: background 150ms ease; +} + +.cfg-map__add:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.1); +} + +.cfg-map__add-icon { + width: 14px; + height: 14px; +} + +.cfg-map__add-icon svg { + width: 100%; + height: 100%; +} + +.cfg-map__empty { + padding: 24px 16px; + text-align: center; + color: var(--muted); + font-size: 13px; +} + +.cfg-map__items { + display: grid; + gap: 8px; + padding: 12px; +} + +.cfg-map__item { + display: grid; + grid-template-columns: 140px 1fr auto; + gap: 8px; + align-items: start; +} + +.cfg-map__item-key { + min-width: 0; +} + +.cfg-map__item-value { + min-width: 0; +} + +.cfg-map__item-remove { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border: none; + border-radius: 6px; + background: transparent; + color: var(--muted); + cursor: pointer; + transition: background 150ms ease, color 150ms ease; +} + +.cfg-map__item-remove svg { + width: 16px; + height: 16px; +} + +.cfg-map__item-remove:hover:not(:disabled) { + background: rgba(255, 92, 92, 0.15); + color: var(--danger); +} + +/* Pill variants */ +.pill--sm { + padding: 4px 10px; + font-size: 11px; } .pill--ok { - border-color: rgba(43, 217, 127, 0.5); + border-color: rgba(43, 217, 127, 0.4); color: var(--ok); } .pill--danger { - border-color: rgba(255, 92, 92, 0.5); + border-color: rgba(255, 92, 92, 0.4); color: var(--danger); } -/* Improved Fieldset */ -.config-form--modern .fieldset { - border: 1px solid var(--border); - border-radius: 12px; - padding: 14px; - margin-top: 12px; - background: rgba(0, 0, 0, 0.08); -} - -:root[data-theme="light"] .config-form--modern .fieldset { - background: rgba(255, 255, 255, 0.5); -} - -.config-form--modern .fieldset > .legend { - font-weight: 600; - font-size: 13px; - color: var(--text); - margin-bottom: 12px; - padding-bottom: 10px; - border-bottom: 1px dashed var(--border); -} - -.config-form--modern .field { - margin-bottom: 12px; -} - -.config-form--modern .field:last-child { - margin-bottom: 0; -} - -.config-form--modern .field span { - font-size: 12px; - font-weight: 500; - color: var(--muted); -} - -.config-form--modern .field input, -.config-form--modern .field select, -.config-form--modern .field textarea { - font-size: 14px; -} - -/* Array improvements */ -.config-form--modern .array { - display: grid; - gap: 10px; -} - -.config-form--modern .array-item { - display: flex; - gap: 12px; - align-items: flex-start; - padding: 12px; - border: 1px solid var(--border); - border-radius: 10px; - background: rgba(0, 0, 0, 0.1); -} - -:root[data-theme="light"] .config-form--modern .array-item { - background: rgba(255, 255, 255, 0.6); -} - -/* Mobile Responsiveness */ +/* =========================================== + Mobile Responsiveness + =========================================== */ @media (max-width: 768px) { .config-layout { grid-template-columns: 1fr; - min-height: auto; } .config-sidebar { @@ -638,22 +1205,27 @@ border-bottom: 1px solid var(--border); } + .config-sidebar__header { + padding: 12px 16px; + } + .config-nav { display: flex; - flex-wrap: wrap; - gap: 6px; - padding: 12px; - max-height: none; - overflow: visible; + flex-wrap: nowrap; + gap: 4px; + padding: 8px 12px; + overflow-x: auto; + -webkit-overflow-scrolling: touch; } .config-nav__item { flex: 0 0 auto; padding: 8px 12px; + white-space: nowrap; } - .config-nav__icon { - width: auto; + .config-nav__label { + display: inline; } .config-sidebar__footer { @@ -662,47 +1234,67 @@ .config-actions { flex-wrap: wrap; + padding: 12px 16px; } - .config-actions__left { - width: 100%; - justify-content: center; - } - + .config-actions__left, .config-actions__right { width: 100%; justify-content: center; } - .config-diff__values { - flex-wrap: wrap; + .config-content { + padding: 16px; } - .field-row { - flex-direction: column; - align-items: flex-start; - gap: 10px; + .config-section-card__header { + padding: 14px 16px; } - .config-form--modern .array-item { - flex-direction: column; + .config-section-card__content { + padding: 16px; + } + + .cfg-toggle-row { + padding: 12px 14px; + } + + .cfg-map__item { + grid-template-columns: 1fr; + gap: 8px; + } + + .cfg-map__item-remove { + justify-self: end; } } @media (max-width: 480px) { + .config-nav__icon { + width: 24px; + height: 24px; + font-size: 16px; + } + .config-nav__label { display: none; } - .config-nav__item { - padding: 10px; + .config-section-card__icon { + width: 28px; + height: 28px; } - .config-section-card__header { - padding: 12px; + .config-section-card__title { + font-size: 15px; } - .config-section-card__content { - padding: 12px; + .cfg-segmented { + flex-wrap: wrap; + } + + .cfg-segmented__btn { + flex: 1 0 auto; + min-width: 60px; } } diff --git a/ui/src/ui/views/config-form.node.ts b/ui/src/ui/views/config-form.node.ts index 76a6e6195..6024c2407 100644 --- a/ui/src/ui/views/config-form.node.ts +++ b/ui/src/ui/views/config-form.node.ts @@ -26,6 +26,15 @@ function jsonValue(value: unknown): string { } } +// SVG Icons as template literals +const icons = { + chevronDown: html``, + plus: html``, + minus: html``, + trash: html``, + edit: html``, +}; + export function renderNode(params: { schema: JsonSchema; value: unknown; @@ -44,27 +53,29 @@ export function renderNode(params: { const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1))); const help = hint?.help ?? schema.description; const key = pathKey(path); + const hasDefault = schema.default !== undefined; + const isDefault = hasDefault && JSON.stringify(value) === JSON.stringify(schema.default); + const isEmpty = value === undefined || value === null || value === ""; if (unsupported.has(key)) { - return html`
- ${label}: unsupported schema node. Use Raw. + return html`
+
${label}
+
Unsupported schema node. Use Raw mode.
`; } + // Handle anyOf/oneOf unions if (schema.anyOf || schema.oneOf) { const variants = schema.anyOf ?? schema.oneOf ?? []; const nonNull = variants.filter( - (v) => - !( - v.type === "null" || - (Array.isArray(v.type) && v.type.includes("null")) - ), + (v) => !(v.type === "null" || (Array.isArray(v.type) && v.type.includes("null"))) ); if (nonNull.length === 1) { return renderNode({ ...params, schema: nonNull[0] }); } + // Check if it's a set of literal values (enum-like) const extractLiteral = (v: JsonSchema): unknown | undefined => { if (v.const !== undefined) return v.const; if (v.enum && v.enum.length === 1) return v.enum[0]; @@ -73,137 +84,314 @@ export function renderNode(params: { const literals = nonNull.map(extractLiteral); const allLiterals = literals.every((v) => v !== undefined); - if (allLiterals && literals.length > 0) { + if (allLiterals && literals.length > 0 && literals.length <= 5) { + // Use segmented control for small sets const resolvedValue = value ?? schema.default; - const currentIndex = literals.findIndex( - (lit) => - lit === resolvedValue || String(lit) === String(resolvedValue), - ); return html` - +
+ ${showLabel ? html`` : nothing} + ${help ? html`
${help}
` : nothing} +
+ ${literals.map((lit, idx) => html` + + `)} +
+
`; } + if (allLiterals && literals.length > 5) { + // Use dropdown for larger sets + return renderSelect({ ...params, options: literals.map(String), value: String(value ?? schema.default ?? "") }); + } + + // Handle mixed primitive types const primitiveTypes = new Set( - nonNull - .map((variant) => schemaType(variant)) - .filter((variant): variant is string => Boolean(variant)), + nonNull.map((variant) => schemaType(variant)).filter(Boolean) ); const normalizedTypes = new Set( - [...primitiveTypes].map((variant) => (variant === "integer" ? "number" : variant)), - ); - const primitiveOnly = [...normalizedTypes].every((variant) => - ["string", "number", "boolean"].includes(variant), + [...primitiveTypes].map((v) => (v === "integer" ? "number" : v)) ); - if (primitiveOnly && normalizedTypes.size > 0) { + if ([...normalizedTypes].every((v) => ["string", "number", "boolean"].includes(v as string))) { const hasString = normalizedTypes.has("string"); const hasNumber = normalizedTypes.has("number"); - const hasBoolean = normalizedTypes.has("boolean"); - - if (hasBoolean && normalizedTypes.size === 1) { - return renderNode({ - ...params, - schema: { ...schema, type: "boolean", anyOf: undefined, oneOf: undefined }, - }); - } - + if (hasString || hasNumber) { - const displayValue = value ?? schema.default ?? ""; - return html` - - `; + return renderTextInput({ + ...params, + inputType: hasNumber && !hasString ? "number" : "text", + }); } } } + // Enum - use segmented for small, dropdown for large if (schema.enum) { const options = schema.enum; - const resolvedValue = value ?? schema.default; - const currentIndex = options.findIndex( - (opt) => - opt === resolvedValue || String(opt) === String(resolvedValue), - ); - const unset = "__unset__"; + if (options.length <= 5) { + const resolvedValue = value ?? schema.default; + return html` +
+ ${showLabel ? html`` : nothing} + ${help ? html`
${help}
` : nothing} +
+ ${options.map((opt) => html` + + `)} +
+
+ `; + } + return renderSelect({ ...params, options: options.map(String), value: String(value ?? schema.default ?? "") }); + } + + // Object type - collapsible section + if (type === "object") { + return renderObject(params); + } + + // Array type + if (type === "array") { + return renderArray(params); + } + + // Boolean - toggle row + if (type === "boolean") { + const displayValue = typeof value === "boolean" ? value : typeof schema.default === "boolean" ? schema.default : false; return html` -