diff --git a/apps/config-builder/README.md b/apps/config-builder/README.md index 1b644042f9..463e73d44d 100644 --- a/apps/config-builder/README.md +++ b/apps/config-builder/README.md @@ -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: diff --git a/apps/config-builder/src/styles.css b/apps/config-builder/src/styles.css index eada61ba08..40806ddf5b 100644 --- a/apps/config-builder/src/styles.css +++ b/apps/config-builder/src/styles.css @@ -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); + } +} diff --git a/apps/config-builder/src/ui/app.ts b/apps/config-builder/src/ui/app.ts index 302138c1d8..e25bd904db 100644 --- a/apps/config-builder/src/ui/app.ts +++ b/apps/config-builder/src/ui/app.ts @@ -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 = {}; + 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 { 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("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` + + `; + + return html` +
+
+
OpenClaw Config Builder
+
Wizard + Explorer
+
+ +
+ ${modeButton("landing", "Home")} + ${modeButton("explorer", "Explorer")} + ${modeButton("wizard", "Wizard")} +
+ + + Docs + +
+ `; + } + private renderSearch() { return html` `; } - 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` + + `; + } + + private renderExplorerSections(visibleSections: ExplorerSection[]) { if (visibleSections.length === 0) { return html`
No matching sections/fields for this filter.
`; } @@ -336,13 +491,18 @@ class ConfigBuilderApp extends LitElement {
${section.id} · ${section.fields.length} field hint${section.fields.length === 1 ? "" : "s"} + ${this.sectionErrorCount(section.id) > 0 + ? html` · ${this.sectionErrorCount(section.id)} errors` + : nothing} ${section.description ? html`
${section.description}` : nothing}
-
${section.fields.map((field) => this.renderField(field))}
+
+ ${section.fields.map((field) => this.renderField(field, "explorer"))} +
`, @@ -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` -