feat(config-builder): ship integrated wizard and explorer UI

This commit is contained in:
Sebastian
2026-02-09 21:23:11 -05:00
parent 7546dfaa88
commit 671ac4d924
5 changed files with 722 additions and 92 deletions

View File

@@ -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:

View File

@@ -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);
}
}

View File

@@ -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>
`;
}

View File

@@ -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

View File

@@ -0,0 +1,5 @@
{
"installCommand": "pnpm install",
"buildCommand": "vite build --outDir dist",
"outputDirectory": "dist"
}