From 0c56dfe711b15fbfc64495d11d69c58ba6c3e344 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Fri, 9 Oct 2026 13:50:08 +0200 Subject: [PATCH 1/2] fix(frontend): stop pages scrolling horizontally at 1280, 768 and 390 Tables scroll inside their own box at every width (the rule only applied below 768px), fieldsets get min-width: 0, hidden tooltips use display: none instead of visibility: hidden so they no longer extend the scrollable area, the recommendations action box and filter bar wrap, settings and inventory sub-tab bars scroll within themselves, a select inside a settings row stays within the row, and chart canvases are capped at their card width. A Playwright guard compares scrollWidth to the viewport for nine pages at three widths. Refs #703 Co-Authored-By: Claude Sonnet 5.5 --- frontend/src/styles/charts.css | 3 ++ frontend/src/styles/components.css | 17 +++++----- frontend/src/styles/responsive.css | 13 +++++--- frontend/src/styles/settings.css | 11 +++++++ frontend/tests-e2e/page-overflow.spec.ts | 40 ++++++++++++++++++++++++ 5 files changed, 72 insertions(+), 12 deletions(-) create mode 100644 frontend/tests-e2e/page-overflow.spec.ts diff --git a/frontend/src/styles/charts.css b/frontend/src/styles/charts.css index e62f9e63..f63828ea 100644 --- a/frontend/src/styles/charts.css +++ b/frontend/src/styles/charts.css @@ -9,6 +9,9 @@ } .chart-section canvas { + /* Chart.js sizes the canvas from its parent; without this a canvas that + * was drawn wider than the card keeps the page wide (#703). */ + max-width: 100%; max-height: 300px; min-height: 280px; } diff --git a/frontend/src/styles/components.css b/frontend/src/styles/components.css index d9f1fe6f..d08c722c 100644 --- a/frontend/src/styles/components.css +++ b/frontend/src/styles/components.css @@ -702,6 +702,7 @@ th .column-filter-btn.active { /* Filter-status bar (Clear-filters badge + aria-live count) above the table. */ .recommendations-filter-status { display: flex; + flex-wrap: wrap; align-items: center; gap: 0.75rem; padding: 0.4rem 0; @@ -749,7 +750,8 @@ th .column-filter-btn.active { .recommendations-action-box { position: sticky; bottom: 0; - height: var(--action-box-height); + min-height: var(--action-box-height); + flex-wrap: wrap; background: var(--cudly-surface); border-top: 1px solid var(--cudly-border); box-shadow: var(--cudly-shadow-md); @@ -1235,8 +1237,10 @@ tr.rec-variant-row:hover td:not(.checkbox-col) { } .info-icon .tooltip-text { - visibility: hidden; - opacity: 0; + /* display:none, not visibility:hidden: a hidden absolutely positioned + * box still extends the scrollable area and widens the page (#703). */ + display: none; + max-width: calc(100vw - 2rem); position: absolute; z-index: 1000; bottom: 125%; @@ -1252,7 +1256,6 @@ tr.rec-variant-row:hover td:not(.checkbox-col) { line-height: 1.4; text-align: left; box-shadow: 0 2px 8px rgba(0,0,0,0.2); - transition: opacity 0.2s, visibility 0.2s; white-space: normal; } @@ -1267,9 +1270,9 @@ tr.rec-variant-row:hover td:not(.checkbox-col) { border-color: var(--cudly-text) transparent transparent transparent; } -.info-icon:hover .tooltip-text { - visibility: visible; - opacity: 1; +.info-icon:hover .tooltip-text, +.info-icon:focus-within .tooltip-text { + display: block; } /* Role badge */ diff --git a/frontend/src/styles/responsive.css b/frontend/src/styles/responsive.css index 4a17ab04..9701fece 100644 --- a/frontend/src/styles/responsive.css +++ b/frontend/src/styles/responsive.css @@ -121,11 +121,6 @@ body.sidebar-open { width: 100%; } - table { - display: block; - overflow-x: auto; - } - .modal-content { width: 95%; } @@ -352,3 +347,11 @@ body.sidebar-open { white-space: nowrap; } } + +/* Tables scroll inside their own box at every width, so a wide table never + * widens the page (issue #703). Previously this only applied below 768px. */ +table { + display: block; + max-width: 100%; + overflow-x: auto; +} diff --git a/frontend/src/styles/settings.css b/frontend/src/styles/settings.css index 85e573c0..84563d3c 100644 --- a/frontend/src/styles/settings.css +++ b/frontend/src/styles/settings.css @@ -3,11 +3,13 @@ .settings-tabs, .inventory-subnav { display: flex; + overflow-x: auto; border-bottom: 2px solid var(--cudly-border); margin-bottom: 1.5rem; } .sub-tab-btn { + flex-shrink: 0; padding: 0.65rem 1.25rem; background: none; border: none; @@ -66,6 +68,8 @@ } .settings-category { + /* A fieldset defaults to min-width: min-content, which widens the page. */ + min-width: 0; border: 1px solid var(--cudly-border); border-radius: 8px; padding: 1.5rem; @@ -117,6 +121,13 @@ display: flex; align-items: center; gap: 1rem; + /* A select sizes to its longest option; keep it inside the row. */ + max-width: 100%; + min-width: 0; +} + +.setting-input select { + max-width: 100%; } .setting-input input[type="text"], diff --git a/frontend/tests-e2e/page-overflow.spec.ts b/frontend/tests-e2e/page-overflow.spec.ts new file mode 100644 index 00000000..87cc48b9 --- /dev/null +++ b/frontend/tests-e2e/page-overflow.spec.ts @@ -0,0 +1,40 @@ +/** + * Page-level horizontal scroll guard (issue #703). + * + * A wide table, a fieldset's min-content width, a hidden-but-laid-out + * tooltip or a non-wrapping toolbar each push the document wider than the + * viewport, so the whole page scrolls sideways. jsdom has no layout, so this + * runs in Chromium and compares the document's scroll width to the viewport. + */ + +import { test, expect } from '@playwright/test'; +import { mockApi, seedAuth } from './fixtures/recs'; + +const PAGES = [ + '/home', + '/opportunities', + '/plans', + '/purchases', + '/inventory', + '/admin/general', + '/admin/purchasing', + '/admin/accounts', + '/admin/users', +]; +const WIDTHS = [1280, 768, 390]; + +for (const width of WIDTHS) { + for (const path of PAGES) { + test(`${path} does not scroll horizontally at ${width}px`, async ({ page }) => { + await page.setViewportSize({ width, height: 900 }); + await seedAuth(page); + await mockApi(page); + await page.goto(path); + await page.waitForLoadState('networkidle'); + const overflow = await page.evaluate( + () => document.documentElement.scrollWidth - window.innerWidth, + ); + expect(overflow).toBeLessThanOrEqual(0); + }); + } +} From 2b963b121b313bab7e005b067df01d9b95f8dd3b Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Fri, 9 Oct 2026 14:29:34 +0200 Subject: [PATCH 2/2] fix(frontend): scope table overflow to containers and cover populated pages Tables keep display: table; the container that directly holds a table takes overflow-x: auto instead (the recommendations list is excluded so its sticky action box keeps sticking, its table scrolls itself). Group card actions and headers wrap, member pills break long emails, the permission matrix is a labelled focusable scroll region and is the containing block for its sr-only cell labels, and the dead tooltip :focus-within rule is dropped. The overflow guard now routes populated users and groups. Refs #703 Co-Authored-By: Claude Sonnet 5.5 --- frontend/src/styles/components.css | 3 +-- frontend/src/styles/responsive.css | 13 ++++++++++--- frontend/src/styles/settings.css | 13 ++++++++++++- frontend/src/users/permissionMatrix.ts | 8 ++++++++ frontend/tests-e2e/page-overflow.spec.ts | 22 ++++++++++++++++++++++ 5 files changed, 53 insertions(+), 6 deletions(-) diff --git a/frontend/src/styles/components.css b/frontend/src/styles/components.css index d08c722c..f51af1a5 100644 --- a/frontend/src/styles/components.css +++ b/frontend/src/styles/components.css @@ -1270,8 +1270,7 @@ tr.rec-variant-row:hover td:not(.checkbox-col) { border-color: var(--cudly-text) transparent transparent transparent; } -.info-icon:hover .tooltip-text, -.info-icon:focus-within .tooltip-text { +.info-icon:hover .tooltip-text { display: block; } diff --git a/frontend/src/styles/responsive.css b/frontend/src/styles/responsive.css index 9701fece..0fbe0ee6 100644 --- a/frontend/src/styles/responsive.css +++ b/frontend/src/styles/responsive.css @@ -348,9 +348,16 @@ body.sidebar-open { } } -/* Tables scroll inside their own box at every width, so a wide table never - * widens the page (issue #703). Previously this only applied below 768px. */ -table { +/* A wide table scrolls inside its container instead of widening the page + * (issue #703). The container, not the table, takes the overflow so a short + * table keeps display:table and fills the card. The recommendations list is + * excluded: its sticky action box must keep sticking to the viewport, so its + * table scrolls itself. */ +:where(div, section, fieldset, form, article):has(> table):not(#recommendations-list) { + overflow-x: auto; +} + +#recommendations-list > table { display: block; max-width: 100%; overflow-x: auto; diff --git a/frontend/src/styles/settings.css b/frontend/src/styles/settings.css index 84563d3c..ddae897a 100644 --- a/frontend/src/styles/settings.css +++ b/frontend/src/styles/settings.css @@ -462,13 +462,15 @@ select.dirty { } .group-card-header { display: flex; + flex-wrap: wrap; + gap: 0.5rem; justify-content: space-between; align-items: flex-start; margin-bottom: 0.75rem; } .group-card-header h4 { margin: 0 0 0.25rem; } .group-card-header .group-description { font-size: 0.85rem; color: var(--cudly-text-muted); margin: 0; } -.group-card-actions { display: flex; gap: 0.5rem; flex-shrink: 0; align-items: center; } +.group-card-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; min-width: 0; align-items: center; } .permission-badge { display: inline-block; font-size: 0.8rem; @@ -481,6 +483,9 @@ select.dirty { } .member-pill { display: inline-block; + max-width: 100%; + box-sizing: border-box; + overflow-wrap: anywhere; font-size: 0.8rem; padding: 0.15rem 0.4rem; border-radius: 10px; @@ -617,3 +622,9 @@ select.dirty { tr.override-disabled { opacity: 0.55; } + +/* The matrix scrolls sideways; its absolutely positioned .sr-only cell labels + * need this scroller as their containing block or they extend the page. */ +#permission-matrix { + position: relative; +} diff --git a/frontend/src/users/permissionMatrix.ts b/frontend/src/users/permissionMatrix.ts index 309a007a..8f4f7eec 100644 --- a/frontend/src/users/permissionMatrix.ts +++ b/frontend/src/users/permissionMatrix.ts @@ -18,6 +18,9 @@ const ACTIONS = ['view', 'create', 'update', 'delete', 'execute', 'approve', 'ad */ function renderEmptyState(container: HTMLElement, message: string): void { container.textContent = ''; + container.removeAttribute('tabindex'); + container.removeAttribute('role'); + container.removeAttribute('aria-label'); const p = document.createElement('p'); p.className = 'empty-state'; p.textContent = message; @@ -74,4 +77,9 @@ export function renderPermissionMatrix(groups: APIGroup[], container: HTMLElemen `; + // The matrix scrolls sideways on narrow screens and has no focusable + // content of its own, so the scroll region must be keyboard-reachable. + container.setAttribute('tabindex', '0'); + container.setAttribute('role', 'region'); + container.setAttribute('aria-label', 'Permission matrix'); } diff --git a/frontend/tests-e2e/page-overflow.spec.ts b/frontend/tests-e2e/page-overflow.spec.ts index 87cc48b9..b9cc0edb 100644 --- a/frontend/tests-e2e/page-overflow.spec.ts +++ b/frontend/tests-e2e/page-overflow.spec.ts @@ -10,6 +10,20 @@ import { test, expect } from '@playwright/test'; import { mockApi, seedAuth } from './fixtures/recs'; +const GROUPS = ['Administrators', 'Purchasers', 'Read-only finance auditors', 'Platform engineering leads'].map((name, i) => ({ + id: `00000000-0000-5000-8000-00000000000${i + 1}`, + name, + description: `${name}: a deliberately long description so the group card header has to share its row with the action buttons`, + permissions: [{ action: i === 0 ? 'admin' : 'view', resource: i === 0 ? '*' : 'recommendations' }], +})); +const USERS = [1, 2, 3].map((n) => ({ + id: `user-${n}`, + email: `long.address.for.user.number.${n}@subdomain.example-company.com`, + groups: GROUPS.map((g) => g.id), + mfa_enabled: n % 2 === 0, + created_at: '2026-07-01T00:00:00Z', +})); + const PAGES = [ '/home', '/opportunities', @@ -29,6 +43,14 @@ for (const width of WIDTHS) { await page.setViewportSize({ width, height: 900 }); await seedAuth(page); await mockApi(page); + // Populated users and groups: empty lists hide the group cards, the + // permission matrix and the long user rows that overflow on phones. + await page.route('**/api/users', (route) => + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ users: USERS }) }), + ); + await page.route('**/api/groups', (route) => + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ groups: GROUPS }) }), + ); await page.goto(path); await page.waitForLoadState('networkidle'); const overflow = await page.evaluate(