mirror of
https://github.com/openclaw/openclaw.git
synced 2026-02-19 18:39:20 -05:00
feat(config-builder): ship integrated wizard and explorer UI
This commit is contained in:
@@ -12,19 +12,33 @@ Use the same front-end stack as the existing OpenClaw web UI (`ui/`):
|
||||
|
||||
## Current status
|
||||
|
||||
Phase 0, Phase 1, Phase 2, and Phase 3 are in place:
|
||||
Phase 0 through Phase 6 are implemented:
|
||||
|
||||
- app boots with Vite + Lit
|
||||
- `OpenClawSchema.toJSONSchema()` runs in browser bundle
|
||||
- `buildConfigSchema()` UI hints load in browser bundle
|
||||
- Explorer scaffold renders grouped sections + field metadata
|
||||
- Sparse draft state persists to localStorage and renders to JSON5 preview
|
||||
- Explorer mode supports grouped schema editing + search/filter
|
||||
- Typed field renderer covers:
|
||||
- strings, numbers, integers, booleans, enums
|
||||
- primitive arrays with add/remove
|
||||
- record-like objects (key/value editor)
|
||||
- JSON fallback editor for complex array/object shapes
|
||||
- Live JSON5 preview supports copy/download/reset
|
||||
- Validation + error UX:
|
||||
- real-time `OpenClawSchema` validation
|
||||
- inline field-level errors
|
||||
- section-level error counts + global summary
|
||||
- Wizard mode:
|
||||
- 7 curated steps with progress indicators
|
||||
- back/continue flow with shared renderer/state
|
||||
- JSON5 preview panel:
|
||||
- sparse output
|
||||
- copy/download/reset
|
||||
- sensitive-value warning banner
|
||||
- Routing + polish:
|
||||
- landing page + mode routing via hash (`#/`, `#/explorer`, `#/wizard`)
|
||||
- responsive layout including mobile preview drawer behavior
|
||||
- docs link in topbar
|
||||
- Vercel static config (`apps/config-builder/vercel.json`)
|
||||
|
||||
To run locally:
|
||||
|
||||
|
||||
@@ -11,23 +11,125 @@ config-builder-app {
|
||||
|
||||
.builder-screen {
|
||||
min-height: 100vh;
|
||||
padding: 0;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
/* -------------------------------------------
|
||||
Topbar + mode routing controls
|
||||
------------------------------------------- */
|
||||
|
||||
.builder-topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 30;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--chrome);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.builder-brand {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.builder-brand__title {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.builder-brand__subtitle {
|
||||
margin-top: 2px;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.builder-mode-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
|
||||
.builder-mode-toggle__btn {
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 7px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.builder-mode-toggle__btn:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.builder-mode-toggle__btn.active {
|
||||
color: var(--accent-foreground);
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
/* -------------------------------------------
|
||||
Landing mode
|
||||
------------------------------------------- */
|
||||
|
||||
.builder-landing {
|
||||
min-height: calc(100vh - 58px);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.builder-landing__card {
|
||||
max-width: 760px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.builder-landing__actions {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.builder-landing__notes {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* -------------------------------------------
|
||||
Workspace layout
|
||||
------------------------------------------- */
|
||||
|
||||
.builder-layout.config-layout {
|
||||
margin: 0;
|
||||
height: 100vh;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
height: calc(100vh - 58px);
|
||||
grid-template-columns: 260px minmax(0, 1fr) 400px;
|
||||
}
|
||||
|
||||
@supports (height: 100dvh) {
|
||||
.builder-layout.config-layout {
|
||||
height: 100dvh;
|
||||
height: calc(100dvh - 58px);
|
||||
}
|
||||
}
|
||||
|
||||
.builder-layout--wizard.config-layout {
|
||||
grid-template-columns: minmax(0, 1fr) 400px;
|
||||
}
|
||||
|
||||
.builder-subtitle {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
@@ -52,6 +154,21 @@ config-builder-app {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.builder-error-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
border-radius: var(--radius-full);
|
||||
border: 1px solid rgba(239, 68, 68, 0.4);
|
||||
background: var(--danger-subtle);
|
||||
color: var(--danger);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.builder-footer-note {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
@@ -77,6 +194,41 @@ config-builder-app {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.builder-error-text {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* -------------------------------------------
|
||||
Validation summary
|
||||
------------------------------------------- */
|
||||
|
||||
.builder-validation-summary {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.builder-validation-summary__title {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.builder-validation-summary__sections {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.builder-validation-summary__list {
|
||||
margin: 10px 0 0;
|
||||
padding-left: 18px;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* -------------------------------------------
|
||||
Field rendering
|
||||
------------------------------------------- */
|
||||
|
||||
.builder-field {
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border);
|
||||
@@ -84,6 +236,10 @@ config-builder-app {
|
||||
background: var(--bg-accent);
|
||||
}
|
||||
|
||||
.builder-field--set {
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.builder-field__head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
@@ -104,21 +260,97 @@ config-builder-app {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.builder-layout.config-layout {
|
||||
height: auto;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.builder-field__controls {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Phase 2 layout: add dedicated JSON5 preview panel. */
|
||||
.builder-layout.config-layout {
|
||||
grid-template-columns: 260px minmax(0, 1fr) 400px;
|
||||
.builder-field__actions {
|
||||
margin-top: 2px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.builder-toggle-row {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* -------------------------------------------
|
||||
Wizard mode
|
||||
------------------------------------------- */
|
||||
|
||||
.builder-wizard {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.builder-wizard__progress {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.builder-wizard__step {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 4px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-elevated);
|
||||
color: var(--muted);
|
||||
padding: 8px 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.builder-wizard__step:hover {
|
||||
border-color: var(--border-strong);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.builder-wizard__step--active {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
background: var(--accent-subtle);
|
||||
}
|
||||
|
||||
.builder-wizard__step--done {
|
||||
border-color: rgba(34, 197, 94, 0.4);
|
||||
color: var(--ok);
|
||||
}
|
||||
|
||||
.builder-wizard__step-index {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: var(--radius-full);
|
||||
border: 1px solid currentColor;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.builder-wizard__step-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.builder-wizard__actions {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* -------------------------------------------
|
||||
Preview panel
|
||||
------------------------------------------- */
|
||||
|
||||
.builder-preview {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
grid-template-rows: auto auto minmax(0, 1fr) auto;
|
||||
min-height: 0;
|
||||
background: var(--bg-accent);
|
||||
border-left: 1px solid var(--border);
|
||||
@@ -150,6 +382,10 @@ config-builder-app {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.builder-preview__mobile-toggle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.builder-preview__code {
|
||||
margin: 0;
|
||||
border: 0;
|
||||
@@ -161,30 +397,36 @@ config-builder-app {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.builder-field--set {
|
||||
border-color: var(--border-strong);
|
||||
.builder-sensitive-warning {
|
||||
margin: 10px 12px 0;
|
||||
}
|
||||
|
||||
.builder-field__controls {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
.builder-sensitive-warning__paths {
|
||||
margin-top: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--warn);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.builder-field__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.builder-toggle-row {
|
||||
margin: 0;
|
||||
}
|
||||
/* -------------------------------------------
|
||||
Responsive
|
||||
------------------------------------------- */
|
||||
|
||||
@media (max-width: 1500px) {
|
||||
.builder-layout.config-layout {
|
||||
grid-template-columns: 260px minmax(0, 1fr);
|
||||
height: auto;
|
||||
min-height: 100vh;
|
||||
min-height: calc(100vh - 58px);
|
||||
}
|
||||
|
||||
@supports (height: 100dvh) {
|
||||
.builder-layout.config-layout {
|
||||
min-height: calc(100dvh - 58px);
|
||||
}
|
||||
}
|
||||
|
||||
.builder-layout--wizard.config-layout {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.builder-preview {
|
||||
@@ -194,3 +436,57 @@ config-builder-app {
|
||||
min-height: 320px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.builder-topbar {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.builder-mode-toggle {
|
||||
order: 3;
|
||||
width: 100%;
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.builder-mode-toggle__btn {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.builder-layout.config-layout {
|
||||
grid-template-columns: 1fr;
|
||||
min-height: calc(100vh - 88px);
|
||||
}
|
||||
|
||||
.config-sidebar {
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.builder-wizard__progress {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.builder-preview {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 20;
|
||||
min-height: 0;
|
||||
max-height: 72vh;
|
||||
transition: transform var(--duration-normal) var(--ease-out);
|
||||
border-top: 1px solid var(--border);
|
||||
box-shadow: var(--shadow-lg);
|
||||
background: var(--panel-strong);
|
||||
}
|
||||
|
||||
.builder-preview__mobile-toggle {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.builder-preview.mobile-collapsed {
|
||||
transform: translateY(calc(100% - 46px));
|
||||
}
|
||||
|
||||
.builder-preview.mobile-open {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,10 @@ import {
|
||||
type ExplorerSection,
|
||||
type ExplorerSnapshot,
|
||||
} from "../lib/schema-spike.ts";
|
||||
import { validateConfigDraft, type ValidationResult } from "../lib/validation.ts";
|
||||
import { modeToHash, parseModeFromHash, type ConfigBuilderMode } from "./navigation.ts";
|
||||
import { renderFieldEditor } from "./components/field-renderer.ts";
|
||||
import { WIZARD_STEPS, wizardStepByIndex, wizardStepFields } from "./wizard.ts";
|
||||
|
||||
type AppState =
|
||||
| { status: "loading" }
|
||||
@@ -56,13 +59,19 @@ function sectionGlyph(label: string): string {
|
||||
|
||||
class ConfigBuilderApp extends LitElement {
|
||||
private state: AppState = { status: "loading" };
|
||||
private mode: ConfigBuilderMode = "landing";
|
||||
private config: ConfigDraft = {};
|
||||
private validation: ValidationResult = validateConfigDraft({});
|
||||
private selectedSectionId: string | null = null;
|
||||
private searchQuery = "";
|
||||
private fieldErrors: Record<string, string> = {};
|
||||
private wizardStepIndex = 0;
|
||||
private previewOpenMobile = false;
|
||||
private copyState: CopyState = "idle";
|
||||
private copyResetTimer: number | null = null;
|
||||
|
||||
private readonly hashChangeHandler = () => this.handleHashChange();
|
||||
|
||||
override createRenderRoot() {
|
||||
// Match the existing OpenClaw web UI approach (global CSS classes/tokens).
|
||||
return this;
|
||||
@@ -71,9 +80,11 @@ class ConfigBuilderApp extends LitElement {
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.bootstrap();
|
||||
window.addEventListener("hashchange", this.hashChangeHandler);
|
||||
}
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
window.removeEventListener("hashchange", this.hashChangeHandler);
|
||||
if (this.copyResetTimer != null) {
|
||||
window.clearTimeout(this.copyResetTimer);
|
||||
this.copyResetTimer = null;
|
||||
@@ -83,7 +94,9 @@ class ConfigBuilderApp extends LitElement {
|
||||
|
||||
private bootstrap(): void {
|
||||
try {
|
||||
this.mode = parseModeFromHash(window.location.hash);
|
||||
this.config = loadPersistedDraft();
|
||||
this.validation = validateConfigDraft(this.config);
|
||||
const snapshot = buildExplorerSnapshot();
|
||||
this.state = { status: "ready", snapshot };
|
||||
} catch (error) {
|
||||
@@ -93,6 +106,32 @@ class ConfigBuilderApp extends LitElement {
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private handleHashChange(): void {
|
||||
const next = parseModeFromHash(window.location.hash);
|
||||
if (next === this.mode) {
|
||||
return;
|
||||
}
|
||||
this.mode = next;
|
||||
if (next !== "wizard") {
|
||||
this.wizardStepIndex = 0;
|
||||
}
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private navigateMode(mode: ConfigBuilderMode): void {
|
||||
if (mode !== this.mode) {
|
||||
this.mode = mode;
|
||||
this.requestUpdate();
|
||||
}
|
||||
const hash = modeToHash(mode);
|
||||
if (window.location.hash !== hash) {
|
||||
window.location.hash = hash;
|
||||
}
|
||||
if (mode === "wizard") {
|
||||
this.focusWizardStep();
|
||||
}
|
||||
}
|
||||
|
||||
private setSection(sectionId: string | null): void {
|
||||
this.selectedSectionId = sectionId;
|
||||
this.requestUpdate();
|
||||
@@ -105,6 +144,7 @@ class ConfigBuilderApp extends LitElement {
|
||||
|
||||
private saveConfig(next: ConfigDraft): void {
|
||||
this.config = next;
|
||||
this.validation = validateConfigDraft(next);
|
||||
persistDraft(next);
|
||||
this.requestUpdate();
|
||||
}
|
||||
@@ -141,6 +181,14 @@ class ConfigBuilderApp extends LitElement {
|
||||
this.saveConfig(resetDraft());
|
||||
}
|
||||
|
||||
private sectionErrorCount(sectionId: string): number {
|
||||
return this.validation.sectionErrorCounts[sectionId] ?? 0;
|
||||
}
|
||||
|
||||
private totalErrorCount(): number {
|
||||
return this.validation.issues.length;
|
||||
}
|
||||
|
||||
private async copyPreview(text: string): Promise<void> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
@@ -162,6 +210,24 @@ class ConfigBuilderApp extends LitElement {
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private setWizardStep(index: number): void {
|
||||
const clamped = Math.max(0, Math.min(WIZARD_STEPS.length - 1, index));
|
||||
if (clamped === this.wizardStepIndex) {
|
||||
return;
|
||||
}
|
||||
this.wizardStepIndex = clamped;
|
||||
this.requestUpdate();
|
||||
this.focusWizardStep();
|
||||
}
|
||||
|
||||
private focusWizardStep(): void {
|
||||
window.setTimeout(() => {
|
||||
const root = document.querySelector(".builder-wizard");
|
||||
const target = root?.querySelector<HTMLElement>("input, select, textarea, button");
|
||||
target?.focus();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
private getVisibleSections(snapshot: ExplorerSnapshot): ExplorerSection[] {
|
||||
const bySection = this.selectedSectionId
|
||||
? snapshot.sections.filter((section) => section.id === this.selectedSectionId)
|
||||
@@ -188,6 +254,57 @@ class ConfigBuilderApp extends LitElement {
|
||||
return visible;
|
||||
}
|
||||
|
||||
private sensitiveFieldsWithValues(snapshot: ExplorerSnapshot): string[] {
|
||||
const paths: string[] = [];
|
||||
for (const section of snapshot.sections) {
|
||||
for (const field of section.fields) {
|
||||
if (!field.sensitive) {
|
||||
continue;
|
||||
}
|
||||
if (getFieldValue(this.config, field.path) === undefined) {
|
||||
continue;
|
||||
}
|
||||
paths.push(field.path);
|
||||
}
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
private renderTopbar() {
|
||||
const modeButton = (mode: ConfigBuilderMode, label: string) => html`
|
||||
<button
|
||||
class="builder-mode-toggle__btn ${this.mode === mode ? "active" : ""}"
|
||||
@click=${() => this.navigateMode(mode)}
|
||||
>
|
||||
${label}
|
||||
</button>
|
||||
`;
|
||||
|
||||
return html`
|
||||
<header class="builder-topbar">
|
||||
<div class="builder-brand">
|
||||
<div class="builder-brand__title">OpenClaw Config Builder</div>
|
||||
<div class="builder-brand__subtitle">Wizard + Explorer</div>
|
||||
</div>
|
||||
|
||||
<div class="builder-mode-toggle" role="tablist" aria-label="Builder mode">
|
||||
${modeButton("landing", "Home")}
|
||||
${modeButton("explorer", "Explorer")}
|
||||
${modeButton("wizard", "Wizard")}
|
||||
</div>
|
||||
|
||||
<a
|
||||
class="btn btn--sm"
|
||||
href="https://docs.openclaw.ai/configuration"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Docs
|
||||
</a>
|
||||
</header>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderSearch() {
|
||||
return html`
|
||||
<div class="config-search">
|
||||
@@ -228,10 +345,12 @@ class ConfigBuilderApp extends LitElement {
|
||||
<aside class="config-sidebar">
|
||||
<div class="config-sidebar__header">
|
||||
<div>
|
||||
<div class="config-sidebar__title">Config Builder</div>
|
||||
<div class="builder-subtitle">Typed field renderer + JSON5 preview</div>
|
||||
<div class="config-sidebar__title">Explorer</div>
|
||||
<div class="builder-subtitle">Schema-backed field editor</div>
|
||||
</div>
|
||||
<span class="pill pill--sm pill--ok">phase 3</span>
|
||||
<span class="pill pill--sm ${this.validation.valid ? "pill--ok" : "pill--danger"}">
|
||||
${this.validation.valid ? "valid" : "errors"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
${this.renderSearch()}
|
||||
@@ -257,6 +376,9 @@ class ConfigBuilderApp extends LitElement {
|
||||
>
|
||||
<span class="config-nav__label">${section.label}</span>
|
||||
<span class="builder-count mono">${section.fields.length}</span>
|
||||
${this.sectionErrorCount(section.id) > 0
|
||||
? html`<span class="builder-error-count">${this.sectionErrorCount(section.id)}</span>`
|
||||
: nothing}
|
||||
</button>
|
||||
`,
|
||||
)}
|
||||
@@ -264,17 +386,18 @@ class ConfigBuilderApp extends LitElement {
|
||||
|
||||
<div class="config-sidebar__footer">
|
||||
<div class="builder-footer-note">
|
||||
Draft values persist in localStorage and render to JSON5 preview.
|
||||
Draft values persist to localStorage. Validation updates in real time.
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderField(field: ExplorerField) {
|
||||
private renderField(field: ExplorerField, context: "explorer" | "wizard") {
|
||||
const value = getFieldValue(this.config, field.path);
|
||||
const hasValue = value !== undefined;
|
||||
const error = this.fieldErrors[field.path] ?? null;
|
||||
const localError = this.fieldErrors[field.path] ?? null;
|
||||
const schemaErrors = this.validation.issuesByPath[field.path] ?? [];
|
||||
|
||||
return html`
|
||||
<div class="cfg-field builder-field ${hasValue ? "builder-field--set" : ""}">
|
||||
@@ -308,16 +431,48 @@ class ConfigBuilderApp extends LitElement {
|
||||
})}
|
||||
</div>
|
||||
|
||||
${error ? html`<div class="cfg-field__error">${error}</div>` : nothing}
|
||||
${localError ? html`<div class="cfg-field__error">${localError}</div>` : nothing}
|
||||
${schemaErrors.map((message) => html`<div class="cfg-field__error">${message}</div>`)}
|
||||
|
||||
<div class="builder-field__actions">
|
||||
<button class="btn btn--sm" @click=${() => this.clearField(field.path)}>Clear</button>
|
||||
${context === "wizard"
|
||||
? html`<button class="btn btn--sm" @click=${() => this.navigateMode("explorer")}>Open in Explorer</button>`
|
||||
: nothing}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderSections(visibleSections: ExplorerSection[]) {
|
||||
private renderValidationSummary() {
|
||||
if (this.validation.valid) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const sectionEntries = Object.entries(this.validation.sectionErrorCounts).toSorted((a, b) =>
|
||||
a[0].localeCompare(b[0]),
|
||||
);
|
||||
|
||||
return html`
|
||||
<div class="callout danger builder-validation-summary" role="alert">
|
||||
<div class="builder-validation-summary__title">
|
||||
${this.totalErrorCount()} validation error${this.totalErrorCount() === 1 ? "" : "s"}
|
||||
</div>
|
||||
<div class="builder-validation-summary__sections">
|
||||
${sectionEntries.map(
|
||||
([section, count]) => html`<span class="pill pill--sm">${section}: ${count}</span>`,
|
||||
)}
|
||||
</div>
|
||||
<ul class="builder-validation-summary__list">
|
||||
${this.validation.issues.slice(0, 8).map(
|
||||
(issue) => html`<li><span class="mono">${issue.path || "(root)"}</span> — ${issue.message}</li>`,
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderExplorerSections(visibleSections: ExplorerSection[]) {
|
||||
if (visibleSections.length === 0) {
|
||||
return html`<div class="config-empty"><div class="config-empty__text">No matching sections/fields for this filter.</div></div>`;
|
||||
}
|
||||
@@ -336,13 +491,18 @@ class ConfigBuilderApp extends LitElement {
|
||||
<div class="config-section-card__desc">
|
||||
<span class="mono">${section.id}</span>
|
||||
· ${section.fields.length} field hint${section.fields.length === 1 ? "" : "s"}
|
||||
${this.sectionErrorCount(section.id) > 0
|
||||
? html` · <span class="builder-error-text">${this.sectionErrorCount(section.id)} errors</span>`
|
||||
: nothing}
|
||||
${section.description ? html`<br />${section.description}` : nothing}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-section-card__content">
|
||||
<div class="cfg-fields">${section.fields.map((field) => this.renderField(field))}</div>
|
||||
<div class="cfg-fields">
|
||||
${section.fields.map((field) => this.renderField(field, "explorer"))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
`,
|
||||
@@ -351,16 +511,109 @@ class ConfigBuilderApp extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private renderPreview() {
|
||||
const preview = formatConfigJson5(this.config);
|
||||
private renderWizardView() {
|
||||
const step = wizardStepByIndex(this.wizardStepIndex);
|
||||
const fields = wizardStepFields(step);
|
||||
|
||||
return html`
|
||||
<aside class="builder-preview">
|
||||
<div class="builder-preview__header">
|
||||
<div class="builder-preview__title mono">openclaw.json</div>
|
||||
<div class="builder-preview__meta mono">${preview.lineCount} lines · ${preview.byteCount} B</div>
|
||||
<div class="builder-wizard">
|
||||
<div class="builder-wizard__progress" role="list">
|
||||
${WIZARD_STEPS.map((entry, index) => {
|
||||
const state =
|
||||
index < this.wizardStepIndex ? "done" : index === this.wizardStepIndex ? "active" : "todo";
|
||||
return html`
|
||||
<button
|
||||
class="builder-wizard__step builder-wizard__step--${state}"
|
||||
@click=${() => this.setWizardStep(index)}
|
||||
role="listitem"
|
||||
aria-current=${index === this.wizardStepIndex ? "step" : "false"}
|
||||
>
|
||||
<span class="builder-wizard__step-index">${index + 1}</span>
|
||||
<span class="builder-wizard__step-label">${entry.label}</span>
|
||||
</button>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
|
||||
<section class="config-section-card">
|
||||
<div class="config-section-card__header">
|
||||
<div class="config-section-card__icon builder-section-glyph" aria-hidden="true">
|
||||
${sectionGlyph(step.label)}
|
||||
</div>
|
||||
<div class="config-section-card__titles">
|
||||
<h2 class="config-section-card__title">${step.label}</h2>
|
||||
<div class="config-section-card__desc">${step.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-section-card__content">
|
||||
<div class="cfg-fields">
|
||||
${fields.map((field) => this.renderField(field, "wizard"))}
|
||||
</div>
|
||||
|
||||
<div class="builder-wizard__actions">
|
||||
<button
|
||||
class="btn btn--sm"
|
||||
?disabled=${this.wizardStepIndex === 0}
|
||||
@click=${() => this.setWizardStep(this.wizardStepIndex - 1)}
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="btn btn--sm primary"
|
||||
@click=${() => {
|
||||
if (this.wizardStepIndex >= WIZARD_STEPS.length - 1) {
|
||||
this.navigateMode("explorer");
|
||||
return;
|
||||
}
|
||||
this.setWizardStep(this.wizardStepIndex + 1);
|
||||
}}
|
||||
>
|
||||
${this.wizardStepIndex >= WIZARD_STEPS.length - 1 ? "Finish" : "Continue"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private toggleMobilePreview(): void {
|
||||
this.previewOpenMobile = !this.previewOpenMobile;
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private renderPreview(snapshot: ExplorerSnapshot) {
|
||||
const preview = formatConfigJson5(this.config);
|
||||
const sensitivePaths = this.sensitiveFieldsWithValues(snapshot);
|
||||
|
||||
return html`
|
||||
<aside class="builder-preview ${this.previewOpenMobile ? "mobile-open" : "mobile-collapsed"}">
|
||||
<div class="builder-preview__header">
|
||||
<div>
|
||||
<div class="builder-preview__title mono">openclaw.json</div>
|
||||
<div class="builder-preview__meta mono">${preview.lineCount} lines · ${preview.byteCount} B</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="btn btn--sm builder-preview__mobile-toggle"
|
||||
@click=${() => this.toggleMobilePreview()}
|
||||
aria-expanded=${this.previewOpenMobile ? "true" : "false"}
|
||||
>
|
||||
${this.previewOpenMobile ? "Hide" : "Show"} preview
|
||||
</button>
|
||||
</div>
|
||||
|
||||
${sensitivePaths.length > 0
|
||||
? html`
|
||||
<div class="callout warn builder-sensitive-warning">
|
||||
Sensitive values included in output (${sensitivePaths.length}).
|
||||
<div class="mono builder-sensitive-warning__paths">${sensitivePaths.join(", ")}</div>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
|
||||
<pre class="builder-preview__code code-block">${preview.text}</pre>
|
||||
|
||||
<div class="builder-preview__footer">
|
||||
@@ -378,9 +631,78 @@ class ConfigBuilderApp extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private renderLanding() {
|
||||
return html`
|
||||
<div class="builder-landing">
|
||||
<section class="card builder-landing__card">
|
||||
<h2 class="card-title">Choose your setup flow</h2>
|
||||
<p class="card-sub">
|
||||
Start with the guided wizard for common setups, or use explorer for full schema control.
|
||||
</p>
|
||||
|
||||
<div class="builder-landing__actions">
|
||||
<button class="btn primary" @click=${() => this.navigateMode("wizard")}>Start Wizard</button>
|
||||
<button class="btn" @click=${() => this.navigateMode("explorer")}>Open Explorer</button>
|
||||
</div>
|
||||
|
||||
<div class="builder-landing__notes">
|
||||
<div class="pill pill--sm">7 curated wizard steps</div>
|
||||
<div class="pill pill--sm">Live JSON5 preview</div>
|
||||
<div class="pill pill--sm">Real-time schema validation</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderWorkspace(snapshot: ExplorerSnapshot) {
|
||||
const explorerSections = this.getVisibleSections(snapshot);
|
||||
const layoutClass = this.mode === "wizard" ? "builder-layout builder-layout--wizard" : "builder-layout";
|
||||
|
||||
return html`
|
||||
<div class="config-layout ${layoutClass}">
|
||||
${this.mode === "explorer" ? this.renderSidebar(snapshot) : nothing}
|
||||
|
||||
<main class="config-main">
|
||||
<div class="config-actions">
|
||||
<div class="config-actions__left">
|
||||
<span class="config-status">
|
||||
${this.mode === "wizard"
|
||||
? `Wizard step ${this.wizardStepIndex + 1} of ${WIZARD_STEPS.length}`
|
||||
: "Explorer mode"}
|
||||
</span>
|
||||
</div>
|
||||
<div class="config-actions__right">
|
||||
<span class="pill pill--sm">sections: ${snapshot.sectionCount}</span>
|
||||
<span class="pill pill--sm">fields: ${snapshot.fieldCount}</span>
|
||||
<span class="pill pill--sm mono">v${snapshot.version}</span>
|
||||
${this.totalErrorCount() > 0
|
||||
? html`<span class="pill pill--sm pill--danger">errors: ${this.totalErrorCount()}</span>`
|
||||
: html`<span class="pill pill--sm pill--ok">valid</span>`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-content">
|
||||
${this.renderValidationSummary()}
|
||||
|
||||
${this.mode === "explorer" && this.searchQuery
|
||||
? html`<div class="builder-search-state">Search: <span class="mono">${this.searchQuery}</span></div>`
|
||||
: nothing}
|
||||
|
||||
${this.mode === "wizard"
|
||||
? this.renderWizardView()
|
||||
: this.renderExplorerSections(explorerSections)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
${this.renderPreview(snapshot)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
override render() {
|
||||
if (this.state.status === "loading") {
|
||||
return html`<div class="builder-screen"><div class="card">Loading schema explorer…</div></div>`;
|
||||
return html`<div class="builder-screen"><div class="card">Loading config builder…</div></div>`;
|
||||
}
|
||||
|
||||
if (this.state.status === "error") {
|
||||
@@ -388,36 +710,11 @@ class ConfigBuilderApp extends LitElement {
|
||||
}
|
||||
|
||||
const { snapshot } = this.state;
|
||||
const visibleSections = this.getVisibleSections(snapshot);
|
||||
|
||||
return html`
|
||||
<div class="builder-screen">
|
||||
<div class="config-layout builder-layout">
|
||||
${this.renderSidebar(snapshot)}
|
||||
|
||||
<main class="config-main">
|
||||
<div class="config-actions">
|
||||
<div class="config-actions__left">
|
||||
<span class="config-status">Phase 3: typed field renderer + live JSON5 preview</span>
|
||||
</div>
|
||||
<div class="config-actions__right">
|
||||
<span class="pill pill--sm">sections: ${snapshot.sectionCount}</span>
|
||||
<span class="pill pill--sm">fields: ${snapshot.fieldCount}</span>
|
||||
<span class="pill pill--sm mono">v${snapshot.version}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-content">
|
||||
${this.searchQuery
|
||||
? html`<div class="builder-search-state">Search: <span class="mono">${this.searchQuery}</span></div>`
|
||||
: nothing}
|
||||
|
||||
${this.renderSections(visibleSections)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
${this.renderPreview()}
|
||||
</div>
|
||||
${this.renderTopbar()}
|
||||
${this.mode === "landing" ? this.renderLanding() : this.renderWorkspace(snapshot)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -305,6 +305,7 @@ function renderRecordObject(params: {
|
||||
const record = asObject(value);
|
||||
const entries = Object.entries(record);
|
||||
const valueKind = field.recordValueKind;
|
||||
const recordEnums = field.recordEnumValues;
|
||||
|
||||
if (!valueKind || valueKind === "unknown" || valueKind === "object" || valueKind === "array") {
|
||||
return renderJsonControl(params);
|
||||
@@ -377,25 +378,42 @@ function renderRecordObject(params: {
|
||||
</div>
|
||||
</label>
|
||||
`
|
||||
: html`
|
||||
<input
|
||||
class="cfg-input cfg-input--sm"
|
||||
type=${valueKind === "number" || valueKind === "integer" ? "number" : "text"}
|
||||
.value=${scalarInputValue(entryValue)}
|
||||
@input=${(event: Event) => {
|
||||
const raw = (event.target as HTMLInputElement).value;
|
||||
const next = { ...record };
|
||||
try {
|
||||
next[key] = raw.trim() === "" ? defaultValueForKind(valueKind) : parseScalar(valueKind, raw);
|
||||
: valueKind === "enum" && recordEnums.length > 0
|
||||
? html`
|
||||
<select
|
||||
class="cfg-select cfg-select--sm"
|
||||
.value=${String(entryValue)}
|
||||
@change=${(event: Event) => {
|
||||
const next = { ...record };
|
||||
next[key] = (event.target as HTMLSelectElement).value;
|
||||
onSet(next);
|
||||
} catch (error) {
|
||||
onValidationError?.(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
`}
|
||||
}}
|
||||
>
|
||||
${recordEnums.map((entry) => html`<option value=${entry}>${entry}</option>`)}
|
||||
</select>
|
||||
`
|
||||
: html`
|
||||
<input
|
||||
class="cfg-input cfg-input--sm"
|
||||
type=${valueKind === "number" || valueKind === "integer"
|
||||
? "number"
|
||||
: "text"}
|
||||
.value=${scalarInputValue(entryValue)}
|
||||
@input=${(event: Event) => {
|
||||
const raw = (event.target as HTMLInputElement).value;
|
||||
const next = { ...record };
|
||||
try {
|
||||
next[key] =
|
||||
raw.trim() === "" ? defaultValueForKind(valueKind) : parseScalar(valueKind, raw);
|
||||
onSet(next);
|
||||
} catch (error) {
|
||||
onValidationError?.(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
`}
|
||||
</div>
|
||||
|
||||
<button
|
||||
|
||||
5
apps/config-builder/vercel.json
Normal file
5
apps/config-builder/vercel.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"installCommand": "pnpm install",
|
||||
"buildCommand": "vite build --outDir dist",
|
||||
"outputDirectory": "dist"
|
||||
}
|
||||
Reference in New Issue
Block a user