From e52d605abbe38fc7921ef031d47ea05b0463c6f1 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Wed, 19 Aug 2026 21:14:58 +0200 Subject: [PATCH] fix(frontend): separate Upcoming Scheduled Purchases actions from the savings figure `.upcoming-card` is a flex row laid out with `justify-content: space-between` and no `gap`, and `.upcoming-actions` had no CSS rule at all. Once a realistic plan name fills the row the space-between free space reaches zero: the savings block ends up pixel-adjacent to the button block, "Est. monthly savings" wraps to two lines, and the two buttons stack. Measured in Chromium, the gap between the savings block and the buttons is 0px from 780px to 1024px, and the gap between View Details and Cancel is 0px at every width including 1600px. Text flush against a solid-background button is what the report describes as the buttons rendering on top of the figure. Give the card a `gap`, pin the savings block at its natural width so a long plan name squeezes the info block instead of the figure, and lay the action block out as a flex row with its own gap. The regression spec measures rendered bounding boxes in Chromium rather than DOM structure, because jsdom does not resolve stylesheets into layout and the jest suite stays green while the collision is visible. It fails on the pre-fix stylesheet at 1024px (separation, label wrap) and 1600px (button gap). Closes #1776. --- frontend/src/styles/plans.css | 14 + .../upcoming-purchases-layout.spec.ts | 252 ++++++++++++++++++ 2 files changed, 266 insertions(+) create mode 100644 frontend/tests-e2e/upcoming-purchases-layout.spec.ts diff --git a/frontend/src/styles/plans.css b/frontend/src/styles/plans.css index ef13b5167..fad593245 100644 --- a/frontend/src/styles/plans.css +++ b/frontend/src/styles/plans.css @@ -94,6 +94,10 @@ display: flex; justify-content: space-between; align-items: center; + /* space-between alone leaves zero separation once the three blocks fill + the row, which put the action buttons flush against the savings figure + (issue #1776). */ + gap: var(--cudly-sp-4); background: var(--cudly-surface); padding: var(--cudly-sp-4) var(--cudly-sp-5); border-radius: var(--cudly-r-md); @@ -143,6 +147,16 @@ .upcoming-savings { text-align: right; + /* Keep the figure and its label at natural width so a long plan name + squeezes the info block instead of wrapping the label into the + buttons. */ + flex-shrink: 0; +} + +/* The block had no rule at all, so its buttons rendered edge to edge. */ +.upcoming-actions { + display: flex; + gap: var(--cudly-sp-2); } .upcoming-savings .amount { diff --git a/frontend/tests-e2e/upcoming-purchases-layout.spec.ts b/frontend/tests-e2e/upcoming-purchases-layout.spec.ts new file mode 100644 index 000000000..22cb3f82e --- /dev/null +++ b/frontend/tests-e2e/upcoming-purchases-layout.spec.ts @@ -0,0 +1,252 @@ +/** + * Layout smoke for the Home -> "Upcoming Scheduled Purchases" card (issue #1776). + * + * The reported failure is "the View Details / Cancel buttons render on top of + * the '$0 Est. monthly savings' text". `.upcoming-card` is a flex row laid out + * with `justify-content: space-between` and no `gap`, and `.upcoming-actions` + * had no CSS rule at all. Once a realistic plan name fills the row the + * space-between free space reaches zero, so the savings text ends up pixel- + * adjacent to the button block, its label wraps, and the buttons stack. + * + * jsdom does not resolve stylesheets into layout, so the jest suite cannot see + * this: it asserts on DOM structure and stays green while the user sees a + * collision. The assertions below run against the real bundle in Chromium and + * measure bounding boxes -- what the browser actually paints. + * + * Every card is measured in a single `evaluate` so all boxes come from one + * layout pass; reading them one locator at a time lets an unrelated async + * reflow (the Home charts settling after a viewport change) shift the numbers + * mid-assertion. + */ + +import { test, expect, type Page } from '@playwright/test'; +import { mockApi, seedAuth } from './fixtures/recs'; + +/** + * Minimum breathing room, in CSS px, between two adjacent blocks in the card. + * Deliberately above zero: the defect this spec pins produced exactly 0px, and + * text flush against a solid-background button reads as an overlap. + */ +const MIN_GAP = 8; + +/** The width at which `.upcoming-card` switches from a flex row to a column. */ +const COLUMN_BREAKPOINT = 768; + +/** + * Rows spanning the boundaries where the card's layout breaks: a long plan + * name (squeezes the row hardest), an absent-savings "$0" figure (the exact + * value in the bug report), the widest realistic currency string, and a short + * row for the unsqueezed baseline. + */ +const UPCOMING = [ + { + execution_id: 'exec-long', + plan_id: 'plan-1', + plan_name: 'Production EC2 Compute Savings Plan ramp - phase two rollout', + scheduled_date: '2026-09-01T00:00:00Z', + provider: 'aws', + service: 'elasticache', + step_number: 12, + total_steps: 24, + estimated_savings: 0, + created_by_user_id: 'user-smoke', + }, + { + execution_id: 'exec-big', + plan_id: 'plan-2', + plan_name: 'Enterprise commitment', + scheduled_date: '2026-09-15T00:00:00Z', + provider: 'azure', + service: 'compute', + step_number: 2, + total_steps: 2, + estimated_savings: 1234567.89, + created_by_user_id: 'user-smoke', + }, + { + execution_id: 'exec-short', + plan_id: 'plan-3', + plan_name: 'Short', + scheduled_date: '2026-10-01T00:00:00Z', + provider: 'gcp', + service: 'gce', + step_number: 1, + total_steps: 1, + estimated_savings: 42, + created_by_user_id: 'user-smoke', + }, +]; + +const SUMMARY = { + potential_monthly_savings: 100, + total_recommendations: 3, + active_commitments: 1, + committed_monthly: 10, + current_coverage: 50, + target_coverage: 80, + ytd_savings: 5, + by_service: {}, +}; + +/** + * Widths to assert at. 1600/1280 are desktop, 1024-780 is the band where the + * row fills up and the collision appeared, 768 is the column breakpoint, and + * 480/360/320 are the mobile sizes the repo already carries findings for. + */ +const WIDTHS = [1600, 1280, 1024, 900, 800, 780, 768, 640, 480, 360, 320]; + +interface Box { + left: number; + right: number; + top: number; + bottom: number; +} + +interface CardBoxes { + card: Box; + savings: Box; + actions: Box; + /** One entry per action button, in DOM order. */ + buttons: Box[]; + /** Number of rendered text lines in the "Est. monthly savings" label. */ + labelLines: number; + /** Horizontal overflow of the card's own content box. */ + cardOverflow: number; +} + +/** Overlapping area of two boxes, in px^2. Zero when they merely touch. */ +function overlapArea(a: Box, b: Box): number { + const x = Math.max(0, Math.min(a.right, b.right) - Math.max(a.left, b.left)); + const y = Math.max(0, Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top)); + return x * y; +} + +/** + * Resize, let any pending reflow settle, then read every box of every card in + * one pass. + */ +async function measureAt(page: Page, width: number): Promise { + await page.setViewportSize({ width, height: 1000 }); + // Two rAFs: the first lands after the resize-driven style recalc, the second + // after any layout it scheduled (Chart.js resizes on the same tick). + await page.evaluate( + () => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => r()))), + ); + + return page.locator('.upcoming-card').evaluateAll((cards) => { + const box = (el: Element) => { + const r = el.getBoundingClientRect(); + return { left: r.left, right: r.right, top: r.top, bottom: r.bottom }; + }; + return cards.map((c) => ({ + card: box(c), + savings: box(c.querySelector('.upcoming-savings')!), + actions: box(c.querySelector('.upcoming-actions')!), + buttons: Array.from(c.querySelectorAll('.upcoming-actions button')).map(box), + // A Range over the text, not the element: `.label` is a block box, so + // Element.getClientRects() returns a single rect however many lines the + // text wraps to, which would make the assertion vacuous. + labelLines: (() => { + const range = document.createRange(); + range.selectNodeContents(c.querySelector('.upcoming-savings .label')!); + return range.getClientRects().length; + })(), + cardOverflow: c.scrollWidth - c.clientWidth, + })); + }); +} + +async function openHome(page: Page): Promise { + await seedAuth(page); + await mockApi(page); + + await page.route('**/api/dashboard/upcoming', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ purchases: UPCOMING }), + }), + ); + await page.route('**/api/dashboard/summary**', (route) => + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(SUMMARY) }), + ); + await page.goto('/home'); + await expect(page.locator('.upcoming-card')).toHaveCount(UPCOMING.length); +} + +test('every fixture row renders both action buttons', async ({ page }) => { + await openHome(page); + const cards = await measureAt(page, 1280); + // Without this, a regression that dropped the Cancel buttons -- the fixture + // session is an admin, so the issue-#950 gate shows them on every row -- + // would let the button-spacing assertions below pass vacuously. + expect(cards.map((c) => c.buttons.length)).toEqual([2, 2, 2]); +}); + +test('the action buttons never overlap or touch the savings figure', async ({ page }) => { + await openHome(page); + + for (const width of WIDTHS) { + const cards = await measureAt(page, width); + + for (const [index, purchase] of UPCOMING.entries()) { + const { savings, actions } = cards[index]!; + const where = `${purchase.plan_name} @ ${width}px`; + + // The reported defect: the buttons paint over the savings text. + expect(overlapArea(savings, actions), `${where}: savings/actions overlap`).toBe(0); + + // Above the breakpoint the card is a flex row, so the blocks are + // separated horizontally; at or below it the card is a column and the + // separation is vertical. + const separation = + width > COLUMN_BREAKPOINT ? actions.left - savings.right : actions.top - savings.bottom; + expect(separation, `${where}: savings/actions separation`).toBeGreaterThanOrEqual(MIN_GAP); + } + } +}); + +test('the action buttons stay on one row, spaced apart from each other', async ({ page }) => { + await openHome(page); + + for (const width of WIDTHS) { + const cards = await measureAt(page, width); + + for (const [index, purchase] of UPCOMING.entries()) { + const { buttons } = cards[index]!; + if (buttons.length < 2) continue; + + const [first, second] = buttons as [Box, Box]; + const where = `${purchase.plan_name} @ ${width}px`; + + // Buttons that wrap onto a second line are what pushed the action block + // into the savings text in the first place. + expect(second.top, `${where}: buttons share a row`).toBeCloseTo(first.top, 0); + expect(second.left - first.right, `${where}: gap between buttons`).toBeGreaterThanOrEqual( + MIN_GAP, + ); + } + } +}); + +test('the savings label keeps one line without overflowing the card', async ({ page }) => { + await openHome(page); + + for (const width of WIDTHS) { + const cards = await measureAt(page, width); + + for (const [index, purchase] of UPCOMING.entries()) { + const { card, actions, labelLines, cardOverflow } = cards[index]!; + const where = `${purchase.plan_name} @ ${width}px`; + + // A wrapped "Est. monthly / savings" is the squeeze that preceded the + // collision; one line means the block kept its natural width. + expect(labelLines, `${where}: savings label line count`).toBe(1); + + // Giving savings and actions their own space must not push the content + // past the card's edge or open a horizontal scrollbar. + expect(actions.right, `${where}: actions within card`).toBeLessThanOrEqual(card.right + 1); + expect(cardOverflow, `${where}: card horizontal overflow`).toBeLessThanOrEqual(0); + } + } +});