Skip to content

feat(webui): adopt the ui-common theme system and add a glass theme - #1923

Merged
inureyes merged 8 commits into
mainfrom
feature/issue-1903-theme-system
Sep 19, 2026
Merged

inureyes merged 8 commits into
mainfrom
feature/issue-1903-theme-system

Conversation

@inureyes

@inureyes inureyes commented Sep 19, 2026

Copy link
Copy Markdown
Member

Closes #1903. Part of #1910.

What changes

  • Theme ids. data-theme always holds <family>-<scheme>: mlxcel-light, mlxcel-dark (the approved baseline, still the default), glass-light, glass-dark. webui/src/design-system/theme.ts resolves it; the stored themeFamily and colorScheme (system/light/dark) land in data-theme-family and data-color-scheme. The old flat theme field is migrated, and system follows host changes live. Settings gets Theme (Standard, Glass) and Color scheme selects.
  • ui-common mechanism. main.tsx imports one theme entry (themes/index.css) after base.css, and no ui-common theme file. The color, shadow and button --token-* values moved from :root into [data-theme] blocks unchanged; common-tokens.css keeps the theme-independent half.
  • Glass theme. Translucent chrome with a light reflection, tinted buttons, highlighted primary and danger fills, near-opaque content surfaces, no per-button blur. It reads the existing intensity, material, transparency, backdrop support, contrast and motion switches plus the prefers-* queries; any transparency or contrast preference makes it opaque at any intensity. Values derive from lablup/backend.ai-go (derivation line, NOTICE); the tokens stay here because they bind to this product's semantic token layer.
  • No flash. webui/public/theme-bootstrap.js is a classic render-blocking script in <head>, external because the CSP is script-src 'self'. build_bundle.py rejects a bundle where it is missing, duplicated, deferred in any way, or after the app module, and any inline script.
  • Gate. check:theme-selectors (in the webui-bundle job and make verify-webui-frontend) evaluates every data-theme/data-theme-family selector against the shipped ids and rejects data-color-scheme selectors, prefers-color-scheme outside the resolver, stray theme imports and ids with no theme block; exemptions need a written reason. Against main it reports tokens.css:47, :48, :50, :51, :75.

Found along the way

  • Chrome blur never rendered in Chromium or Firefox (Lightning CSS drops a standard backdrop-filter that precedes its -webkit- twin), and the primary and danger fills were hard-coded over ui-common's rules. Both are one-line fixes in components.css, and both survive the merge with refactor(webui): adopt every shared @lablup/ui-common component #1902's rewrite of that file, alongside its :where(:not(:disabled)) hover rules.
  • features/chat/style.test.ts passed vacuously (Vitest blanks CSS imports); it now reads from disk. models.css read two undefined tokens; inspector labels are now muted and the selected row highlighted.
  • Review fixed rendered glass fills and a bootstrap check that let type="module" through; the security pass fixed the gate skipping its check through a symlink and two super-linear patterns.

Validation

