fix(ui): keep the Models page fixed and give its tabs and filters room (#12899)

The document scrolled again on the gallery. The screen-reader spans in
the table rows are position: absolute, and nothing above them was
positioned, so they resolved against the viewport, escaped the table
scroller and the pinned shell, and stretched the document to about twice
the window height. Make the table scroller and the full-height page the
containing block, so the spans are clipped with the rows.

The tabs and filters were also packed tight. Raise the view tabs to 40px
with an accent underline on the current one, space the facet chips and
toggles apart, give toggles, chips, density and state segments a hit area
of at least 36px (40 to 44px on phones), and leave more air between the
filter rows. Existing --dk-* tokens only.

Add a spec that checks the document height equals the window height
while the table still overflows.

Assisted-by: Claude:claude-sonnet-5-5 [Claude Code]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
mudler-agentandEttore Di Giacinto authored and GitHub committed 2026-10-25 19:13:41 +01:00
1 parent 6e3222922e
commit f7011f1870
3 files changed
+83 -1

No files matched your search

@@ -1,4 +1,5 @@
import { test, expect } from './coverage-fixtures.js'
import { mockLedger } from './ledger-fixtures.js'
// The split view is meant to scroll inside itself. It is easy to regress into
// scrolling the document instead, because the shell's height rules are floors
@@ -64,6 +65,26 @@ test.describe('Models Explore - the view scrolls, not the page', () => {
expect(paneOverflows).toBe('auto')
})
test('the document itself does not scroll while the table does', async ({ page }) => {
// Screen-reader spans inside the table are position: absolute. Without a
// positioned scroller above them they escape every clip and stretch the
// document, so the whole window scrolls instead of the table.
// The shared fixture has a gallery long enough to scroll its table.
await page.unroute('**/api/models*')
await mockLedger(page)
await page.setViewportSize({ width: 1440, height: 900 })
await page.goto('/app/models')
await expect(page.locator('[data-testid="discover-rail-item"]').first()).toBeVisible({ timeout: 10_000 })
const m = await page.evaluate(() => {
const doc = document.scrollingElement
const wrap = document.querySelector('.ledger-wrap')
return { docScroll: doc.scrollHeight, docClient: doc.clientHeight, wrapScroll: wrap.scrollHeight, wrapClient: wrap.clientHeight }
})
expect(m.docScroll).toBe(m.docClient)
expect(m.wrapScroll).toBeGreaterThan(m.wrapClient)
})
test('stacked below the breakpoint it scrolls with the document again', async ({ page }) => {
// Pinning the height when the columns stack would trap both halves in short
// scrollers, so the constraint is lifted there on purpose.
+5
View File
@@ -9229,6 +9229,11 @@ button.home-strip__head:hover:not(:disabled) { background: var(--dk-hover); }
/* The page scrolls inside the pinned shell; the rail and pane keep their own
inner scrollers for long lists and long details. */
overflow-y: auto;
/* Containing block for absolutely positioned descendants. The screen-reader
spans inside long tables (.dk-sr-only is position: absolute) otherwise
resolve against the viewport, escape every scroller below, and give the
document its own scroll height, so the whole window scrolls again. */
position: relative;
}
/* The header, fused. A slim row that reads as the top of the view rather than
+57 -1
View File
@@ -222,7 +222,9 @@
min-height: 0;
}
.ledger-wrap { flex: 1; min-height: 160px; }
/* The table's scroller is also the containing block for its screen-reader
spans, so they are clipped with the rows instead of stretching the page. */
.ledger-wrap { flex: 1; min-height: 160px; position: relative; }
.ledger__pane {
min-width: 0;
@@ -657,6 +659,60 @@ tr[data-selected="true"] .ledger-mark {
.ledger-confirm__ok { display: flex; gap: var(--dk-space-2); align-items: center; color: var(--dk-ok-text); font-weight: var(--dk-weight-semibold); }
.ledger-confirm__warn { color: var(--dk-warn-text); }
/* --- Room for the controls ------------------------------------------------
Tabs choose the view, so they are the larger and more strongly marked
control; filters narrow what the view shows and stay a step smaller. Every
control here has a hit area of at least 36px, tabs 40px. */
.models-page .models-bar { row-gap: var(--dk-space-3); padding-bottom: var(--dk-space-4); }
.models-page .models-bar .view-bar__count { margin-inline-start: var(--dk-space-1); }
.models-page .models-tabs { gap: var(--dk-space-1); padding: 4px; margin-inline-start: var(--dk-space-3); }
.models-page .models-tabs .dk-hubtab { height: 40px; padding: 0 var(--dk-space-4); gap: var(--dk-space-2); }
/* The current view lifts onto a card and carries an accent underline, so it
reads as selected without relying on the surface step alone. */
.models-page .models-tabs .dk-hubtab[aria-current="page"] {
box-shadow: var(--dk-shadow-rest), inset 0 0 0 1px var(--dk-edge), inset 0 -2px 0 var(--dk-accent);
}
.models-page .models-tabs .dk-hubtab:focus-visible { outline: 2px solid var(--dk-accent); outline-offset: 2px; }
.models-page .cat-bar { margin-bottom: var(--dk-space-3); }
.models-page .ledger-controls { gap: var(--dk-space-4); margin-bottom: var(--dk-space-4); }
.models-page .ledger-controls__row { gap: var(--dk-space-3) var(--dk-space-4); }
/* Toggles: the label is part of the target. */
.models-page .ledger-refine { gap: var(--dk-space-2) var(--dk-space-5); }
.models-page .ledger-refine .filter-bar-group__toggle { min-height: 40px; gap: var(--dk-space-3); }
/* Facet chips: taller, and spaced so neighbours are not hit by mistake. */
.models-page .ledger-facets { gap: var(--dk-space-2); padding: 4px 32px 8px 4px; }
.models-page .ledger-facets .dk-chip { height: 36px; padding: 0 var(--dk-space-4); }
.models-page .ledger-facets .dk-chip[aria-pressed="true"] {
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--dk-accent-text) 40%, transparent);
}
/* Density and state segments share one height. */
.models-page .ledger-density { padding: 4px; }
.models-page .ledger-density .dk-seg { width: 40px; height: 36px; }
.models-page .ledger-states { padding: 4px; gap: var(--dk-space-1); }
.models-page .ledger-states .dk-seg { height: 36px; padding: 0 var(--dk-space-4); gap: var(--dk-space-2); }
.models-page .ledger-states .dk-seg[aria-selected="true"] {
box-shadow: var(--dk-shadow-rest), inset 0 0 0 1px var(--dk-edge), inset 0 -2px 0 var(--dk-accent);
}
.models-page .ledger-bar { margin-bottom: var(--dk-space-3); min-height: 32px; }
@media (max-width: 640px) {
/* Thumb-sized targets, and the tabs take the full width as two halves. */
.models-page .models-tabs { width: 100%; margin-inline-start: 0; }
.models-page .models-tabs .dk-hubtab { flex: 1 1 0; justify-content: center; height: 44px; }
.models-page .ledger-refine .filter-bar-group__toggle { min-height: 44px; }
.models-page .ledger-facets .dk-chip { height: 40px; }
.models-page .ledger-density .dk-seg { width: 44px; height: 40px; }
.models-page .ledger-states .dk-seg { height: 40px; }
}
/* --- Narrow ---------------------------------------------------------------- */
@media (max-width: 1100px) {