feat(ui): give the models gallery filter form a deliberate structure

The filter area had accreted controls into one undifferentiated flow. The
"Fits in GPU" toggle and the backend select were direct children of
.filter-bar, the same wrapping container as the 18 taxonomy chips, so their
position was decided by how many chips happened to wrap at the current width
rather than by any layout intent. At narrow widths they were pushed past the
right edge of that container's horizontal scroll and became unreachable
entirely.

Restructure into three bands inside the house .filter-bar-group wrapper that
components/FilterBar.jsx already uses on Backends and the System tabs:

  1. query scope: search plus the backend select
  2. taxonomy: the chip row, alone, free to wrap
  3. refinements: fits-in-GPU and context size, under a hairline rule

The backend select leads the chips rather than trailing them because picking a
backend disables the use cases that backend cannot serve, so it gates the row
below it. Fits-in-GPU and context size share a band because they are one
control group: the context size is the length the VRAM estimate is computed at,
and that estimate is what the fits filter tests against.

Chips had no visible keyboard focus indicator. The global focus ring is wrapped
in :where(), so it carries the specificity of a bare :focus-visible, ties with
.filter-btn and loses on source order, leaving focused chips showing their
resting drop shadow. Restate the ring where it outranks both resting and hover.

Also: aria-pressed on the chips, a real label association and aria-valuetext on
the context slider (it steps over an index, so it announced "2"), disabled chip
styling moved off inline styles, a prefers-reduced-motion block for the chip
transition, and the hard-coded English "Context:" moved into all seven locales.

No behaviour change: same filters, same state, same requests. Page reset on
change, localStorage persistence and "Clear filters" verified unchanged.

Assisted-by: Claude:claude-opus-4-8
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
Ettore Di Giacinto
2026-07-20 11:17:42 +00:00
parent 462583f383
commit 0d48233626
10 changed files with 350 additions and 72 deletions

View File

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

View File

@@ -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...",

View File

@@ -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...",

View File

@@ -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...",

View File

@@ -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...",

View File

@@ -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...",

View File

