Repository navigation
feat(webui): restructure Settings into scoped sections with schema-typed inputs #1917
Description
Part of #1910.
Problem / Background
Measured 2026-09-17 against a release build of main 0ef0a1a4 with a model loaded and --settings --props --slots --metrics enabled: the Settings route is one scroll of 5,720 px at 1440x900 with 129 form inputs, 763 words and 11 h2 headings. A user looking for default_temperature has to scroll past appearance, a privacy paragraph, eight session-default fields, a model selector, the load profile editor with a JSON dump and a CLI dump, and the effective-context block before reaching the live fields, which are then rendered as raw text with float noise.
Current Behavior
SettingsScreen (webui/src/app.tsx:134-139) renders appearance controls (theme, material, language, high contrast, glass intensity range, reduce motion, reduce transparency via a private Toggle at app.tsx:141), then an info banner "Browser appearance only", then ServerSettings (webui/src/features/settings/server-settings.tsx:12-28), which stacks in one column: Privacy, GenerationSettings (generation-settings.tsx: seven text Fields from GENERATION_FIELDS plus a disabled "Reasoning / structured output" field whose value is a sentence), a "Selected model for settings" Select, ProfileSettings (profile-settings.tsx: scope select, three fields, save/discard/reset, a code dump of the profile JSON and of profileCli, an import/export <details> with a textarea), "Effective context" (three disabled fields, a code dump of props.geometry, ContextCheck from context-check.tsx), and LiveSettings (live-settings.tsx:49: every mutable field as a text Field, then a <details> with every read-only field).
LiveSettings shows every value through settingInput(spec, value) (webui/src/api/settings.ts:44), which is JSON.stringify for anything but strings. Floats therefore show f32 noise (0.800000011920929, 0.949999988079071, 0.05000000074505806) because the server stores f32 (src/server/config.rs:509-510) and serializes it with json!(live.default_temperature) (src/server/runtime_settings.rs:269); null appears as the literal string null; labels are raw keys (default_dry_multiplier, default_dry_sequence_breakers, lang_bias_config, chat_template_kwargs); enums such as diffusion_sampler (allowed: entropy-bound, confidence-threshold, runtime_settings.rs:431) are free-text; booleans are text; the hint concatenates spec.help, "Current: ...", "Type: ..." and "allowed: ...". The schema already carries what a typed control needs: SettingSpec has type (SettingKind, nine kinds), allowed, mutable, default, help, reason (webui/src/api/settings.ts:2-3, mirrored by KnobSpec in runtime_settings.rs:176-186).
Every section opens with a two- to four-sentence contract statement, for example the fingerprint and numeric-bounds paragraph at live-settings.tsx:49 and the CLI-precedence paragraph at profile-settings.tsx:17. Generation parameters exist in three places with precedence explained only in prose: GenerationSettings (session defaults in generation-preferences.ts), the Chat "Parameters for next turn" overrides (webui/src/features/chat/parameters.tsx:12-14), and the live default_* fields; the UI never shows the resolved value for the next request. All settings copy goes through a local words(en, ko) helper (server-settings.tsx:17, live-settings.tsx:16, generation-settings.tsx:12, profile-settings.tsx:13, context-check.tsx:20), which #1913 replaces with t() from webui/src/i18n/catalog.ts.
Proposed Solution
Split Settings into four sections, each a tab (the existing controlled Tabs adapter at webui/src/design-system/common-adapters.tsx:47) addressed by a route hash: #settings/appearance, #settings/requests, #settings/model, #settings/server. Extend routeFromHash (webui/src/app.tsx:20-23) so settings/<section> resolves to route = 'settings' with a section value, defaulting to appearance for a bare #settings or an unknown section; RouteId in webui/src/design-system/shell.tsx:8 is unchanged, the section is a second value passed to SettingsScreen. Tab changes write the hash with history.replaceState so Back leaves Settings rather than stepping through tabs. Each tab opens with PageHeader from #1914 and a one-line description; the long contract statements move to docs/webui/settings.md and are reachable from a help Tooltip (the retained local one in webui/src/design-system/primitives.tsx:132) or a "Learn more" link, not inline prose.
- Appearance (browser): the existing appearance controls, moved out of
app.tsxintowebui/src/features/settings/appearance-settings.tsx;Togglebecomes a shared primitive so the live-settings boolean control can reuse it. The "Browser appearance only" banner becomes the tab's one-line description. - Requests (browser memory): the seven
GENERATION_FIELDSas number inputs. Each field shows its effective next-request value and source through a new pure helper ingeneration-defaults.ts:resolveEffectiveParameter(name, override: number | undefined, sessionDefault: number | undefined, serverDefault: unknown): { value: number | null; source: 'override' | 'session' | 'server' | 'unknown' }. The server default iscurrent["default_" + name]from the selected ready model's/settingsresponse when a schema entry with that name exists, otherwiseunknown(rendered as "server default, not readable").parameters.tsxuses the same helper for its "Inherited" hint so Chat and Settings agree. Remove the disabled "Reasoning / structured output" sentence field; that statement goes to the docs. - Model (selected model): the model
Select(keeptestId="settings-model-selector"), then the next-load profile editor with its JSON andprofileClioutput behind a "Show as CLI flags" disclosure with a copy button, and import/export behind one disclosure. When the selected model is Ready and--settingsis on, the live mutable settings render below it, grouped by prefix in this order: Sampling (default_*exceptdefault_dry_*), DRY (default_dry_*), Diffusion (diffusion_*), Template (chat_template_kwargs,lang_bias_config, and any other name containingtemplateorbias), Other (the remainder). The fingerprint-conflict message stays, promoted to a banner at the top of the group. Labels come from catalog keyssettings.live.<name>with the raw key as secondary text; a name without a catalog entry falls back to the raw key alone so schema growth never blanks a label. - Server (read-only): startup values (the
!spec.mutableschema entries, no longer inside a<details>), the effective context block (nCtx,totalSlots,kvCacheModeas a definition list,geometrybehind a disclosure), theContextChecktokenizer probe, and the disabled-endpoint banners.
Typed controls come from one new module, webui/src/features/settings/setting-control.tsx, exporting SettingControl({ spec, value, draft, onChange, error, disabled }) that maps SettingKind to a control: bool to Toggle; str with allowed to Select; str without allowed to a text Field; int to <input type="number" step="1">; float to <input type="number" step="any">; array, object, object_or_null to a <textarea> that validates JSON on blur; every _or_null kind adds a "Use null" toggle that, when on, disables the inner control and writes the literal string null into the draft. The draft stays Record<string, string> and parseSettingInput (api/settings.ts:32-43) stays the sole validator, so the apply, reject-retention, fingerprint and reset flows in live-settings.tsx:24-48 are unchanged. Add formatSettingValue(spec, value): string beside settingInput in api/settings.ts: for float it returns the shortest decimal that round-trips through Math.fround (String(Number(Math.fround(v).toPrecision(7))) is acceptable if it satisfies the test below), for int it is String(v), for null it is the empty string (the null toggle carries the state), and for everything else it defers to settingInput. Display uses formatSettingValue; what is sent is exactly what the user typed, parsed by parseSettingInput. An alternative, rounding on the server, was rejected because the endpoint is shared with non-WebUI clients and #1910 keeps /ui-api/v1 and /settings unchanged.
Scope
In scope: webui/src/app.tsx (route parsing, SettingsScreen becomes a tab host), webui/src/features/settings/ (new appearance-settings.tsx, setting-control.tsx, settings-screen.tsx; rewrite server-settings.tsx, live-settings.tsx, generation-settings.tsx, profile-settings.tsx; generation-defaults.ts gains resolveEffectiveParameter), webui/src/api/settings.ts (formatSettingValue), webui/src/features/chat/parameters.tsx (hint only), webui/src/design-system/primitives.tsx (Toggle), webui/src/i18n/catalog.ts and tests/fixtures/webui/strings.json (new keys), docs/webui/settings.md (receives the moved contract prose), the four settings test files and webui/tests/settings.spec.ts.
Out of scope: any server change (no numeric min/max in the schema, no rounding, no new endpoint); the Chat parameter panel layout (Chat child of #1910); the toolbar and shell (shell child); migrating non-settings screens off words() (i18n child).
Implementation Notes
- Reuse:
Tabs(common-adapters.tsx:47),Select(common-select.tsx:13),Field,ErrorBanner,Dialog,Tooltipfromprimitives.tsx;PageHeader,PageLayout,Badge,BaseCardfrom@lablup/ui-common0.1.0-alpha.19 as wrapped by feat(webui): app shell shows loaded models, uses PageLayout/PageHeader, and completes the global shortcuts #1914 and refactor(webui): adopt every shared @lablup/ui-common component #1902 (do not import ui-common directly from a feature file; add an adapter incommon-adapters.tsxif one is missing).parseSettingInput,validateGenerationDefaults,validateLoadProfile,profileCli,useLoadProfile,useGenerationDefaults,contextBudgetare unchanged. - Constraints:
docs/webui/ux-contract.mdgates hold (keyboard-first, visible focus, 200 percent zoom, 390 px with no horizontal scroll, WCAG 2.2 AA, IME-safe inputs); theTabsadapter already stops composing keydown. Nowindow.confirm; the existingDialogreset confirmations stay. The schema may hold up to 256 entries (api/settings.ts:11); grouping and labelling must not assume a fixed list. - Edge cases:
--settingsoff shows the existing "Live settings disabled by operator" banner in the Model tab and the Server tab shows only/propsdata; single-model mode keeps the "restart required" banner in the Model tab; no ready model selected shows the "No ready model selected" banner and the Requests tab resolves every server default asunknown; afloatwhose f32 round trip is not shorter (for example0.1234567) displays the 7-significant-digit form; a_or_nullfield whose current value isnullopens with the null toggle on and the inner control empty; astrenum whose current value is not inallowedrenders theSelectwith an extra disabled option showing the raw value so nothing is hidden; an unknown hash section falls back toappearance. - Error handling: per-field errors from
parseSettingInputand fromrejected[].reasonstay attached to the field; a JSON textarea parse failure sets the field error "Enter valid JSON" and blocks Apply exactly as invalid text does today; refresh and apply failures keep the existing messages.
Acceptance Criteria
- No single Settings tab exceeds two viewport heights (1,800 px) at 1440x900 with a model loaded and
--settings --props --slots --metricson; the before value (5,720 px, 129 inputs, 763 words) and the after value per tab are recorded in the closing comment. - Every live setting renders a control matching its schema kind and enum:
diffusion_sampleris aSelectwith exactly itsallowedvalues, everyboolis aToggle, everyfloatshows no more than 7 significant digits, and the literal stringnullappears nowhere in the DOM. - Each of the seven request fields shows a resolved value and its source, and the Chat "Parameters for next turn" hint shows the same value for the same state.
-
default_temperatureis reachable within two clicks of opening Settings (Model tab, then the field) with a ready model selected. -
#settings/serveropens the Server tab directly; Back from any tab leaves Settings. - The fingerprint-conflict flow, partial-apply reporting, "saving is not loading" profile semantics, disabled-endpoint banners and the context probe wording ("raw tokens" as a lower bound) behave as before;
live-settings.test.tsx,profiles-state.test.tsx,server-settings-selection.test.tsx,settings.test.tsandwebui/tests/settings.spec.tspass with only selector updates. - New unit tests:
formatSettingValuemaps0.800000011920929to0.8and0.05000000074505806to0.05and leaves0.1234567at 7 digits;SettingControlrenders the right element perSettingKindand the null toggle writesnullto the draft;resolveEffectiveParameterreturnsoverrideoversessionoverserveroverunknown; the route parser maps#settings/modeland#settings/boguscorrectly. - Integrated into the real app flow: the four tabs are what
#settingsrenders in the bundled binary, not a parallel screen. - Verified against the bundled binary with
--settingsenabled and a loaded model, at 1440x900 and 390x844, with screenshots attached.
Verification
pnpm --dir webui run lint
pnpm --dir webui run typecheck
pnpm --dir webui run unit
pnpm --dir webui run browser
pnpm --dir webui run verify-generated
cargo build --release --features metal,accelerate
./target/release/mlxcel-server --webui --models-dir models/mlx --api-key-file <file> --no-models-autoload --settings --props --metrics --slotsManual: load mlx-community/Qwen3-4B-4bit from Models, open #settings/model, confirm default_temperature shows 0.8 in a number input and diffusion_sampler is a select, change default_temperature to 0.7, Apply, confirm the value reads back as 0.7; open #settings/requests, set temperature 0.5, confirm the effective row reads 0.5 (session default) and the Chat parameters hint agrees; record document.documentElement.scrollHeight, input count and word count per tab.
Technical Considerations
Depends on #1914 (PageHeader/PageLayout wrappers and route plumbing), #1913 (settings copy on t() keys; if it has not merged, add the new keys to the catalog and leave the words() sites it owns untouched), and #1902. Related: docs/webui/settings.md scope table, docs/webui/ui-common.md export matrix, #1911 for the Activity counters.
Activity
refactor(webui): key screen copy in the catalog and drop window.confi…
test(webui): open the Requests tab in the cross-engine smoke
fix(webui): keep non-nullable live settings editable after null
fix(webui): scope Settings server defaults to the server instance
feat(webui): split Settings into scoped tabs with typed live controls (…