diff --git a/core/http/react-ui/e2e/models-gallery.spec.js b/core/http/react-ui/e2e/models-gallery.spec.js index 5556fa239..fe32f3d9e 100644 --- a/core/http/react-ui/e2e/models-gallery.spec.js +++ b/core/http/react-ui/e2e/models-gallery.spec.js @@ -1081,3 +1081,141 @@ test.describe("Models Gallery - Markdown descriptions", () => { await expect(detail).toContainText("Backend"); }); }); + +// The filter block is three deliberate bands: query scope (search + backend +// select), the use-case chip row, and the refinements (fits-in-GPU + context). +// These assert the separation holds, because the regression they guard against +// is the refinements being swept back into the chip row's wrap, where their +// position depends on how many chips happen to wrap at the current width. +test.describe("Models Gallery - Filter layout structure", () => { + test.beforeEach(async ({ page }) => { + await page.route("**/api/models*", (route) => { + route.fulfill({ + contentType: "application/json", + body: JSON.stringify(MOCK_MODELS_RESPONSE), + }); + }); + await page.route("**/api/backends/usecases", (route) => { + route.fulfill({ + contentType: "application/json", + body: JSON.stringify(BACKEND_USECASES_MOCK), + }); + }); + await page.route("**/api/resources", (route) => { + route.fulfill({ + contentType: "application/json", + body: JSON.stringify(MOCK_GPU_RESOURCES_RESPONSE), + }); + }); + await page.goto("/app/models"); + await expect(page.locator("th", { hasText: "Backend" })).toBeVisible({ + timeout: 10_000, + }); + }); + + test("the chip row contains only use-case chips", async ({ page }) => { + const chipRow = page.locator(".filter-bar"); + await expect(chipRow).toHaveCount(1); + // Nothing but .filter-btn children: no toggle, no select, no slider. + const childClasses = await chipRow.evaluate((el) => + Array.from(el.children).map((c) => c.className), + ); + expect(childClasses.length).toBeGreaterThan(0); + for (const cls of childClasses) { + expect(cls).toContain("filter-btn"); + } + await expect(chipRow.locator("input[type='range']")).toHaveCount(0); + await expect(chipRow.locator(".filter-bar-group__toggle")).toHaveCount(0); + await expect(chipRow.getByText("All Backends")).toHaveCount(0); + }); + + test("refinements live in their own band, outside the chip row", async ({ + page, + }) => { + const refine = page.getByTestId("models-filters-refine"); + await expect(refine).toBeVisible(); + await expect(refine.locator(".filter-bar")).toHaveCount(0); + await expect(refine.getByText("Fits in GPU")).toBeVisible(); + await expect(refine.locator("#models-context-size")).toBeVisible(); + // The band is a sibling of the chip row, never a descendant. + const nested = await page + .locator(".filter-bar") + .locator('[data-testid="models-filters-refine"]') + .count(); + expect(nested).toBe(0); + }); + + test("the backend select sits in the query band above the chips", async ({ + page, + }) => { + const selectBtn = page.locator("button", { hasText: "All Backends" }); + await expect(selectBtn).toBeVisible(); + const inChipRow = await page + .locator(".filter-bar") + .locator("button", { hasText: "All Backends" }) + .count(); + expect(inChipRow).toBe(0); + // Reads above the chips it gates. + const selectBox = await selectBtn.boundingBox(); + const chipBox = await page.locator(".filter-bar").boundingBox(); + expect(selectBox.y).toBeLessThan(chipBox.y); + }); + + test("refinements stay grouped and on one band at a narrow width", async ({ + page, + }) => { + await page.setViewportSize({ width: 900, height: 900 }); + const refine = page.getByTestId("models-filters-refine"); + await expect(refine).toBeVisible(); + const chipBox = await page.locator(".filter-bar").boundingBox(); + const refineBox = await refine.boundingBox(); + // Below the chip row, not interleaved with it. + expect(refineBox.y).toBeGreaterThanOrEqual(chipBox.y + chipBox.height - 1); + await expect(refine.getByText("Fits in GPU")).toBeVisible(); + await expect(refine.locator("#models-context-size")).toBeVisible(); + }); + + test("chips expose pressed state and the context slider is labelled", async ({ + page, + }) => { + const chatBtn = page.locator(".filter-btn", { hasText: "Chat" }); + await expect(chatBtn).toHaveAttribute("aria-pressed", "false"); + await chatBtn.click(); + await expect(chatBtn).toHaveAttribute("aria-pressed", "true"); + + const slider = page.locator("#models-context-size"); + // The slider steps over an index, so the announced value must be the size. + await expect(slider).toHaveAttribute("aria-valuetext", /^\d+K$/); + await expect(page.locator("label[for='models-context-size']")).toBeVisible(); + }); + + test("a keyboard-focused chip shows a focus ring", async ({ page }) => { + // The global :focus-visible rule is wrapped in :where(), so it ties with + // .filter-btn on specificity and loses on order. Without an explicit rule + // the chips render their resting shadow while focused, i.e. no indicator. + await page.locator(".filter-bar-group__search input").click(); + await page.keyboard.press("Tab"); // backend select + await page.keyboard.press("Tab"); // first chip + const focused = page.locator(".filter-btn:focus-visible"); + await expect(focused).toHaveCount(1); + // The ring transitions in, so settle before reading the computed value. + await page.waitForTimeout(400); + const shadow = await focused.evaluate( + (el) => getComputedStyle(el).boxShadow, + ); + // A 3px spread ring, not the 1px/2px resting drop shadow. + expect(shadow).toMatch(/0px 0px 0px 3px/); + }); + + test("the context control is keyboard reachable and drives the value", async ({ + page, + }) => { + const slider = page.locator("#models-context-size"); + const before = await slider.inputValue(); + await slider.focus(); + await expect(slider).toBeFocused(); + await page.keyboard.press("ArrowRight"); + await expect(slider).not.toHaveValue(before); + await expect(slider).toHaveAttribute("aria-valuetext", /^\d+K$/); + }); +}); diff --git a/core/http/react-ui/public/locales/de/models.json b/core/http/react-ui/public/locales/de/models.json index 9142a1b8b..6f165a8e8 100644 --- a/core/http/react-ui/public/locales/de/models.json +++ b/core/http/react-ui/public/locales/de/models.json @@ -25,7 +25,10 @@ "rerank": "Rerank", "fitsGpu": "Passt in die GPU", "allBackends": "Alle Backends", - "searchBackends": "Backends suchen..." + "searchBackends": "Backends suchen...", + "contextSize": "Kontext:", + "useCaseLabel": "Nach Anwendungsfall filtern", + "unavailableForBackend": "Für das gewählte Backend nicht verfügbar" }, "search": { "placeholder": "Modelle suchen...", diff --git a/core/http/react-ui/public/locales/en/models.json b/core/http/react-ui/public/locales/en/models.json index ec5180f3b..804c54490 100644 --- a/core/http/react-ui/public/locales/en/models.json +++ b/core/http/react-ui/public/locales/en/models.json @@ -44,7 +44,10 @@ "ner": "NER", "fitsGpu": "Fits in GPU", "allBackends": "All Backends", - "searchBackends": "Search backends..." + "searchBackends": "Search backends...", + "contextSize": "Context:", + "useCaseLabel": "Filter by use case", + "unavailableForBackend": "Not available for the selected backend" }, "search": { "placeholder": "Search models...", diff --git a/core/http/react-ui/public/locales/es/models.json b/core/http/react-ui/public/locales/es/models.json index 69ca19dae..15e13a613 100644 --- a/core/http/react-ui/public/locales/es/models.json +++ b/core/http/react-ui/public/locales/es/models.json @@ -25,7 +25,10 @@ "rerank": "Rerank", "fitsGpu": "Cabe en la GPU", "allBackends": "Todos los backends", - "searchBackends": "Buscar backends..." + "searchBackends": "Buscar backends...", + "contextSize": "Contexto:", + "useCaseLabel": "Filtrar por caso de uso", + "unavailableForBackend": "No disponible para el backend seleccionado" }, "search": { "placeholder": "Buscar modelos...", diff --git a/core/http/react-ui/public/locales/id/models.json b/core/http/react-ui/public/locales/id/models.json index 3acff3afb..4811edc6d 100644 --- a/core/http/react-ui/public/locales/id/models.json +++ b/core/http/react-ui/public/locales/id/models.json @@ -32,7 +32,10 @@ "vad": "VAD", "fitsGpu": "Muat di GPU", "allBackends": "Semua Backend", - "searchBackends": "Cari backends..." + "searchBackends": "Cari backends...", + "contextSize": "Konteks:", + "useCaseLabel": "Filter berdasarkan kasus penggunaan", + "unavailableForBackend": "Tidak tersedia untuk backend yang dipilih" }, "search": { "placeholder": "Cari model...", diff --git a/core/http/react-ui/public/locales/it/models.json b/core/http/react-ui/public/locales/it/models.json index 15eca936f..a23ab3f9e 100644 --- a/core/http/react-ui/public/locales/it/models.json +++ b/core/http/react-ui/public/locales/it/models.json @@ -25,7 +25,10 @@ "rerank": "Rerank", "fitsGpu": "Entra nella GPU", "allBackends": "Tutti i backend", - "searchBackends": "Cerca backend..." + "searchBackends": "Cerca backend...", + "contextSize": "Contesto:", + "useCaseLabel": "Filtra per caso d'uso", + "unavailableForBackend": "Non disponibile per il backend selezionato" }, "search": { "placeholder": "Cerca modelli...", diff --git a/core/http/react-ui/public/locales/ko/models.json b/core/http/react-ui/public/locales/ko/models.json index 2ca05d4a7..40b18f26a 100644 --- a/core/http/react-ui/public/locales/ko/models.json +++ b/core/http/react-ui/public/locales/ko/models.json @@ -31,7 +31,10 @@ "vad": "VAD", "fitsGpu": "GPU에 적합", "allBackends": "모든 백엔드", - "searchBackends": "백엔드 검색..." + "searchBackends": "백엔드 검색...", + "contextSize": "컨텍스트:", + "useCaseLabel": "사용 사례로 필터링", + "unavailableForBackend": "선택한 백엔드에서 사용할 수 없음" }, "search": { "placeholder": "모델 검색...", diff --git a/core/http/react-ui/public/locales/zh-CN/models.json b/core/http/react-ui/public/locales/zh-CN/models.json index 88cd49d7d..f1ddd7dbf 100644 --- a/core/http/react-ui/public/locales/zh-CN/models.json +++ b/core/http/react-ui/public/locales/zh-CN/models.json @@ -25,7 +25,10 @@ "rerank": "重排", "fitsGpu": "适合 GPU", "allBackends": "所有后端", - "searchBackends": "搜索后端..." + "searchBackends": "搜索后端...", + "contextSize": "上下文:", + "useCaseLabel": "按用例筛选", + "unavailableForBackend": "所选后端不支持" }, "search": { "placeholder": "搜索模型...", diff --git a/core/http/react-ui/src/App.css b/core/http/react-ui/src/App.css index 1b4273a21..e47b76b68 100644 --- a/core/http/react-ui/src/App.css +++ b/core/http/react-ui/src/App.css @@ -2191,6 +2191,68 @@ select.input { user-select: none; white-space: nowrap; } +/* Models gallery filter block. Three bands (query / taxonomy / refinements) + stacked by .filter-bar-group. The refinements live in their own band so the + chip row's wrap height can never displace them. */ +.models-filters__query { + flex-wrap: nowrap; + gap: var(--spacing-sm); +} +.models-filters__backend { + flex: 0 0 auto; + min-width: 0; +} +/* .filter-bar carries its own bottom margin for standalone use; inside the + group the parent's gap owns the rhythm. */ +.models-filters > .filter-bar { + margin-bottom: 0; +} +.models-filters__refine { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--spacing-sm) var(--spacing-lg); + padding-top: var(--spacing-sm); + border-top: 1px solid var(--color-border-subtle); +} +/* Both refinements read as one secondary group: same size, same colour and the + same icon-then-label rhythm as .filter-bar-group__toggle. */ +.models-filters__context { + display: flex; + align-items: center; + gap: var(--spacing-sm); + font-size: var(--text-xs); + color: var(--color-text-secondary); +} +.models-filters__context > label { + display: inline-flex; + align-items: center; + gap: var(--spacing-xs); + white-space: nowrap; + cursor: pointer; +} +.models-filters__context input[type='range'] { + width: 140px; + accent-color: var(--color-primary); + cursor: pointer; +} +.models-filters__context-value { + font-weight: var(--font-weight-medium); + min-width: 3em; + font-variant-numeric: tabular-nums; +} + +@media (max-width: 768px) { + /* Search and backend select each take a full row rather than squeezing the + input below a usable width. */ + .models-filters__query { + flex-wrap: wrap; + } + .models-filters__backend { + flex: 1 1 100%; + } +} + .filter-btn__count { display: inline-flex; align-items: center; @@ -2720,6 +2782,40 @@ button.collapsible-header:focus-visible { border-color: var(--color-primary-border); } +/* The global :focus-visible ring is wrapped in :where(), so it carries the + specificity of a bare :focus-visible - the same (0,1,0) as .filter-btn, which + is declared later and therefore won with its resting shadow. Chips were left + with no visible keyboard focus indicator at all; this restates the ring where + it outranks both the resting and the hover shadow. */ +.filter-btn:focus-visible { + box-shadow: 0 0 0 3px var(--color-focus-ring); + border-color: var(--color-primary); +} + +/* A chip is disabled when the selected backend cannot serve that use case. It + keeps its resting colours so the row still reads as a set, dimmed enough to + register as out of play. */ +.filter-btn:disabled { + opacity: 0.4; + cursor: not-allowed; + box-shadow: none; +} +.filter-btn:disabled:hover { + transform: none; + box-shadow: none; + color: var(--color-text-secondary); + border-color: var(--color-border-default); +} + +@media (prefers-reduced-motion: reduce) { + .filter-btn { + transition: none; + } + .filter-btn:hover { + transform: none; + } +} + /* Login page */ .login-page { min-height: 100vh; diff --git a/core/http/react-ui/src/pages/Models.jsx b/core/http/react-ui/src/pages/Models.jsx index e5665b44e..152fef833 100644 --- a/core/http/react-ui/src/pages/Models.jsx +++ b/core/http/react-ui/src/pages/Models.jsx @@ -336,73 +336,96 @@ export default function Models() { - {/* Search */} -
- - handleSearch(e.target.value)} - /> -
+ {/* Filters, in three deliberate bands. + 1. Query scope: free-text search plus the backend select. The backend + select leads the taxonomy row rather than trailing it because + picking a backend disables the use-cases that backend cannot serve + (see isFilterAvailable), so it reads as the gate on what follows. + 2. Taxonomy: the use-case chips, which wrap freely. + 3. Refinements: fits-in-GPU and context size. These two are one + control group, not two strays - the context size is the length the + VRAM estimate is computed at, and that estimate is exactly what the + fits filter tests against. + Each band owns its container, so how many chips happen to wrap at a + given width can no longer decide where the other controls land. */} +
+
+
+