feat(ui): major config form UX overhaul

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
This commit is contained in:
Maude Bot
2026-01-20 11:40:13 -05:00
parent bd8f4b052d
commit f6abe62e5f
4 changed files with 1414 additions and 603 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -26,6 +26,15 @@ function jsonValue(value: unknown): string {
}
}
// SVG Icons as template literals
const icons = {
chevronDown: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"></polyline></svg>`,
plus: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>`,
minus: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="12" x2="19" y2="12"></line></svg>`,
trash: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>`,
edit: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg>`,
};
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`<div class="callout danger">
${label}: unsupported schema node. Use Raw.
return html`<div class="cfg-field cfg-field--error">
<div class="cfg-field__label">${label}</div>
<div class="cfg-field__error">Unsupported schema node. Use Raw mode.</div>
</div>`;
}
// 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`
<label class="field">
${showLabel ? html`<span>${label}</span>` : nothing}
${help ? html`<div class="muted">${help}</div>` : nothing}
<select
.value=${currentIndex >= 0 ? String(currentIndex) : ""}
?disabled=${disabled}
@change=${(e: Event) => {
const idx = (e.target as HTMLSelectElement).value;
onPatch(path, idx === "" ? undefined : literals[Number(idx)]);
}}
>
<option value="">Select…</option>
${literals.map(
(lit, idx) =>
html`<option value=${String(idx)}>${String(lit)}</option>`,
)}
</select>
</label>
<div class="cfg-field">
${showLabel ? html`<label class="cfg-field__label">${label}</label>` : nothing}
${help ? html`<div class="cfg-field__help">${help}</div>` : nothing}
<div class="cfg-segmented">
${literals.map((lit, idx) => html`
<button
type="button"
class="cfg-segmented__btn ${lit === resolvedValue || String(lit) === String(resolvedValue) ? 'active' : ''}"
?disabled=${disabled}
@click=${() => onPatch(path, lit)}
>
${String(lit)}
</button>
`)}
</div>
</div>
`;
}
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`
<label class="field">
${showLabel ? html`<span>${label}</span>` : nothing}
${help ? html`<div class="muted">${help}</div>` : nothing}
<input
type=${hasNumber && !hasString ? "number" : "text"}
.value=${displayValue == null ? "" : String(displayValue)}
?disabled=${disabled}
@input=${(e: Event) => {
const raw = (e.target as HTMLInputElement).value;
if (hasString || !hasNumber || raw.trim() === "" || /[^0-9-.]/.test(raw)) {
onPatch(path, raw === "" ? undefined : raw);
return;
}
const parsed = Number(raw);
onPatch(path, Number.isNaN(parsed) ? raw : parsed);
}}
/>
</label>
`;
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`
<div class="cfg-field">
${showLabel ? html`<label class="cfg-field__label">${label}</label>` : nothing}
${help ? html`<div class="cfg-field__help">${help}</div>` : nothing}
<div class="cfg-segmented">
${options.map((opt) => html`
<button
type="button"
class="cfg-segmented__btn ${opt === resolvedValue || String(opt) === String(resolvedValue) ? 'active' : ''}"
?disabled=${disabled}
@click=${() => onPatch(path, opt)}
>
${String(opt)}
</button>
`)}
</div>
</div>
`;
}
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`
<label class="field">
${showLabel ? html`<span>${label}</span>` : nothing}
${help ? html`<div class="muted">${help}</div>` : nothing}
<select
.value=${currentIndex >= 0 ? String(currentIndex) : unset}
?disabled=${disabled}
@change=${(e: Event) => {
const idx = (e.target as HTMLSelectElement).value;
onPatch(path, idx === unset ? undefined : options[Number(idx)]);
}}
>
<option value=${unset}>Select…</option>
${options.map(
(opt, idx) =>
html`<option value=${String(idx)}>${String(opt)}</option>`,
)}
</select>
<label class="cfg-toggle-row ${disabled ? 'disabled' : ''}">
<div class="cfg-toggle-row__content">
<span class="cfg-toggle-row__label">${label}</span>
${help ? html`<span class="cfg-toggle-row__help">${help}</span>` : nothing}
</div>
<div class="cfg-toggle">
<input
type="checkbox"
.checked=${displayValue}
?disabled=${disabled}
@change=${(e: Event) => onPatch(path, (e.target as HTMLInputElement).checked)}
/>
<span class="cfg-toggle__track"></span>
</div>
</label>
`;
}
if (type === "object") {
const fallback = value ?? schema.default;
const obj =
fallback && typeof fallback === "object" && !Array.isArray(fallback)
? (fallback as Record<string, unknown>)
: {};
const props = schema.properties ?? {};
const entries = Object.entries(props);
const sorted = entries.sort((a, b) => {
const orderA = hintForPath([...path, a[0]], hints)?.order ?? 0;
const orderB = hintForPath([...path, b[0]], hints)?.order ?? 0;
if (orderA !== orderB) return orderA - orderB;
return a[0].localeCompare(b[0]);
});
const reserved = new Set(Object.keys(props));
const additional = schema.additionalProperties;
const allowExtra = Boolean(additional) && typeof additional === "object";
// Number/Integer
if (type === "number" || type === "integer") {
return renderNumberInput(params);
}
// String
if (type === "string") {
return renderTextInput({ ...params, inputType: "text" });
}
// Fallback
return html`
<div class="cfg-field cfg-field--error">
<div class="cfg-field__label">${label}</div>
<div class="cfg-field__error">Unsupported type: ${type}. Use Raw mode.</div>
</div>
`;
}
function renderTextInput(params: {
schema: JsonSchema;
value: unknown;
path: Array<string | number>;
hints: ConfigUiHints;
disabled: boolean;
showLabel?: boolean;
inputType: "text" | "number";
onPatch: (path: Array<string | number>, value: unknown) => void;
}): TemplateResult {
const { schema, value, path, hints, disabled, onPatch, inputType } = params;
const showLabel = params.showLabel ?? true;
const hint = hintForPath(path, hints);
const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1)));
const help = hint?.help ?? schema.description;
const isSensitive = hint?.sensitive ?? isSensitivePath(path);
const placeholder = hint?.placeholder ?? (schema.default !== undefined ? `Default: ${schema.default}` : "");
const displayValue = value ?? "";
return html`
<div class="cfg-field">
${showLabel ? html`<label class="cfg-field__label">${label}</label>` : nothing}
${help ? html`<div class="cfg-field__help">${help}</div>` : nothing}
<div class="cfg-input-wrap">
<input
type=${isSensitive ? "password" : inputType}
class="cfg-input"
placeholder=${placeholder}
.value=${displayValue == null ? "" : String(displayValue)}
?disabled=${disabled}
@input=${(e: Event) => {
const raw = (e.target as HTMLInputElement).value;
if (inputType === "number" && raw.trim() !== "") {
const parsed = Number(raw);
onPatch(path, Number.isNaN(parsed) ? raw : parsed);
} else {
onPatch(path, raw === "" ? undefined : raw);
}
}}
/>
${schema.default !== undefined ? html`
<button
type="button"
class="cfg-input__reset"
title="Reset to default"
?disabled=${disabled}
@click=${() => onPatch(path, schema.default)}
>↺</button>
` : nothing}
</div>
</div>
`;
}
function renderNumberInput(params: {
schema: JsonSchema;
value: unknown;
path: Array<string | number>;
hints: ConfigUiHints;
disabled: boolean;
showLabel?: boolean;
onPatch: (path: Array<string | number>, value: unknown) => void;
}): TemplateResult {
const { schema, value, path, hints, disabled, onPatch } = params;
const showLabel = params.showLabel ?? true;
const hint = hintForPath(path, hints);
const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1)));
const help = hint?.help ?? schema.description;
const displayValue = value ?? schema.default ?? "";
const numValue = typeof displayValue === "number" ? displayValue : 0;
return html`
<div class="cfg-field">
${showLabel ? html`<label class="cfg-field__label">${label}</label>` : nothing}
${help ? html`<div class="cfg-field__help">${help}</div>` : nothing}
<div class="cfg-number">
<button
type="button"
class="cfg-number__btn"
?disabled=${disabled}
@click=${() => onPatch(path, numValue - 1)}
></button>
<input
type="number"
class="cfg-number__input"
.value=${displayValue == null ? "" : String(displayValue)}
?disabled=${disabled}
@input=${(e: Event) => {
const raw = (e.target as HTMLInputElement).value;
const parsed = raw === "" ? undefined : Number(raw);
onPatch(path, parsed);
}}
/>
<button
type="button"
class="cfg-number__btn"
?disabled=${disabled}
@click=${() => onPatch(path, numValue + 1)}
>+</button>
</div>
</div>
`;
}
function renderSelect(params: {
schema: JsonSchema;
value: string;
path: Array<string | number>;
hints: ConfigUiHints;
disabled: boolean;
showLabel?: boolean;
options: string[];
onPatch: (path: Array<string | number>, value: unknown) => void;
}): TemplateResult {
const { schema, value, path, hints, disabled, options, onPatch } = params;
const showLabel = params.showLabel ?? true;
const hint = hintForPath(path, hints);
const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1)));
const help = hint?.help ?? schema.description;
return html`
<div class="cfg-field">
${showLabel ? html`<label class="cfg-field__label">${label}</label>` : nothing}
${help ? html`<div class="cfg-field__help">${help}</div>` : nothing}
<select
class="cfg-select"
?disabled=${disabled}
@change=${(e: Event) => {
const val = (e.target as HTMLSelectElement).value;
onPatch(path, val === "" ? undefined : val);
}}
>
<option value="" ?selected=${!value}>Select...</option>
${options.map(opt => html`
<option value=${opt} ?selected=${opt === value}>${opt}</option>
`)}
</select>
</div>
`;
}
function renderObject(params: {
schema: JsonSchema;
value: unknown;
path: Array<string | number>;
hints: ConfigUiHints;
unsupported: Set<string>;
disabled: boolean;
showLabel?: boolean;
searchQuery?: string;
onPatch: (path: Array<string | number>, value: unknown) => void;
}): TemplateResult {
const { schema, value, path, hints, unsupported, disabled, onPatch, searchQuery } = params;
const showLabel = params.showLabel ?? true;
const hint = hintForPath(path, hints);
const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1)));
const help = hint?.help ?? schema.description;
const fallback = value ?? schema.default;
const obj = fallback && typeof fallback === "object" && !Array.isArray(fallback)
? (fallback as Record<string, unknown>)
: {};
const props = schema.properties ?? {};
const entries = Object.entries(props);
// Sort by hint order
const sorted = entries.sort((a, b) => {
const orderA = hintForPath([...path, a[0]], hints)?.order ?? 0;
const orderB = hintForPath([...path, b[0]], hints)?.order ?? 0;
if (orderA !== orderB) return orderA - orderB;
return a[0].localeCompare(b[0]);
});
const reserved = new Set(Object.keys(props));
const additional = schema.additionalProperties;
const allowExtra = Boolean(additional) && typeof additional === "object";
// For top-level, don't wrap in collapsible
if (path.length === 1) {
return html`
<div class="fieldset">
${showLabel ? html`<div class="legend">${label}</div>` : nothing}
${help ? html`<div class="muted">${help}</div>` : nothing}
<div class="cfg-fields">
${sorted.map(([propKey, node]) =>
renderNode({
schema: node,
@@ -213,162 +401,151 @@ export function renderNode(params: {
unsupported,
disabled,
onPatch,
}),
searchQuery,
})
)}
${allowExtra
? renderMapField({
schema: additional as JsonSchema,
value: obj,
path,
hints,
unsupported,
disabled,
reservedKeys: reserved,
onPatch,
})
: nothing}
${allowExtra ? renderMapField({
schema: additional as JsonSchema,
value: obj,
path,
hints,
unsupported,
disabled,
reservedKeys: reserved,
onPatch,
}) : nothing}
</div>
`;
}
if (type === "array") {
const itemsSchema = Array.isArray(schema.items)
? schema.items[0]
: schema.items;
if (!itemsSchema) {
return html`<div class="field">
${showLabel ? html`<span>${label}</span>` : nothing}
<div class="muted">Unsupported array schema. Use Raw.</div>
</div>`;
}
const arr = Array.isArray(value)
? value
: Array.isArray(schema.default)
? schema.default
: [];
// Nested objects get collapsible treatment
return html`
<details class="cfg-object" open>
<summary class="cfg-object__header">
<span class="cfg-object__title">${label}</span>
<span class="cfg-object__chevron">${icons.chevronDown}</span>
</summary>
${help ? html`<div class="cfg-object__help">${help}</div>` : nothing}
<div class="cfg-object__content">
${sorted.map(([propKey, node]) =>
renderNode({
schema: node,
value: obj[propKey],
path: [...path, propKey],
hints,
unsupported,
disabled,
onPatch,
searchQuery,
})
)}
${allowExtra ? renderMapField({
schema: additional as JsonSchema,
value: obj,
path,
hints,
unsupported,
disabled,
reservedKeys: reserved,
onPatch,
}) : nothing}
</div>
</details>
`;
}
function renderArray(params: {
schema: JsonSchema;
value: unknown;
path: Array<string | number>;
hints: ConfigUiHints;
unsupported: Set<string>;
disabled: boolean;
showLabel?: boolean;
searchQuery?: string;
onPatch: (path: Array<string | number>, value: unknown) => void;
}): TemplateResult {
const { schema, value, path, hints, unsupported, disabled, onPatch, searchQuery } = params;
const showLabel = params.showLabel ?? true;
const hint = hintForPath(path, hints);
const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1)));
const help = hint?.help ?? schema.description;
const itemsSchema = Array.isArray(schema.items) ? schema.items[0] : schema.items;
if (!itemsSchema) {
return html`
<div class="field" style="margin-top: 12px;">
${showLabel ? html`<span>${label}</span>` : nothing}
${help ? html`<div class="muted">${help}</div>` : nothing}
<div class="array">
${arr.map((item, idx) => {
const itemPath = [...path, idx];
return html`<div class="array-item">
<div style="flex: 1;">
<div class="cfg-field cfg-field--error">
<div class="cfg-field__label">${label}</div>
<div class="cfg-field__error">Unsupported array schema. Use Raw mode.</div>
</div>
`;
}
const arr = Array.isArray(value) ? value : Array.isArray(schema.default) ? schema.default : [];
return html`
<div class="cfg-array">
<div class="cfg-array__header">
${showLabel ? html`<span class="cfg-array__label">${label}</span>` : nothing}
<span class="cfg-array__count">${arr.length} item${arr.length !== 1 ? 's' : ''}</span>
<button
type="button"
class="cfg-array__add"
?disabled=${disabled}
@click=${() => {
const next = [...arr, defaultValue(itemsSchema)];
onPatch(path, next);
}}
>
<span class="cfg-array__add-icon">${icons.plus}</span>
Add
</button>
</div>
${help ? html`<div class="cfg-array__help">${help}</div>` : nothing}
${arr.length === 0 ? html`
<div class="cfg-array__empty">
No items yet. Click "Add" to create one.
</div>
` : html`
<div class="cfg-array__items">
${arr.map((item, idx) => html`
<div class="cfg-array__item">
<div class="cfg-array__item-header">
<span class="cfg-array__item-index">#${idx + 1}</span>
<button
type="button"
class="cfg-array__item-remove"
title="Remove item"
?disabled=${disabled}
@click=${() => {
const next = [...arr];
next.splice(idx, 1);
onPatch(path, next);
}}
>
${icons.trash}
</button>
</div>
<div class="cfg-array__item-content">
${renderNode({
schema: itemsSchema,
value: item,
path: itemPath,
path: [...path, idx],
hints,
unsupported,
disabled,
showLabel: false,
onPatch,
searchQuery,
})}
</div>
<button
class="btn danger"
?disabled=${disabled}
@click=${() => {
const next = [...arr];
next.splice(idx, 1);
onPatch(path, next);
}}
>
Remove
</button>
</div>`;
})}
<button
class="btn"
?disabled=${disabled}
@click=${() => {
const next = [...arr];
next.push(defaultValue(itemsSchema));
onPatch(path, next);
}}
>
Add
</button>
</div>
`)}
</div>
</div>
`;
}
if (type === "boolean") {
const displayValue =
typeof value === "boolean"
? value
: typeof schema.default === "boolean"
? schema.default
: false;
return html`
<div class="field-row">
<div class="field-row__info">
${showLabel ? html`<span class="field-row__label">${label}</span>` : nothing}
${help ? html`<span class="field-row__help">${help}</span>` : nothing}
</div>
<label class="toggle-switch">
<input
type="checkbox"
.checked=${displayValue}
?disabled=${disabled}
@change=${(e: Event) =>
onPatch(path, (e.target as HTMLInputElement).checked)}
/>
<span class="toggle-switch__track">
<span class="toggle-switch__thumb"></span>
</span>
</label>
</div>
`;
}
if (type === "number" || type === "integer") {
const displayValue = value ?? schema.default;
return html`
<label class="field">
${showLabel ? html`<span>${label}</span>` : nothing}
${help ? html`<div class="muted">${help}</div>` : nothing}
<input
type="number"
.value=${displayValue == null ? "" : String(displayValue)}
?disabled=${disabled}
@input=${(e: Event) => {
const raw = (e.target as HTMLInputElement).value;
const parsed = raw === "" ? undefined : Number(raw);
onPatch(path, parsed);
}}
/>
</label>
`;
}
if (type === "string") {
const isSensitive = hint?.sensitive ?? isSensitivePath(path);
const placeholder = hint?.placeholder ?? (isSensitive ? "••••" : "");
const displayValue = value ?? schema.default ?? "";
return html`
<label class="field">
${showLabel ? html`<span>${label}</span>` : nothing}
${help ? html`<div class="muted">${help}</div>` : nothing}
<input
type=${isSensitive ? "password" : "text"}
placeholder=${placeholder}
.value=${displayValue == null ? "" : String(displayValue)}
?disabled=${disabled}
@input=${(e: Event) =>
onPatch(path, (e.target as HTMLInputElement).value)}
/>
</label>
`;
}
return html`<div class="field">
${showLabel ? html`<span>${label}</span>` : nothing}
<div class="muted">Unsupported type. Use Raw.</div>
</div>`;
`}
</div>
`;
}
function renderMapField(params: {
@@ -381,103 +558,115 @@ function renderMapField(params: {
reservedKeys: Set<string>;
onPatch: (path: Array<string | number>, value: unknown) => void;
}): TemplateResult {
const { schema, value, path, hints, unsupported, disabled, reservedKeys, onPatch } =
params;
const { schema, value, path, hints, unsupported, disabled, reservedKeys, onPatch } = params;
const anySchema = isAnySchema(schema);
const entries = Object.entries(value ?? {}).filter(
([key]) => !reservedKeys.has(key),
);
const entries = Object.entries(value ?? {}).filter(([key]) => !reservedKeys.has(key));
return html`
<div class="field" style="margin-top: 12px;">
<div class="row" style="justify-content: space-between;">
<span class="muted">Extra entries</span>
<div class="cfg-map">
<div class="cfg-map__header">
<span class="cfg-map__label">Custom entries</span>
<button
class="btn"
type="button"
class="cfg-map__add"
?disabled=${disabled}
@click=${() => {
const next = { ...(value ?? {}) };
let index = 1;
let key = `new-${index}`;
let key = `custom-${index}`;
while (key in next) {
index += 1;
key = `new-${index}`;
key = `custom-${index}`;
}
next[key] = anySchema ? {} : defaultValue(schema);
onPatch(path, next);
}}
>
Add
<span class="cfg-map__add-icon">${icons.plus}</span>
Add Entry
</button>
</div>
${entries.length === 0
? html`<div class="muted">No entries yet.</div>`
: entries.map(([key, entryValue]) => {
${entries.length === 0 ? html`
<div class="cfg-map__empty">No custom entries.</div>
` : html`
<div class="cfg-map__items">
${entries.map(([key, entryValue]) => {
const valuePath = [...path, key];
const fallback = jsonValue(entryValue);
return html`<div class="array-item" style="gap: 8px;">
<input
class="mono"
style="min-width: 140px;"
?disabled=${disabled}
.value=${key}
@change=${(e: Event) => {
const nextKey = (e.target as HTMLInputElement).value.trim();
if (!nextKey || nextKey === key) return;
const next = { ...(value ?? {}) };
if (nextKey in next) return;
next[nextKey] = next[key];
delete next[key];
onPatch(path, next);
}}
/>
<div style="flex: 1;">
${anySchema
? html`<label class="field" style="margin: 0;">
<div class="muted">JSON value</div>
<textarea
class="mono"
rows="5"
.value=${fallback}
?disabled=${disabled}
@change=${(e: Event) => {
const target = e.target as HTMLTextAreaElement;
const raw = target.value.trim();
if (!raw) {
onPatch(valuePath, undefined);
return;
}
try {
onPatch(valuePath, JSON.parse(raw));
} catch {
target.value = fallback;
}
}}
></textarea>
</label>`
: renderNode({
schema,
value: entryValue,
path: valuePath,
hints,
unsupported,
disabled,
showLabel: false,
onPatch,
})}
return html`
<div class="cfg-map__item">
<div class="cfg-map__item-key">
<input
type="text"
class="cfg-input cfg-input--sm"
placeholder="Key"
.value=${key}
?disabled=${disabled}
@change=${(e: Event) => {
const nextKey = (e.target as HTMLInputElement).value.trim();
if (!nextKey || nextKey === key) return;
const next = { ...(value ?? {}) };
if (nextKey in next) return;
next[nextKey] = next[key];
delete next[key];
onPatch(path, next);
}}
/>
</div>
<div class="cfg-map__item-value">
${anySchema
? html`
<textarea
class="cfg-textarea cfg-textarea--sm"
placeholder="JSON value"
rows="2"
.value=${fallback}
?disabled=${disabled}
@change=${(e: Event) => {
const target = e.target as HTMLTextAreaElement;
const raw = target.value.trim();
if (!raw) {
onPatch(valuePath, undefined);
return;
}
try {
onPatch(valuePath, JSON.parse(raw));
} catch {
target.value = fallback;
}
}}
></textarea>
`
: renderNode({
schema,
value: entryValue,
path: valuePath,
hints,
unsupported,
disabled,
showLabel: false,
onPatch,
})}
</div>
<button
type="button"
class="cfg-map__item-remove"
title="Remove entry"
?disabled=${disabled}
@click=${() => {
const next = { ...(value ?? {}) };
delete next[key];
onPatch(path, next);
}}
>
${icons.trash}
</button>
</div>
<button
class="btn danger"
?disabled=${disabled}
@click=${() => {
const next = { ...(value ?? {}) };
delete next[key];
onPatch(path, next);
}}
>
Remove
</button>
</div>`;
`;
})}
</div>
`}
</div>
`;
}

View File

@@ -14,70 +14,79 @@ export type ConfigFormProps = {
onPatch: (path: Array<string | number>, value: unknown) => void;
};
// SVG Icons for section cards
const sectionIcons = {
env: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>`,
update: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>`,
agents: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M12 2a2 2 0 0 1 2 2c0 .74-.4 1.39-1 1.73V7h1a7 7 0 0 1 7 7h1a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-1v1a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-1H2a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1h1a7 7 0 0 1 7-7h1V5.73c-.6-.34-1-.99-1-1.73a2 2 0 0 1 2-2z"></path><circle cx="8" cy="14" r="1"></circle><circle cx="16" cy="14" r="1"></circle></svg>`,
auth: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path></svg>`,
channels: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path></svg>`,
messages: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path><polyline points="22,6 12,13 2,6"></polyline></svg>`,
commands: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><polyline points="4 17 10 11 4 5"></polyline><line x1="12" y1="19" x2="20" y2="19"></line></svg>`,
hooks: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>`,
skills: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon></svg>`,
tools: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"></path></svg>`,
gateway: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="10"></circle><line x1="2" y1="12" x2="22" y2="12"></line><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path></svg>`,
wizard: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M15 4V2"></path><path d="M15 16v-2"></path><path d="M8 9h2"></path><path d="M20 9h2"></path><path d="M17.8 11.8 19 13"></path><path d="M15 9h0"></path><path d="M17.8 6.2 19 5"></path><path d="m3 21 9-9"></path><path d="M12.2 6.2 11 5"></path></svg>`,
default: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline></svg>`,
};
// Section metadata
const SECTION_META: Record<string, { label: string; icon: string; description: string }> = {
const SECTION_META: Record<string, { label: string; description: string }> = {
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"
},
};
function getSectionIcon(key: string) {
return sectionIcons[key as keyof typeof sectionIcons] ?? sectionIcons.default;
}
function matchesSearch(key: string, schema: JsonSchema, query: string): boolean {
if (!query) return true;
const q = query.toLowerCase();
@@ -161,14 +170,13 @@ export function renderConfigForm(props: ConfigFormProps) {
${entries.map(([key, node]) => {
const meta = SECTION_META[key] ?? {
label: key.charAt(0).toUpperCase() + key.slice(1),
icon: "📄",
description: node.description ?? ""
};
return html`
<section class="config-section-card" id="config-section-${key}">
<div class="config-section-card__header">
<span class="config-section-card__icon">${meta.icon}</span>
<span class="config-section-card__icon">${getSectionIcon(key)}</span>
<div class="config-section-card__titles">
<h3 class="config-section-card__title">${meta.label}</h3>
${meta.description ? html`

View File

@@ -30,22 +30,44 @@ export type ConfigProps = {
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: "🧙" },
// SVG Icons for sidebar
const sidebarIcons = {
all: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"></rect><rect x="14" y="3" width="7" height="7"></rect><rect x="14" y="14" width="7" height="7"></rect><rect x="3" y="14" width="7" height="7"></rect></svg>`,
env: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>`,
update: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>`,
agents: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2a2 2 0 0 1 2 2c0 .74-.4 1.39-1 1.73V7h1a7 7 0 0 1 7 7h1a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-1v1a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-1H2a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1h1a7 7 0 0 1 7-7h1V5.73c-.6-.34-1-.99-1-1.73a2 2 0 0 1 2-2z"></path><circle cx="8" cy="14" r="1"></circle><circle cx="16" cy="14" r="1"></circle></svg>`,
auth: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path></svg>`,
channels: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path></svg>`,
messages: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path><polyline points="22,6 12,13 2,6"></polyline></svg>`,
commands: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="4 17 10 11 4 5"></polyline><line x1="12" y1="19" x2="20" y2="19"></line></svg>`,
hooks: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>`,
skills: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon></svg>`,
tools: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"></path></svg>`,
gateway: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><line x1="2" y1="12" x2="22" y2="12"></line><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path></svg>`,
wizard: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 4V2"></path><path d="M15 16v-2"></path><path d="M8 9h2"></path><path d="M20 9h2"></path><path d="M17.8 11.8 19 13"></path><path d="M15 9h0"></path><path d="M17.8 6.2 19 5"></path><path d="m3 21 9-9"></path><path d="M12.2 6.2 11 5"></path></svg>`,
default: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline></svg>`,
};
// Section definitions
const SECTIONS: Array<{ key: string; label: string }> = [
{ key: "env", label: "Environment" },
{ key: "update", label: "Updates" },
{ key: "agents", label: "Agents" },
{ key: "auth", label: "Authentication" },
{ key: "channels", label: "Channels" },
{ key: "messages", label: "Messages" },
{ key: "commands", label: "Commands" },
{ key: "hooks", label: "Hooks" },
{ key: "skills", label: "Skills" },
{ key: "tools", label: "Tools" },
{ key: "gateway", label: "Gateway" },
{ key: "wizard", label: "Setup Wizard" },
];
function getSectionIcon(key: string) {
return sidebarIcons[key as keyof typeof sidebarIcons] ?? sidebarIcons.default;
}
function computeDiff(
original: Record<string, unknown> | null,
current: Record<string, unknown> | null
@@ -117,7 +139,7 @@ export function renderConfig(props: ConfigProps) {
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: "📄" }));
.map(k => ({ key: k, label: k.charAt(0).toUpperCase() + k.slice(1) }));
const allSections = [...availableSections, ...extraSections];
@@ -163,7 +185,7 @@ export function renderConfig(props: ConfigProps) {
class="config-nav__item ${props.activeSection === null ? "active" : ""}"
@click=${() => props.onSectionChange(null)}
>
<span class="config-nav__icon">📋</span>
<span class="config-nav__icon">${sidebarIcons.all}</span>
<span class="config-nav__label">All Settings</span>
</button>
${allSections.map(section => html`
@@ -171,7 +193,7 @@ export function renderConfig(props: ConfigProps) {
class="config-nav__item ${props.activeSection === section.key ? "active" : ""}"
@click=${() => props.onSectionChange(section.key)}
>
<span class="config-nav__icon">${section.icon}</span>
<span class="config-nav__icon">${getSectionIcon(section.key)}</span>
<span class="config-nav__label">${section.label}</span>
</button>
`)}