Rebased onto fd33ff15 (#1919, #1921, #1922) with an unchanged source diff, then merged with main at 5616f86b (#1924, #1902's component adoption); app.tsx keeps #1902's PageHeader Settings screen with the theme selects, and the bundle was regenerated. On cdb80fe7: typecheck, lint, the gate, unit (35 node:test, 470 Vitest), Chromium suite (88, including the glass button fill case and #1902's button hover cases; axe on all four ids), build_bundle.py --verify, contract, binary-asset and cross-repo checks. The engines smoke on Chromium, Firefox and WebKit passed before the rebase. Each new check was reverted to confirm it fails.

Bundled-binary checks

Playwright Chromium against mlxcel-server built from c26e3247 (before the rebase and the #1924 merge) with the embedded bundle and no model loaded.

  1. No flash: pass. theme-bootstrap.js is served 200, text/javascript, no-cache, under the strict CSP, ahead of the app module. With the app module held, all nine cases (cold profile under light and dark hosts, each id stored under the opposite host, glass system under both hosts, legacy {theme: dark}) had the right data-theme and canvas with nothing mounted, and no CSP violation.
  2. Settings: pass. Glass then Dark gives glass-dark, persists without the legacy field, is restored before mount on reload; system follows a host flip live.
  3. 390x844: pass in both families: 0 px overflow on all four routes; the sheet opens, navigates, closes on Escape and returns focus.
  4. 200 percent zoom: not native (headless Chromium cannot zoom; a half-width CSS viewport at deviceScaleFactor 2 stood in). 320 and 640 CSS px: 0 px overflow everywhere. 195 CSS px (390 px window): 125 px on every route from body { min-width: 320px }; main's bundle measures the same, so it predates this PR.
  5. Keyboard: pass for mlxcel-light and glass-dark: 18 Tab stops in DOM order, each with a visible indicator; Escape restores focus from the command palette (button and Cmd+K), a Select popup and the sheet.
  6. Contrast: pass. axe WCAG 2.2 AA on all four routes for all four ids at intensity 100: 0 violations normally, under prefers-contrast: more and with High contrast on; the chrome drops to blur(0px) with no gradient.
  7. Served-CSP spec: pass (both variants).

Merge with main

origin/main 31c0459 (#1920, verbatim display names) was merged into this branch as 83fc058 by the epic #1910 orchestrator. Only the generated bundle conflicted; it was regenerated with scripts/webui/build_bundle.py (--verify digest 986294f2) and not hand-merged. On the merged tree: typecheck, lint, check:theme-selectors (152 files, 4 theme ids), unit (Vitest 473 in 51 files) and the Chromium Playwright suite on a private port (88 passed) all pass. The live-server script above was also re-run against a server built from cdb80fe (after #1924 was merged in): 23 of 25 cases pass, and the two failures are the pre-existing 195 CSS px overflow from body { min-width: 320px } described above.

@inureyes inureyes added status:review Under review type:enhancement New features, capabilities, or significant additions priority:medium Medium priority area:architecture Architecture and code structure changes labels Sep 19, 2026
@inureyes

Copy link
Copy Markdown
Member Author

Implementation Review Summary

Intent

Theme the WebUI through the ui-common [data-theme] mechanism with family-scoped ids, add a glass family in light and dark, apply it before mount, and gate dead theme selectors in CI.

Findings Addressed

  • Glass primary and danger buttons never rendered their highlighted fill: components.css hard-coded #245bd8 / #c6352b at the same specificity as ui-common's .button--primary and later in the bundle, so both glass schemes painted the mlxcel fill at rest and the glass hue only on hover. The two rules now read --token-buttonPrimaryBg / --token-buttonDangerBg (mlxcel resolves to the same colors). theme.spec.ts now asserts the rendered fill; with the hex values put back the new glass case fails (background-image: none). Fixed in ca0371d. (HIGH)

Remaining Items

  • scripts/webui/build_bundle.py:232 and theme-bootstrap.test.ts:99 test \b(type="module"|async|defer)\b. The trailing \b after a quote never matches before a space or >, so a bootstrap tag with type="module" (deferred, can paint first) passes both guards, and the "before the app module" check then finds the bootstrap's own type="module". Current markup is correct; the guard is weaker than it claims. Suggested: \btype="module"|\basync\b|\bdefer\b, and locate the app module by src="./assets/. (MEDIUM)
  • themes/glass.css:97-98 gives the danger button the primary's blue --glass-button-shadow (rgba(0, 88, 185, 0.2) in light); lablup/backend.ai-go uses a red-tinted danger shadow. Now visible since the fill renders. (LOW)
  • themes/glass.css:13 names upstream files by a bare repository path; the repository's upstream-reference rule asks for https://github.com/lablup/backend.ai-go/blob/main/src/themes/tokens/glass-light.css style URLs. (LOW)
  • Glass --token-buttonPrimaryBorder / --token-buttonDangerBorder (glass edge) never render: common-components.css:3 sets the inline button border. Decorative only; that file belongs to refactor(webui): adopt every shared @lablup/ui-common component #1902. (LOW)
  • The bootstrap parity matrix in theme-bootstrap.test.ts lists families by hand, so a third family added to THEME_FAMILIES but not to theme-bootstrap.js would not fail the unit gate. (LOW)
  • Pending by design: no-flash on the embedded bundle per theme id, native 200 percent zoom and 390 px, keyboard order and Escape focus restoration, and increased-contrast readability against the installed binary.

Verification

  • All stated requirements implemented (bundled-binary acceptance checks pending, as stated in the PR)
  • No placeholder/mock code remaining
  • Integrated into project code flow (main.tsx entry, index.html bootstrap embedded by rust-embed, Settings controls, CI webui-bundle job)
  • Project conventions followed
  • Existing modules reused where applicable
  • No unintended structural changes
  • Tests pass: typecheck, lint, check:theme-selectors (also fails on a reintroduced [data-theme="dark"]), unit (32 node:test, 401 Vitest), Chromium suite (60, up from 59 with the new rendered-fill case), build_bundle.py --verify, check_binary_assets.py, check_webui_contract.py --self-test

@inureyes

Copy link
Copy Markdown
Member Author

Security and performance review (de46f4f)

No CRITICAL or HIGH findings, so nothing was auto-fixed. Green: typecheck, lint, check:theme-selectors, unit (32 node:test, 425 Vitest), Chromium suite (60), build_bundle.py --verify, check_binary_assets.py, check_webui_contract.py --self-test. Not run: tests/csp.spec.ts, which needs a running server.

Clean: theme-bootstrap.js, fuzzed over 105 stored-value and matchMedia combinations (corrupt JSON, __proto__ keys, non-string values, markup, a 5 MB payload, throwing storage and getters), never throws and writes only allow-listed values. No inline script or style; the bootstrap is same-origin under script-src 'self'. Glass adds no animation, and every opaque or contrast path removes the blur. The system listener is removed on cleanup.

Remaining:

  • MEDIUM webui/scripts/check-theme-selectors.mjs:277: the entry guard compares path.resolve(argv[1]) with the realpath in import.meta.url, so running the script by a symlinked absolute path exits 0 with no output even with a dead selector present (reproduced). pnpm --dir webui run is unaffected. Compare against realpathSync(process.argv[1]).
  • LOW check-theme-selectors.mjs:83: SCRIPT_IMPORT_RE backtracks cubically on import followed by long whitespace (800 spaces: 275 ms); the attribute and @media patterns are quadratic. Repository input only.
  • LOW gate gaps, none present today: uppercase attribute names, escapes or comments inside a selector, conditional @import and <link media> on prefers-color-scheme, dataset['theme'], backtick values; .jsx, .cjs, .mts, .cts files and dot-directories are not scanned.
  • LOW scripts/webui/build_bundle.py:232 and theme-bootstrap.test.ts:103 accept a nomodule bootstrap tag, which browsers never run (reproduced against assert_index_html).
  • LOW themes/glass.css:105 blurs every ui-common secondary and icon button, transcript and table rows included; in WebKit each is its own backdrop layer during streaming scroll. Unmeasured; consider limiting control glass to the chrome.
  • LOW (note) the components.css order swap turns on the chrome blur in Chromium and Firefox for the default family, so the sticky toolbar now re-blurs scrolled content there.

@inureyes

Copy link
Copy Markdown
Member Author

PR finalization pass

Checked test coverage, documentation, and lint/format for #1903 (theme system). Everything already documented in the brief (design-system.md, ui-common.md, ux-contract.md, bundling.md/bundling.ko.md, NOTICE, README) was verified accurate and complete; no duplication added.

Added: two preferences.test.ts cases covering clampIntensity's boundary behavior (negative, over 100, fractional, non-finite and non-numeric stored/direct values) through loadAppearance and applyAppearance. glass-intensity.css has one discrete [data-glass-intensity="N"] rule per integer 0-100, so an unclamped value would silently drop the glass blur and saturation with no visible error; that path had no prior test.

Updated: the Vitest count in docs/webui/design-system.md (Verification section) from 425 to 427, and src/webui/assets/mlxcel-webui-manifest.json (source_digest_sha256 only, asset bytes unchanged) since the test file lives under webui/src. Also corrected stale validation counts in the PR description (32/401/59 to the current 35/427/60).

Gates run, all green: pnpm --dir webui run typecheck, lint, check:theme-selectors, unit (35 node:test, 427 Vitest across 40 files); the full Chromium suite via the isolated Playwright config (60 passed, port 4903); python3 scripts/webui/build_bundle.py and --verify; check_webui_contract.py --self-test; check_binary_assets.py; check_cross_repo_refs.py.

Left open: the bundled-binary acceptance checks (no-flash on the embedded bundle, native 200 percent zoom, 390 px, live contrast) remain pending an orchestrator-provided server, as already noted in the PR description.

Commit: c26e324.

@inureyes inureyes added status:done Completed and removed status:review Under review labels Sep 19, 2026
The WebUI wrote the raw preference into data-theme, so the default `system` matched neither the light nor the dark selector, and the --token-* contract was configured by hand on :root, where no theme file could take effect.

data-theme now always holds a <family>-<scheme> id (mlxcel-light, mlxcel-dark, glass-light, glass-dark) resolved by theme.ts, with the stored family and scheme kept apart and the pre-change flat `theme` field migrated. main.tsx imports one theme entry after ui-common base.css; the baseline values moved into mlxcel theme blocks unchanged, and a glass family reads the existing intensity, transparency, contrast and motion switches. A classic script in index.html applies the id before first paint, since the CSP forbids inline scripts. check-theme-selectors fails on any theme selector no shipped id matches and runs in the webui-bundle job. The glass tokens derive from lablup/backend.ai-go, recorded in NOTICE.

The .material-glass rule now writes -webkit-backdrop-filter first: the minifier kept only the prefixed form, so the chrome blur never rendered in Chromium or Firefox. models.css read two tokens no theme defines.

Validated with typecheck, lint, the new gate, unit (32 node, 401 Vitest), the Chromium browser suite (59), the three-engine smoke, and build_bundle.py --verify.

Refs #1903
The glass theme binds a highlighted gradient fill to --token-buttonPrimaryBg and --token-buttonDangerBg, but the rendered primary and danger buttons never showed it. components.css hard-coded #245bd8 and #c6352b on .ds-button-primary and .ds-button-danger with the same specificity as ui-common's .button--primary and later in the bundle, so both glass schemes painted the mlxcel fill at rest and switched to the glass hue only on hover.

The two rules now read the theme tokens. The mlxcel tokens resolve to the same two colors, so the baseline does not move.

theme.spec.ts now checks the computed fill of the rendered buttons rather than only the tokens: the mlxcel baseline stays flat, both glass schemes paint the gradient over their own solid, and every opaque case paints no gradient. Put the two hex values back and the new glass case fails, because it receives background-image none. Typecheck, lint, the theme-selector gate, unit (32 node:test, 401 Vitest), the Chromium suite (60) and build_bundle.py --verify pass. The committed bundle is regenerated.

Refs #1903
The classic-script checks in build_bundle.py and theme-bootstrap.test.ts used `\b(type="module"|async|defer)\b`, whose trailing word boundary after a quote never matches, so a `type="module"` bootstrap passed, and the ordering check then found the bootstrap's own module attribute. Both now reject any type, async or defer attribute and locate the app module by its src. Feeding build_bundle.py a module, deferred, late, missing or inline variant of index.html rejects each.

The bootstrap parity cases now cover every family and scheme theme.ts ships, so a family added there but not to theme-bootstrap.js fails the unit gate (checked by adding one). The glass danger button gets its own red-tinted shadow instead of the primary blue, and glass.css cites the upstream files by URL.

Refs #1903
From the security review. The gate compared a resolved argv path with import.meta.url, which Node resolves through symlinks, so running it through a symlinked path exited 0 without checking anything; it now compares real paths. The import and attribute-selector patterns backtracked cubically and quadratically on long whitespace runs and are now linear, attribute names match case-insensitively as they do in HTML, and .jsx, .cjs, .mts and .cts files are scanned. build_bundle.py and the unit test also reject a nomodule or duplicated bootstrap tag. Each new spec case fails against the old code.

Glass no longer gives ui-common secondary buttons a backdrop blur. Those buttons sit on transcript, table and form surfaces, which the epic keeps stable, and in WebKit each one became its own blur layer repainted during streaming. They keep the tinted translucent fill.

Refs #1903
data-glass-intensity has one discrete CSS rule per integer 0-100 in glass-intensity.css, so a value outside that range or shape (negative, over 100, a fraction, a non-finite number, a non-numeric string) previously reached loadAppearance and applyAppearance untested; an unclamped write there would silently drop the glass blur and saturation with no visible error.

Adds two preferences.test.ts cases covering clampIntensity's boundary behavior through both entry points, and updates the Vitest count in docs/webui/design-system.md's Verification section from 425 to 427. Regenerates src/webui/assets/mlxcel-webui-manifest.json (source_digest_sha256 only; asset bytes are unchanged) since the test file lives under webui/src.

Validation: pnpm --dir webui run typecheck/lint/check:theme-selectors/unit (35 node:test, 427 Vitest across 40 files), pnpm exec playwright test against the full theme/browser/activity/chat/models/settings suite (60 passed), python3 scripts/webui/build_bundle.py --verify, check_webui_contract.py --self-test, check_binary_assets.py and check_cross_repo_refs.py.
The rebase onto main picked up #1922, which adds Vitest files and Activity and Settings browser cases, so the Verification section now reads 448 Vitest tests across 43 files and 62 Chromium tests (63 with font diagnostics).

Refs #1903
@inureyes
inureyes force-pushed the feature/issue-1903-theme-system branch from c26e324 to 1f28f7d Compare September 19, 2026 06:07
Brings in #1902's ui-common component adoption. components.css merged cleanly with both sides intact: the .material-glass rule keeps -webkit-backdrop-filter ahead of backdrop-filter, .ds-button-primary and .ds-button-danger keep reading --token-buttonPrimaryBg and --token-buttonDangerBg, and #1902's :where(:not(:disabled)) hover rules are unchanged. app.tsx takes #1902's PageHeader Settings screen with this branch's Theme and Color scheme selects and the system-scheme listener. The design-system and ui-common docs keep both texts, with the test counts recomputed. The bundle was regenerated, not hand-merged.

theme.spec.ts (including the glass button fill case) and ui-common-components.spec.ts (including both button hover cases) pass in the full Chromium suite (88), with unit (35 node:test, 470 Vitest), the theme-selector gate and build_bundle.py --verify.

Refs #1903
…eme-system

# Conflicts:
#	src/webui/assets/assets/code-highlight-BZ-vBiaX.js
#	src/webui/assets/assets/code-highlight-CzWARrNf.js
#	src/webui/assets/assets/code-highlight-DVOlMPN7.js
#	src/webui/assets/assets/history-BTvgVv_A.js
#	src/webui/assets/assets/history-Bh2B8HiI.js
#	src/webui/assets/assets/history-DTnpX_xH.js
#	src/webui/assets/assets/index-Bfja5tJs.js
#	src/webui/assets/assets/index-Bm7r1mqL.js
#	src/webui/assets/assets/index-Cw4PvI3b.js
#	src/webui/assets/index.html
#	src/webui/assets/mlxcel-webui-manifest.json
@inureyes
inureyes merged commit da2a88a into main Sep 19, 2026
18 checks passed
@inureyes
inureyes deleted the feature/issue-1903-theme-system branch September 19, 2026 11:16
inureyes added a commit that referenced this pull request Sep 19, 2026
Brings in the theme system from #1923. The only conflicts were in the generated bundle under src/webui/assets/, which was regenerated from the merged source with scripts/webui/build_bundle.py rather than hand-merged.
inureyes added a commit that referenced this pull request Sep 19, 2026
With #1923 the selected Models row now paints its --color-selection fill, and the accent Badge on it failed WCAG AA color contrast: ui-common alpha.19 draws --token-colorPrimary text on a fixed translucent blue fill, which measured 4.49:1 on the selected row in mlxcel-light and 3.73 to 4.49:1 in all four themes under high contrast, where the selection fill is 30 percent focus color. Row activation makes a selected row routine, and the new row-activation axe test caught it after the merge.

The Badge adapter CSS now draws the accent tone as body text inside a --color-focus outline, which clears 4.5:1 on every surface it sits on. A new models.spec.ts test selects a row, hovers another, and runs the axe color-contrast rule on the table in mlxcel and glass, light and dark, with high contrast off and on. Without the CSS change it fails in five of those eight variants (mlxcel-light, and every theme with high contrast on).
inureyes added a commit that referenced this pull request Sep 19, 2026
…1926)

In the Models library a click anywhere in a row, not only on the name button, now opens the inspector for that entry, so Load, Use in Chat, Unload and Delete are reachable without discovering the name button. Selecting still only calls `selectModel` and never loads; clicking the row that is already selected does nothing, because `selectModel` always aborts the live event stream and forces a full snapshot even for the same id.

The behavior is an opt-in seam in the shared `DataTable` adapter, `activateRowPrimary`, for #1918 to build on. A body-row click focuses and clicks the row's primary control (the element with `ROW_PRIMARY_CLASS`, here the "Inspect {name}" button), so pointer and keyboard run the same handler. Clicks on another interactive element in the row, clicks that end a text-selection drag, and rows without an enabled primary control (including the loading and empty rows) are ignored. Rows keep their native `row` role and gain no tab stop; while the name button has `:focus-visible` the whole row is outlined.

## Why not ui-common `onRowClick`

The issue suggested alpha.19's `onRowClick` / `isRowClickable`. Measured on the Models page, they render every body `<tr>` as `role="button"` with `tabIndex=0`, which fails axe `aria-required-children` (critical: the rowgroup holds buttons, not rows) and `nested-interactive` (serious: the name button sits inside the row button), doubles the tab stops per row, and names the row `Inspect alpha cache 4bit chat, ...`, so the `router-real.harness.ts` locator `new RegExp('Inspect .*' + name)` matches two elements and its `.click()` throws. The adapter's props type omits both props so they cannot come back by accident; `docs/webui/ui-common.md` records this.

## Changes during review

- Re-selecting the selected row is a no-op (security review: each stray click cost a stream reconnect, a full snapshot and the Activity history).
- After merging #1923 the selected row paints its selection fill, and the accent "Selected" badge on it failed WCAG AA contrast: 4.49:1 in mlxcel-light and 3.73 to 4.49:1 in every theme under high contrast, because ui-common paints `--token-colorPrimary` text on a fixed translucent blue fill. The Badge adapter now draws the accent tone as body text inside a `--color-focus` outline. A new test checks a selected and a hovered row for contrast in all four themes with high contrast off and on; without the CSS change it fails in five of the eight variants.

## Test-first evidence

The row-activation tests against the unmodified source (origin/main 31c0459):

```
  ✓  1 [chromium] › tests/models.spec.ts:164:1 › library journey observes operations before load/chat/unload/cache removal (924ms)
  ✓  2 [chromium] › tests/models.spec.ts:218:3 › Models 1440 light: bounded inventory, keyboard inspection, no search POST, a11y (713ms)
  ✓  3 [chromium] › tests/models.spec.ts:218:3 › Models 390 dark: bounded inventory, keyboard inspection, no search POST, a11y (688ms)
  ✘  4 [chromium] › tests/models.spec.ts:270:3 › Models row activation › clicking a non-name cell opens that entry and reaches Load, Use in Chat and Unload (5.2s)
  ✘  5 [chromium] › tests/models.spec.ts:294:3 › Models row activation › keyboard Tab reaches the next row with a visible row outline, and Space or Enter opens it (315ms)
  ✘  6 [chromium] › tests/models.spec.ts:323:3 › Models row activation › dragging to select text inside a cell does not activate the row (5.5s)
  ✘  7 [chromium] › tests/models.spec.ts:351:3 › Models row activation › rows keep table semantics, each entry keeps one Inspect button, and the page stays axe clean (5.3s)

    Error: expect(locator).toBeVisible() failed
    Locator: getByRole('complementary', { name: 'Model details' })
    Error: element(s) not found

  4 failed
  3 passed (23.0s)
```

Reverting only `screen.tsx` on the final branch fails all five row-activation tests (the four above plus the contrast test) while the three name-button tests still pass. Every adapter guard was removed in turn and each removal fails a unit test; removing only the selection guard fails the drag test, which ends with a control click on the same point so it cannot pass by missing the row.

## Validation

- `typecheck`, `lint`, `unit` (51 Vitest files, 484 tests, plus 35 node tests including the i18n drift and theme-selector checks), `check:theme-selectors`.
- Playwright Chromium, one spec per run on an isolated port: models 8/8, theme 23/23, browser 28/28, ui-common-components 14/14, ui-common-shell 12/12, settings 3/3, chat 2/2, activity 3/3.
- Bundle regenerated from the merged source; `build_bundle.py --verify`, `check_webui_contract.py`, `check_binary_assets.py` and `check_cross_repo_refs.py` pass. `browser:all` (multi-engine) is left to CI.

## Real-binary acceptance

Run at head e7f3db0 against the bundled server (`--webui --models-dir models/mlx --no-models-autoload --settings --props --metrics --slots`, 224 catalog entries), Chromium at 1440x900, with the library filtered to `qwen3-0.6b-4bit` (two rows: that `models_dir` entry and the `mlx-community/Qwen3-0.6B-4bit` cache entry). All pass:

- [x] Clicking the Lifecycle cell of `qwen3-0.6b-4bit` opens the inspector for that entry; the row gets `models-selected`, its selection fill and the "Selected" badge; no POST is sent. Clicking the other row switches the inspector to it.
- [x] Clicking the already-selected row five more times sends no bootstrap, catalog or events request (0 / 0 / 0). As a control, switching to a different row sends 1 bootstrap, 5 catalog and 1 events reconnect.
- [x] Keyboard: from the search field, 5 Tabs reach the first row's name button (`:focus-visible`); the row shows a 2px solid focus-colored outline and has no `role` or `tabindex`. Space opens that row; Tab moves to the next row (outlined) and Enter opens it.
- [x] From the inspector opened by a row click: Load (lifecycle `loading` then `ready`), Use in Chat (enabled, navigates to `#chat`), then back on Models, Unload through the confirmation dialog (`unloading` then `unloaded`). `GET /ui-api/v1/catalog?q=qwen3-0.6b-4bit` then reports `state: unloaded`, `worker_exit_observed: true`. Exactly two POSTs were sent, both to `/ui-api/v1/model-actions`. The model is left unloaded.
- [x] Drag-selecting text in a Task cell of an unselected row selects "chat, rerank," and leaves the inspector unchanged; a plain click at the same point then opens that row.
- [x] At 390x844 a row click opens the inspector with no horizontal page scroll.

## Real-router harness

Run by the epic #1910 orchestrator on this head (`e7f3db03`): `cargo test --profile test-fast --features metal,accelerate --lib server::router_server::router_webui_playwright_harness_tests::real_router_browser_harness -- --ignored --exact --nocapture` (the `make verify-webui-integration-fake` target) passed. Its Playwright log shows the spec ran rather than skipped: `tests/router-real.harness.ts` "secured Rust router drives download, load, chat, Stop/drain, unload, remove and negative security cases" passed in 5.9 s, including the unchanged `Inspect {name}` locator at `router-real.harness.ts:321`.

Closes #1901

Part of #1910
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:architecture Architecture and code structure changes priority:medium Medium priority status:done Completed type:enhancement New features, capabilities, or significant additions

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(webui): adopt the ui-common theme system and add a glass theme

1 participant