@@ -31,7 +31,10 @@
"vad": "VAD",
"fitsGpu": "GPU에 적합",
"allBackends": "모든 백엔드",
"searchBackends": "백엔드 검색..."
"searchBackends": "백엔드 검색...",
"contextSize": "컨텍스트:",
"useCaseLabel": "사용 사례로 필터링",
"unavailableForBackend": "선택한 백엔드에서 사용할 수 없음"
},
"search": {
"placeholder": "모델 검색...",

View File

@@ -25,7 +25,10 @@
"rerank": "重排",
"fitsGpu": "适合 GPU",
"allBackends": "所有后端",
"searchBackends": "搜索后端..."
"searchBackends": "搜索后端...",
"contextSize": "上下文:",
"useCaseLabel": "按用例筛选",
"unavailableForBackend": "所选后端不支持"
},
"search": {
"placeholder": "搜索模型...",

View File

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

View File

@@ -336,73 +336,96 @@ export default function Models() {
<RecommendedModels addToast={addToast} />
{/* Search */}
<div className="search-bar" style={{ marginBottom: 'var(--spacing-md)' }}>
<i className="fas fa-search search-icon" />
<input
className="input"
type="text"
placeholder={t('search.placeholder')}
value={search}
onChange={(e) => handleSearch(e.target.value)}
/>
</div>
{/* 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. */}
<div className="filter-bar-group models-filters">
<div className="filter-bar-group__row models-filters__query">
<div className="search-bar filter-bar-group__search">
<i className="fas fa-search search-icon" aria-hidden="true" />
<input
className="input"
type="text"
placeholder={t('search.placeholder')}
aria-label={t('search.placeholder')}
value={search}
onChange={(e) => handleSearch(e.target.value)}
/>
</div>
{allBackends.length > 0 && (
<div className="models-filters__backend">
<SearchableSelect
value={backendFilter}
onChange={(v) => { setBackendFilter(v); setPage(1) }}
options={allBackends}
placeholder={t('filters.allBackends')}
allOption={t('filters.allBackends')}
searchPlaceholder={t('filters.searchBackends')}
/>
</div>
)}
</div>
{/* Filter buttons */}
<div className="filter-bar">
{FILTERS.map(f => {
const isAll = f.key === ''
const active = isAll ? filters.length === 0 : filters.includes(f.key)
const available = isFilterAvailable(f.key)
return (
<button
key={f.key}
className={`filter-btn ${active ? 'active' : ''}`}
disabled={!available}
style={!available ? { opacity: 0.4, cursor: 'not-allowed' } : undefined}
onClick={() => toggleFilter(f.key)}
>
<i className={`fas ${f.icon}`} style={{ marginRight: 4 }} />
{t(f.labelKey)}
</button>
)
})}
{totalGpuMemory > 0 && (
<label className="filter-bar-group__toggle">
<Toggle checked={fitsFilter} onChange={setFitsFilter} />
<i className="fas fa-microchip" />
<span>{t('filters.fitsGpu')}</span>
</label>
)}
{allBackends.length > 0 && (
<SearchableSelect
value={backendFilter}
onChange={(v) => { setBackendFilter(v); setPage(1) }}
options={allBackends}
placeholder={t('filters.allBackends')}
allOption={t('filters.allBackends')}
searchPlaceholder={t('filters.searchBackends')}
/>
)}
</div>
<div className="filter-bar" role="group" aria-label={t('filters.useCaseLabel')}>
{FILTERS.map(f => {
const isAll = f.key === ''
const active = isAll ? filters.length === 0 : filters.includes(f.key)
const available = isFilterAvailable(f.key)
return (
<button
key={f.key}
type="button"
className={`filter-btn ${active ? 'active' : ''}`}
disabled={!available}
aria-pressed={active}
title={!available ? t('filters.unavailableForBackend') : undefined}
onClick={() => toggleFilter(f.key)}
>
<i className={`fas ${f.icon}`} aria-hidden="true" style={{ marginRight: 4 }} />
{t(f.labelKey)}
</button>
)
})}
</div>
{/* Context size slider for VRAM estimates */}
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-sm)', marginBottom: 'var(--spacing-md)', fontSize: '0.8125rem' }}>
<label style={{ color: 'var(--color-text-muted)', whiteSpace: 'nowrap' }}>
<i className="fas fa-memory" style={{ marginRight: 4 }} />
Context:
</label>
<input
type="range"
min={0}
max={CONTEXT_SIZES.length - 1}
value={CONTEXT_SIZES.indexOf(contextSize)}
onChange={(e) => setContextSize(CONTEXT_SIZES[e.target.value])}
style={{ width: 140, accentColor: 'var(--color-primary)' }}
/>
<span style={{ fontWeight: 600, minWidth: '3em' }}>
{CONTEXT_LABELS[CONTEXT_SIZES.indexOf(contextSize)]}
</span>
<div className="models-filters__refine" data-testid="models-filters-refine">
{totalGpuMemory > 0 && (
<label className="filter-bar-group__toggle">
<Toggle checked={fitsFilter} onChange={setFitsFilter} />
<i className="fas fa-microchip" aria-hidden="true" />
<span>{t('filters.fitsGpu')}</span>
</label>
)}
<div className="models-filters__context">
<label htmlFor="models-context-size">
<i className="fas fa-memory" aria-hidden="true" />
{t('filters.contextSize')}
</label>
<input
id="models-context-size"
type="range"
min={0}
max={CONTEXT_SIZES.length - 1}
value={CONTEXT_SIZES.indexOf(contextSize)}
// The slider steps over an index, so the raw value ("2") is
// meaningless to a screen reader; announce the size instead.
aria-valuetext={CONTEXT_LABELS[CONTEXT_SIZES.indexOf(contextSize)]}
onChange={(e) => setContextSize(CONTEXT_SIZES[e.target.value])}
/>
<span className="models-filters__context-value">
{CONTEXT_LABELS[CONTEXT_SIZES.indexOf(contextSize)]}
</span>
</div>
</div>
</div>
{/* Table */}