diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index aa8b73c75c..de97ab6f53 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -20,6 +20,17 @@ ## Purpose +### Disk mount scrolling + +DisksCard keeps every supplied mount in its parent's scrolling flow. It must not +cap the mount list or create a nested scroll target whose only overflow cue is +a platform scrollbar. Large lists intentionally increase card height; aggregate +usage, individual mount data and empty-state behaviour remain unchanged. This +boundary is local to DisksCard, not a global scrollbar styling requirement. +SharedPrimitives.guardrails.test.ts protects this composition; component tests +preserve mount counts and totals. The disk-mounts qualification fixture checks +short/long lists, themes and keyboard reachability with production CSS. + ### Ollama credential editing The provider panel exposes the existing Basic Auth configuration. Saved passwords are represented by presence text, never a placeholder secret or input value. diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 8c1e2fa553..0a4681caa6 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,55 +1,37 @@ { "version": 1, - "base_sha": "a3eab37bcd36a0a087680ece938ca862c6f4b4d9", - "verified_at": "2026-09-10T20:31:10.676001Z", + "base_sha": "72809bc1cf71e2493d1f0c492b926110a969bdb4", + "verified_at": "2026-09-11T10:26:20.316799Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/components/Settings/AIProviderConfigurationSection.tsx", - "frontend-modern/src/components/Settings/aiSettingsModel.ts", - "frontend-modern/src/components/Settings/useAISettingsState.ts", - "frontend-modern/src/types/ai.ts" + "frontend-modern/src/components/shared/cards/DisksCard.tsx" ], "content_sha256": { - "frontend-modern/src/components/Settings/AIProviderConfigurationSection.tsx": "76f3ed93e95cbe9f24f5cd55d49aeae7397f739e6520665f1b8de074ccaeaa96", - "frontend-modern/src/components/Settings/aiSettingsModel.ts": "9dee0d8b8bd013e870732c3893fa4742e40914ec4931a044b1698bc90f598c22", - "frontend-modern/src/components/Settings/useAISettingsState.ts": "d4bdd6ce19279e5e243b69e9ca769436c3d88a50f243bd8241c7aeffafeb2613", - "frontend-modern/src/types/ai.ts": "bdad2e3926976f761b2a75db03ed4473f67e73cdc60e6327f15acc6626225507" + "frontend-modern/src/components/shared/cards/DisksCard.tsx": "cdda554f93b7aecb925d803b0192260ef1a445065c501468a0412b824dc08028" }, "routes": [ - "/settings/pulse-intelligence/provider", - "/settings/system-updates", - "/proxmox" + "/qualification/disks/?theme=light&count=2", + "/qualification/disks/?theme=dark&count=24" ], "viewports": [ { - "width": 1440, - "height": 900 + "width": 600, + "height": 500 }, { - "width": 390, - "height": 900 + "width": 1200, + "height": 500 } ], "states": [ - "Saved Ollama password with blank masked input", - "Unrelated save preserves credentials", - "Loading disables save and credential controls", - "Failed save retains replacement draft, successful retry preserves whitespace and clears draft", - "Explicit password and username clearing persists across reload", - "Source build clears cached release offers and disables release updates", - "Stable and preview builds retain manual update checks", - "Live Proxmox agent identity and CPU with no source-build agent upgrade target" + "2 and 24 mounts in light and dark themes", + "No nested clipping before scrolling; all mounts remain in outer flow" ], "interactions": [ - "Expand Ollama accordion, focus password and Tab", - "Save unrelated option with keyboard Enter", - "Fail replacement save then retry", - "Clear saved credentials and reload", - "Hover and focus disabled source update check", - "Keyboard manual stable and preview update checks", - "Expand and collapse Proxmox details with keyboard, reload", - "Inspect desktop and narrow screenshots, no horizontal overflow in provider form" + "Focus Before disks, keyboard End then Tab", + "Verify final mount in viewport and After disks focused", + "Inspect full-page Firefox dark narrow and Chromium light desktop screenshots" ], - "command": "node merged-ollama-browser.cjs && node merged-source-build-browser.cjs", - "notes": "Fresh verification of merged source on isolated Vite port 5174 at 1440x900 and 390x900. Playwright uses synthetic provider, version, update and agent HTTP responses over the managed local dev backend. Credential writes are intercepted. Actual pixels inspected for provider saved, clear and reloaded states, source update controls and Proxmox detail. Evidence scripts, interaction matrix, logs and screenshots retained in machine-local issue-1913-upgrade task directory. Earlier locator failures are retained and not counted as passes. No release qualification is claimed." + "command": "pulse-heavy-run -- node tests/qualification/disk-mounts/check.mjs", + "notes": "16 synthetic production-card/CSS cases passed in Firefox142.0.1 and Chromium141.0.7390.37. Screenshots retained in lane outcome screenshots directory. No full installed drawer or reporter Firefox140 ESR reproduction claimed. Production runtime unchanged during completion repair." } diff --git a/frontend-modern/qualification/disks/index.html b/frontend-modern/qualification/disks/index.html new file mode 100644 index 0000000000..9de75126e9 --- /dev/null +++ b/frontend-modern/qualification/disks/index.html @@ -0,0 +1,7 @@ + + + +
+ + + diff --git a/frontend-modern/qualification/disks/main.tsx b/frontend-modern/qualification/disks/main.tsx new file mode 100644 index 0000000000..974b20f12d --- /dev/null +++ b/frontend-modern/qualification/disks/main.tsx @@ -0,0 +1,22 @@ +import { render } from 'solid-js/web'; +import { DisksCard } from '../../src/components/shared/cards/DisksCard'; +import '../../src/index.css'; +const params = new URLSearchParams(location.search); +document.documentElement.classList.toggle('dark', params.get('theme') === 'dark'); +const disks = Array.from({ length: Number(params.get('count') ?? 24) }, (_, i) => ({ + mountpoint: `/mnt/disk-${i}`, + total: 1000000000, + used: 500000000, + free: 500000000, + usage: 0.5, +})); +render( + () => ( +
+ + + +
+ ), + document.getElementById('root')!, +); diff --git a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts index 8296f22fed..055920188f 100644 --- a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts +++ b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts @@ -344,6 +344,14 @@ const frontendIndexCssSource = readFileSync(join(process.cwd(), 'src/index.css') const readFrontendSource = (path: string) => readFileSync(join(process.cwd(), path), 'utf8'); describe('shared primitive guardrails', () => { + it('keeps disk mounts in the parent scrolling flow rather than a hidden nested list', () => { + const source = readFrontendSource('src/components/shared/cards/DisksCard.tsx'); + expect(source).toContain('data-testid="disks-card-mounts"'); + expect(source).not.toMatch(/max-h-|overflow-y-|custom-scrollbar/); + expect(source).toContain(''); + expect(source).toContain(' { expect(HOST_METRICS_HISTORY_GROUPS.map((group) => group.id)).toEqual([ 'utilization', diff --git a/frontend-modern/src/components/shared/cards/DisksCard.tsx b/frontend-modern/src/components/shared/cards/DisksCard.tsx index cffd2d9b21..0644b07724 100644 --- a/frontend-modern/src/components/shared/cards/DisksCard.tsx +++ b/frontend-modern/src/components/shared/cards/DisksCard.tsx @@ -47,7 +47,8 @@ export const DisksCard: Component = (props) => { )} -
+ {/* Keep mounts in the parent scroll flow: overlay scrollbars can hide a nested list. */} +
{(disk) => { const total = disk.total ?? 0; diff --git a/frontend-modern/src/components/shared/cards/__tests__/DisksCard.test.tsx b/frontend-modern/src/components/shared/cards/__tests__/DisksCard.test.tsx index acd11805bb..af0170e5e5 100644 --- a/frontend-modern/src/components/shared/cards/__tests__/DisksCard.test.tsx +++ b/frontend-modern/src/components/shared/cards/__tests__/DisksCard.test.tsx @@ -55,6 +55,21 @@ describe('DisksCard', () => { expect(screen.getByText('/data')).toBeInTheDocument(); }); + it.each([2, 24])('keeps all %i mounts in the parent scroll flow', (count) => { + const disks = Array.from({ length: count }, (_, i) => ({ + mountpoint: `/mnt/disk-${i}`, + total: 100, + used: 50, + free: 50, + usage: 0.5, + })); + render(() => ); + const mounts = screen.getByTestId('disks-card-mounts'); + expect(mounts.children).toHaveLength(count); + expect(mounts.className).not.toMatch(/max-h-|overflow-|custom-scrollbar/); + expect(screen.getByTitle(`/mnt/disk-${count - 1}`)).toBeInTheDocument(); + }); + it('renders nothing when no disks are available', () => { const { container } = render(() => ); diff --git a/tests/qualification/disk-mounts/README.md b/tests/qualification/disk-mounts/README.md new file mode 100644 index 0000000000..b7a1fa23c2 --- /dev/null +++ b/tests/qualification/disk-mounts/README.md @@ -0,0 +1,49 @@ +# Disk mount visibility (#2051) + +The Disks card previously clipped its mount list to 140px. Its thin nested +scrollbar was the only indication of additional mounts. The repair keeps all +mounts in the outer scrolling flow; aggregate usage and individual mount data +are unchanged. Large lists make the card taller rather than introducing another +scroll target. This deliberately avoids a global scrollbar-style change. + +## Reproduce + +Install locked root and frontend-modern npm dependencies and pinned Playwright +Firefox/Chromium browsers, then from the repository root: + +```sh +pulse-heavy-run -- node tests/qualification/disk-mounts/check.mjs +``` + +The isolated Vite fixture imports the production card and stylesheet. It uses +synthetic mount data, no backend, credentials or customer installation. It checks +2 and 24 mounts in light/dark themes, list clipping and keyboard End/Tab access +to the final mount and following control. Vite is shut down after the run. +The qualification HTML is not an application entry or production build input. + +## Evidence — 11 September 2026 + +Baseline 72809bc1cf71, Firefox 142.0.1: short list 54/54px; long list +clientHeight=140, scrollHeight=736, overflow=auto, maxHeight=140px. The no-clipping +assertion fails. After repair all eight Firefox 142.0.1 / Chromium 141.0.7390.37 +cases pass: short 54/54px, long 736/736px, overflow=visible, maxHeight=none. +Keyboard checks pass in each case. Component coverage additionally preserves +aggregate usage, all mounts and the empty state. + +This demonstrates the nested clipping and its removal with production CSS. It +does not establish why Firefox 140.15 ESR on the reporter's Debian desktop hides +its native scrollbar, nor reproduce their full drawer/estate. Reporter retest +and release availability remain distinct from this source/browser proof. + +The reporter's subsequent before/after-scroll crops in comment +https://github.com/rcourtman/Pulse/issues/2051#issuecomment-5632905322 were also +inspected: no evident pre-scroll thumb, then a narrow pale mark beside the disk +bars after scrolling. Removing the nested overflow eliminates that card's native +track/thumb entirely, rather than claiming to repair Firefox painting. The +fixture proves there is no hidden card overflow before keyboard scrolling; +outer-page navigation still reaches every mount. Exact desktop painting remains +unreproduced. + +Completion verification adds 600x500 and 1200x500 viewports: all 16 cases pass. +Full-page narrow Firefox dark and desktop Chromium light images were inspected; +all mounts and the focused following control remain visible without nested clipping. diff --git a/tests/qualification/disk-mounts/check.mjs b/tests/qualification/disk-mounts/check.mjs new file mode 100644 index 0000000000..d557bd5df6 --- /dev/null +++ b/tests/qualification/disk-mounts/check.mjs @@ -0,0 +1,78 @@ +import { firefox, chromium } from "@playwright/test"; +import { createServer } from "../../../frontend-modern/node_modules/vite/dist/node/index.js"; +import assert from "node:assert/strict"; +process.chdir("frontend-modern"); +const server = await createServer({ + root: ".", + configFile: "vite.config.ts", + server: { port: 18791 }, +}); +await server.listen(); +try { + for (const engine of [firefox, chromium]) { + const browser = await engine.launch(); + try { + for (const width of [600, 1200]) + for (const theme of ["light", "dark"]) + for (const count of [2, 24]) { + const page = await browser.newPage({ + viewport: { width, height: 500 }, + }); + await page.goto( + `http://127.0.0.1:18791/qualification/disks/?theme=${theme}&count=${count}`, + ); + const last = page.getByTitle(`/mnt/disk-${count - 1}`, { + exact: true, + }); + await last.waitFor(); + const geometry = await last.evaluate((el) => { + const list = el.parentElement.parentElement.parentElement; + const style = getComputedStyle(list); + return { + height: list.clientHeight, + scrollHeight: list.scrollHeight, + overflow: style.overflowY, + maxHeight: style.maxHeight, + }; + }); + console.log( + JSON.stringify({ + browser: browser.version(), + theme, + width, + count, + ...geometry, + }), + ); + assert.equal( + geometry.height, + geometry.scrollHeight, + "mounts must not be clipped inside a nested scroller", + ); + await page.getByRole("button", { name: "Before disks" }).focus(); + await page.keyboard.press("End"); + await page.waitForTimeout(300); + await page.keyboard.press("Tab"); + assert.equal( + await page + .getByRole("button", { name: "After disks" }) + .evaluate((el) => el === document.activeElement), + true, + ); + const box = await last.boundingBox(); + assert.ok( + box && box.y >= 0 && box.y + box.height <= 500, + "last mount reachable through outer scrolling", + ); + if (process.env.DISK_SCREENSHOT_DIR && count === 24) { + await page.screenshot({path: `${process.env.DISK_SCREENSHOT_DIR}/${engine.name()}-${theme}-${width}.png`, fullPage:true}); + } + await page.close(); + } + } finally { + await browser.close(); + } + } +} finally { + await server.close(); +}