feat(webui): adopt the ui-common theme system and add a glass theme - #1923
Conversation
Implementation Review SummaryIntent
Findings Addressed
Remaining Items
Verification
|
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), Clean: Remaining:
|
PR finalization passChecked 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 Updated: the Vitest count in Gates run, all green: 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. |
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.
c26e324 to
1f28f7d
Compare
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
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.
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).
…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
Closes #1903. Part of #1910.
What changes
data-themealways holds<family>-<scheme>:mlxcel-light,mlxcel-dark(the approved baseline, still the default),glass-light,glass-dark.webui/src/design-system/theme.tsresolves it; the storedthemeFamilyandcolorScheme(system/light/dark) land indata-theme-familyanddata-color-scheme. The old flatthemefield is migrated, andsystemfollows host changes live. Settings gets Theme (Standard, Glass) and Color scheme selects.main.tsximports one theme entry (themes/index.css) afterbase.css, and no ui-common theme file. The color, shadow and button--token-*values moved from:rootinto[data-theme]blocks unchanged;common-tokens.csskeeps the theme-independent half.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.webui/public/theme-bootstrap.jsis a classic render-blocking script in<head>, external because the CSP isscript-src 'self'.build_bundle.pyrejects a bundle where it is missing, duplicated, deferred in any way, or after the app module, and any inline script.check:theme-selectors(in thewebui-bundlejob andmake verify-webui-frontend) evaluates everydata-theme/data-theme-familyselector against the shipped ids and rejectsdata-color-schemeselectors,prefers-color-schemeoutside the resolver, stray theme imports and ids with no theme block; exemptions need a written reason. Againstmainit reportstokens.css:47,:48,:50,:51,:75.Found along the way
backdrop-filterthat precedes its-webkit-twin), and the primary and danger fills were hard-coded over ui-common's rules. Both are one-line fixes incomponents.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.tspassed vacuously (Vitest blanks CSS imports); it now reads from disk.models.cssread two undefined tokens; inspector labels are now muted and the selected row highlighted.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 at5616f86b(#1924, #1902's component adoption);app.tsxkeeps #1902's PageHeader Settings screen with the theme selects, and the bundle was regenerated. Oncdb80fe7: 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-serverbuilt fromc26e3247(before the rebase and the #1924 merge) with the embedded bundle and no model loaded.theme-bootstrap.jsis 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, glasssystemunder both hosts, legacy{theme: dark}) had the rightdata-themeand canvas with nothing mounted, and no CSP violation.glass-dark, persists without the legacy field, is restored before mount on reload;systemfollows a host flip live.body { min-width: 320px }; main's bundle measures the same, so it predates this PR.mlxcel-lightandglass-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.prefers-contrast: moreand with High contrast on; the chrome drops toblur(0px)with no gradient.Merge with main
origin/main31c0459 (#1920, verbatim display names) was merged into this branch as 83fc058 by the epic #1910 orchestrator. Only the generated bundle conflicted; it was regenerated withscripts/webui/build_bundle.py(--verifydigest 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 frombody { min-width: 320px }described above.