From 9b7ca8e458781172b2fab3c9983cdd2027481bac Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Sat, 24 Oct 2026 01:34:44 +0000 Subject: [PATCH 1/3] fix(frontend): render the Opportunities summary with the Home kpi-tile Home built its four summary cards as .card.kpi-tile with DOM calls, while Opportunities wrote plain .card innerHTML, so the same kind of KPI had a different title weight, value size, padding and border on the two pages. New lib/kpi-tile.ts (buildKpiTile / renderKpiTiles) builds the tile markup once with textContent. Home's renderDashboardSummary now uses it (same markup as before, sparkline slot included) and so does the Opportunities summary (data-kpi count / savings / upfront / payback, no detail line or sparkline). The card/value/savings classes stay, so existing selectors and tests keep working. Refs #743 Co-Authored-By: Claude Opus 5.5 --- frontend/src/__tests__/kpi-tile.test.ts | 57 ++++++++++ .../recommendations-kpi-tiles.test.ts | 107 ++++++++++++++++++ frontend/src/dashboard.ts | 26 +---- frontend/src/lib/kpi-tile.ts | 71 ++++++++++++ frontend/src/recommendations.ts | 27 ++--- 5 files changed, 247 insertions(+), 41 deletions(-) create mode 100644 frontend/src/__tests__/kpi-tile.test.ts create mode 100644 frontend/src/__tests__/recommendations-kpi-tiles.test.ts create mode 100644 frontend/src/lib/kpi-tile.ts diff --git a/frontend/src/__tests__/kpi-tile.test.ts b/frontend/src/__tests__/kpi-tile.test.ts new file mode 100644 index 000000000..2f85febf8 --- /dev/null +++ b/frontend/src/__tests__/kpi-tile.test.ts @@ -0,0 +1,57 @@ +/** + * Issue #743: lib/kpi-tile builds the one KPI tile used by Home and + * Opportunities. + */ +import { buildKpiTile, renderKpiTiles } from '../lib/kpi-tile'; + +describe('buildKpiTile', () => { + test('builds the full tile: title, value, detail and hidden spark slot', () => { + const tile = buildKpiTile({ + kpi: 'savings', title: 'Potential Monthly Savings', value: '$300', valueSavings: true, + detail: '2 recommendations', spark: true, + }); + expect(tile.className).toBe('card kpi-tile'); + expect(tile.dataset['kpi']).toBe('savings'); + expect(tile.querySelector('h3')?.textContent).toBe('Potential Monthly Savings'); + const value = tile.querySelector('p.value')!; + expect(value.className).toBe('value kpi-tile-value savings'); + expect(value.textContent).toBe('$300'); + expect(tile.querySelector('p.detail.kpi-tile-detail')?.textContent).toBe('2 recommendations'); + const spark = tile.querySelector('svg.kpi-tile-spark')!; + expect(spark.classList.contains('hidden')).toBe(true); + expect(spark.getAttribute('data-spark-key')).toBe('savings'); + expect(spark.getAttribute('aria-hidden')).toBe('true'); + }); + + test('omits the detail line, savings color and spark slot when not asked for', () => { + const tile = buildKpiTile({ kpi: 'count', title: 'Total Recommendations', value: '19' }); + expect(tile.querySelector('p.value')?.classList.contains('savings')).toBe(false); + expect(tile.querySelector('.kpi-tile-detail')).toBeNull(); + expect(tile.querySelector('svg')).toBeNull(); + expect(tile.children).toHaveLength(2); + }); + + test('an empty-string detail still renders the line', () => { + const tile = buildKpiTile({ kpi: 'x', title: 'T', value: 'V', detail: '' }); + expect(tile.querySelector('.kpi-tile-detail')).not.toBeNull(); + }); + + test('treats title, value and detail as text, never markup', () => { + const tile = buildKpiTile({ kpi: 'x', title: 't', value: '', detail: 'd' }); + expect(tile.querySelector('img, b, i')).toBeNull(); + expect(tile.querySelector('p.value')?.textContent).toBe(''); + }); +}); + +describe('renderKpiTiles', () => { + test('replaces the container contents with one tile per spec, in order', () => { + const container = document.createElement('section'); + container.innerHTML = '

old

'; + renderKpiTiles(container, [ + { kpi: 'a', title: 'A', value: '1' }, + { kpi: 'b', title: 'B', value: '2' }, + ]); + expect(container.querySelector('.error')).toBeNull(); + expect(Array.from(container.children).map((c) => (c as HTMLElement).dataset['kpi'])).toEqual(['a', 'b']); + }); +}); diff --git a/frontend/src/__tests__/recommendations-kpi-tiles.test.ts b/frontend/src/__tests__/recommendations-kpi-tiles.test.ts new file mode 100644 index 000000000..74da42fb6 --- /dev/null +++ b/frontend/src/__tests__/recommendations-kpi-tiles.test.ts @@ -0,0 +1,107 @@ +/** + * Issue #743: the Opportunities summary renders the same .kpi-tile component + * as Home (it used to write plain `.card` innerHTML, so the same kind of KPI + * looked different on the two pages). + */ +import { loadRecommendations } from '../recommendations'; +import type { LocalRecommendation } from '../types'; + +jest.mock('../api', () => ({ + getRecommendations: jest.fn(), + refreshRecommendations: jest.fn(), + listAccounts: jest.fn().mockResolvedValue([]), + listAccountsMinimal: jest.fn().mockResolvedValue([]), + getConfig: jest.fn().mockResolvedValue({ global: {} }), + listAccountServiceOverrides: jest.fn().mockResolvedValue([]), +})); + +jest.mock('../api/recommendations', () => ({ + getRecommendationsFreshness: jest.fn().mockResolvedValue({ + last_collected_at: new Date().toISOString(), + last_collection_error: null, + }), + refreshRecommendations: jest.fn().mockResolvedValue({}), +})); + +jest.mock('../toast', () => ({ + showToast: jest.fn().mockReturnValue({ dismiss: jest.fn() }), +})); + +const RECS: LocalRecommendation[] = [ + { id: 'a', provider: 'aws', cloud_account_id: 'acct', service: 'ec2', resource_type: 'm5.large', region: 'us-east-1', count: 1, term: 1, payment: 'all-upfront', savings: 100, upfront_cost: 1200 } as LocalRecommendation, + { id: 'b', provider: 'aws', cloud_account_id: 'acct', service: 'rds', resource_type: 'db.t3', region: 'us-east-1', count: 1, term: 1, payment: 'all-upfront', savings: 200, upfront_cost: 2400 } as LocalRecommendation, +]; + +jest.mock('../state', () => ({ + getCurrentProvider: jest.fn().mockReturnValue('all'), + setCurrentProvider: jest.fn(), + getCurrentAccountIDs: jest.fn().mockReturnValue([]), + setCurrentAccountIDs: jest.fn(), + getRecommendations: jest.fn().mockReturnValue([]), + getRecommendationByID: jest.fn().mockReturnValue(undefined), + setRecommendations: jest.fn(), + getSelectedRecommendationIDs: jest.fn().mockReturnValue(new Set()), + clearSelectedRecommendations: jest.fn(), + addSelectedRecommendation: jest.fn(), + removeSelectedRecommendation: jest.fn(), + getRecommendationsSort: jest.fn().mockReturnValue({ column: 'savings', direction: 'desc' }), + setRecommendationsSort: jest.fn(), + getRecommendationsColumnFilters: jest.fn().mockReturnValue({}), + setRecommendationsColumnFilter: jest.fn(), + clearAllRecommendationsColumnFilters: jest.fn(), + getVisibleRecommendations: jest.fn().mockReturnValue([]), + setVisibleRecommendations: jest.fn(), + getCostPeriod: jest.fn().mockReturnValue('monthly'), + setCostPeriod: jest.fn(), + getHiddenColumns: jest.fn().mockReturnValue(new Set()), + setHiddenColumns: jest.fn(), + // Administrators group id (see recommendations-enabled-providers.test.ts). + getCurrentUser: jest.fn().mockReturnValue({ id: 'u', email: 'u@example.com', groups: ['00000000-0000-5000-8000-000000000001'] }), +})); + +import * as api from '../api'; + +function seedDOM(): void { + const tab = document.createElement('div'); + tab.id = 'opportunities-tab'; + tab.className = 'tab-content active'; + for (const id of ['recommendations-summary', 'recommendations-list']) { + const el = document.createElement('div'); + el.id = id; + tab.appendChild(el); + } + document.body.replaceChildren(tab); +} + +beforeEach(() => { + jest.clearAllMocks(); + seedDOM(); + (api.getRecommendations as jest.Mock).mockResolvedValue({ summary: {}, recommendations: RECS, regions: [] }); +}); + +test('summary renders four .kpi-tile cards in the Home tile markup', async () => { + await loadRecommendations(); + const tiles = Array.from(document.querySelectorAll('#recommendations-summary > .card')); + expect(tiles.map((t) => t.dataset['kpi'])).toEqual(['count', 'savings', 'upfront', 'payback']); + for (const tile of tiles) { + expect(tile.classList.contains('kpi-tile')).toBe(true); + expect(tile.querySelector(':scope > h3')).not.toBeNull(); + expect(tile.querySelector(':scope > p.value.kpi-tile-value')).not.toBeNull(); + } + expect(tiles.map((t) => t.querySelector('h3')!.textContent)).toEqual([ + 'Total Recommendations', 'Potential Monthly Savings', 'Total Upfront Cost', 'Payback Period', + ]); +}); + +test('only the savings tile uses the savings color, and values are filled in', async () => { + await loadRecommendations(); + const value = (kpi: string) => + document.querySelector(`#recommendations-summary [data-kpi="${kpi}"] .kpi-tile-value`)!; + expect(value('count').textContent).toBe('2'); + expect(value('savings').classList.contains('savings')).toBe(true); + for (const kpi of ['count', 'upfront', 'payback']) { + expect(value(kpi).classList.contains('savings')).toBe(false); + } + expect(value('savings').textContent).toContain('$'); + expect(value('upfront').textContent).toContain('$'); +}); diff --git a/frontend/src/dashboard.ts b/frontend/src/dashboard.ts index 2a8e61716..5dd39d2ee 100644 --- a/frontend/src/dashboard.ts +++ b/frontend/src/dashboard.ts @@ -13,6 +13,7 @@ import { confirmDialog } from './confirmDialog'; import { canAccess } from './permissions'; import { groupRecsByCell, pageLevelRange, formatSavingsRange, triggerAutoRefreshIfStale } from './recommendations'; import { showSkeletonTiles, showSkeletonBlock, teardownSkeleton } from './lib/skeleton'; +import { buildKpiTile } from './lib/kpi-tile'; import { formatTrendAxisTick } from './modules/chart-utils'; // Re-export for backward compatibility (tests and other callers import from dashboard.ts). export { formatTrendAxisTick } from './modules/chart-utils'; @@ -353,29 +354,8 @@ function renderDashboardSummary(data: DashboardSummary, recs: readonly LocalReco { kpi: 'ytd', title: 'YTD Savings', value: formatCurrency(data.ytd_savings), valueSavings: true, detail: 'From commitment purchases' }, ]; - for (const t of tiles) { - const card = document.createElement('div'); - card.classList.add('card', 'kpi-tile'); - card.dataset['kpi'] = t.kpi; - const h3 = document.createElement('h3'); - h3.textContent = t.title; - const valueP = document.createElement('p'); - valueP.classList.add('value', 'kpi-tile-value'); - if (t.valueSavings) valueP.classList.add('savings'); - valueP.textContent = t.value; - const detailP = document.createElement('p'); - detailP.classList.add('detail', 'kpi-tile-detail'); - detailP.textContent = t.detail; - const spark = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); - spark.classList.add('kpi-tile-spark', 'hidden'); - spark.dataset['sparkKey'] = t.kpi; - spark.setAttribute('aria-hidden', 'true'); - card.appendChild(h3); - card.appendChild(valueP); - card.appendChild(detailP); - card.appendChild(spark); - summary.appendChild(card); - } + // Shared with the Opportunities summary (issue #743). + for (const t of tiles) summary.appendChild(buildKpiTile({ ...t, spark: true })); } /** diff --git a/frontend/src/lib/kpi-tile.ts b/frontend/src/lib/kpi-tile.ts new file mode 100644 index 000000000..2a8fd856f --- /dev/null +++ b/frontend/src/lib/kpi-tile.ts @@ -0,0 +1,71 @@ +/** + * Shared KPI tile builder (issue #743). + * + * Home's summary built `.card.kpi-tile` elements by hand while Opportunities + * wrote plain `.card` innerHTML, so the same kind of figure looked different + * on the two pages. Both now build their tiles here. + * + * Markup (styles in components.css under `.kpi-tile`): + *
+ *

Title

+ *

Value

+ *

Detail

(optional) + *
+ * + * The `card` / `value` / `savings` classes stay for existing selectors and + * tests. Text is set with textContent, never innerHTML. + */ + +export interface KpiTileSpec { + /** Stable key, rendered as data-kpi (and data-spark-key when spark is set). */ + kpi: string; + title: string; + value: string; + /** Render the value in the savings (green) color. */ + valueSavings?: boolean; + /** Small line under the value; omitted when absent. */ + detail?: string; + /** Reserve a hidden sparkline slot that a caller fills in later. */ + spark?: boolean; +} + +const SVG_NS = 'http://www.w3.org/2000/svg'; + +export function buildKpiTile(spec: KpiTileSpec): HTMLDivElement { + const card = document.createElement('div'); + card.classList.add('card', 'kpi-tile'); + card.dataset['kpi'] = spec.kpi; + + const h3 = document.createElement('h3'); + h3.textContent = spec.title; + card.appendChild(h3); + + const value = document.createElement('p'); + value.classList.add('value', 'kpi-tile-value'); + if (spec.valueSavings) value.classList.add('savings'); + value.textContent = spec.value; + card.appendChild(value); + + if (spec.detail !== undefined) { + const detail = document.createElement('p'); + detail.classList.add('detail', 'kpi-tile-detail'); + detail.textContent = spec.detail; + card.appendChild(detail); + } + + if (spec.spark) { + const spark = document.createElementNS(SVG_NS, 'svg'); + spark.classList.add('kpi-tile-spark', 'hidden'); + spark.dataset['sparkKey'] = spec.kpi; + spark.setAttribute('aria-hidden', 'true'); + card.appendChild(spark); + } + + return card; +} + +/** Replace `container`'s children with one tile per spec. */ +export function renderKpiTiles(container: HTMLElement, specs: readonly KpiTileSpec[]): void { + container.replaceChildren(...specs.map(buildKpiTile)); +} diff --git a/frontend/src/recommendations.ts b/frontend/src/recommendations.ts index 307ff2d7a..474ad9e2a 100644 --- a/frontend/src/recommendations.ts +++ b/frontend/src/recommendations.ts @@ -24,6 +24,7 @@ import type { AccountServiceOverride } from './api/accounts'; import type { RecommendationsResponse, LocalRecommendation, RecommendationsSummary, GlobalConfig } from './types'; import { openModal } from './modal'; import { showSkeletonRows, teardownSkeleton } from './lib/skeleton'; +import { renderKpiTiles } from './lib/kpi-tile'; import { canAccess } from './permissions'; // Issue #869: true when the current session can take any action on @@ -904,24 +905,14 @@ function renderRecommendationsSummary( const upfrontLabel = isSelectionView ? 'Selected Upfront Cost' : 'Total Upfront Cost'; const paybackLabel = 'Payback Period'; - container.innerHTML = ` -
-

${countLabel}

-

${variantCount}

-
-
-

${savingsLabel}

-

${savingsText}

-
-
-

${upfrontLabel}

-

${upfrontText}

-
-
-

${paybackLabel}

-

${paybackText}

-
- `; + // Issue #743: the same .kpi-tile component as Home's summary, so the two + // pages' KPI cards match. + renderKpiTiles(container, [ + { kpi: 'count', title: countLabel, value: String(variantCount) }, + { kpi: 'savings', title: savingsLabel, value: savingsText, valueSavings: true }, + { kpi: 'upfront', title: upfrontLabel, value: upfrontText }, + { kpi: 'payback', title: paybackLabel, value: paybackText }, + ]); } // Comparator extractors per column. Numeric columns return numbers From 03fe2ef617a33518af8e294b9ca61182ae4f0088 Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Sat, 24 Oct 2026 01:34:44 +0000 Subject: [PATCH 2/3] fix(frontend): one card surface and one dialog surface across the app Cards: .card was a borderless 0 2px 4px shadow on white, .kpi-tile and .group-card used a hairline border, .plan-card and .upcoming-card a token shadow with no border. .card is now the single surface (1px --cudly-border, --cudly-shadow-sm, --cudly-r-md, --cudly-surface). .kpi-tile inherits it instead of redefining it, and .plan-card, .group-card and .upcoming-card match it. .stat-card stays a borderless muted inset tile because it sits inside a card. Dialogs: .modal, .modal-overlay, confirmDialog and the Archera offer each had their own scrim (0.5 vs 0.45 black), radius (8px) and shadow, plus loose z-index literals. New base.css tokens: --cudly-scrim, --cudly-shadow-dialog and a named z-index ladder (modal 1000, raised 1050, page overlay 1100, confirm 2000, toast 2100). The values are unchanged, so the stacking order stays the same and a confirm still opens above any modal. Every dialog panel now uses --cudly-r-lg (12px, matching the existing mobile bottom-sheet corners) and the dialog shadow. Refs #743 Co-Authored-By: Claude Opus 5.5 --- frontend/src/__tests__/surface-css.test.ts | 101 +++++++++++++++++++++ frontend/src/styles/base.css | 12 +++ frontend/src/styles/components.css | 25 ++--- frontend/src/styles/modals.css | 26 +++--- frontend/src/styles/plans.css | 2 + frontend/src/styles/settings.css | 5 +- 6 files changed, 145 insertions(+), 26 deletions(-) create mode 100644 frontend/src/__tests__/surface-css.test.ts diff --git a/frontend/src/__tests__/surface-css.test.ts b/frontend/src/__tests__/surface-css.test.ts new file mode 100644 index 000000000..4312cb38b --- /dev/null +++ b/frontend/src/__tests__/surface-css.test.ts @@ -0,0 +1,101 @@ +/** + * Issue #743: one dialog surface and one card surface. + * + * The four modal systems (.modal + .modal-content, .modal-overlay, + * confirmDialog's .modal-confirm, the Archera offer panel) each had their + * own scrim opacity, radius, shadow and z-index. The card styles differed in + * border, shadow and fill. These read the stylesheets (jsdom has no cascade) + * and pin every surface to the shared tokens. + */ +import fs from 'fs'; +import path from 'path'; + +const stylesDir = path.join(__dirname, '..', 'styles'); +const all = fs.readdirSync(stylesDir).filter((f) => f.endsWith('.css')) + .map((f) => fs.readFileSync(path.join(stylesDir, f), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '')) + .join('\n'); + +/** Declarations of the rule whose whole selector list is `selector`. */ +function rule(selector: string): Record { + const re = /([^{}]+)\{([^{}]*)\}/g; + let m: RegExpExecArray | null; + while ((m = re.exec(all)) !== null) { + if (m[1]!.trim().replace(/\s+/g, ' ') !== selector) continue; + const out: Record = {}; + for (const d of m[2]!.split(';')) { + const i = d.indexOf(':'); + if (i > 0) out[d.slice(0, i).trim()] = d.slice(i + 1).trim(); + } + return out; + } + throw new Error(`rule not found: ${selector}`); +} + +describe('design tokens', () => { + test.each([ + '--cudly-scrim', '--cudly-shadow-dialog', '--cudly-z-modal', '--cudly-z-modal-raised', + '--cudly-z-page-overlay', '--cudly-z-confirm', '--cudly-z-toast', + ])('%s is defined', (token) => { + expect(all).toMatch(new RegExp(`${token}:\\s*[^;]+;`)); + }); + + test('the stacking ladder keeps confirm above modals and toasts on top', () => { + const z = (t: string) => Number(new RegExp(`${t}:\\s*(\\d+)`).exec(all)![1]); + expect(z('--cudly-z-modal')).toBeLessThan(z('--cudly-z-modal-raised')); + expect(z('--cudly-z-modal-raised')).toBeLessThan(z('--cudly-z-page-overlay')); + expect(z('--cudly-z-page-overlay')).toBeLessThan(z('--cudly-z-confirm')); + expect(z('--cudly-z-confirm')).toBeLessThan(z('--cudly-z-toast')); + }); +}); + +describe('one dialog surface (issue #743)', () => { + test.each(['.modal', '.modal-overlay', '.modal-confirm-backdrop', '.archera-offer-backdrop'])( + '%s uses the shared scrim', (sel) => { + expect(rule(sel)['background']).toBe('var(--cudly-scrim)'); + }, + ); + + test.each(['.modal-content', '.modal-confirm', '.archera-offer-panel'])( + '%s uses the shared radius, shadow and surface', (sel) => { + const r = rule(sel); + expect(r['border-radius']).toBe('var(--cudly-r-lg)'); + expect(r['box-shadow']).toBe('var(--cudly-shadow-dialog)'); + expect(r['background']).toBe('var(--cudly-surface)'); + }, + ); + + test.each([ + ['.modal', 'var(--cudly-z-modal)'], + ['.modal-overlay', 'var(--cudly-z-modal)'], + ['#archera-offer-modal-container', 'var(--cudly-z-modal-raised)'], + ['#archera-page-container', 'var(--cudly-z-page-overlay)'], + ['.modal-confirm-backdrop', 'var(--cudly-z-confirm)'], + ['.toast-container', 'var(--cudly-z-toast)'], + ])('%s stacks at %s', (sel, z) => { + expect(rule(sel)['z-index']).toBe(z); + }); +}); + +describe('one card surface (issue #743)', () => { + test('.card is a hairline border + small shadow on the surface color', () => { + const r = rule('.card'); + expect(r['border']).toBe('1px solid var(--cudly-border)'); + expect(r['box-shadow']).toBe('var(--cudly-shadow-sm)'); + expect(r['border-radius']).toBe('var(--cudly-r-md)'); + expect(r['background']).toBe('var(--cudly-surface)'); + }); + + test('.kpi-tile inherits the surface from .card instead of redefining it', () => { + const r = rule('.kpi-tile'); + expect(r['border']).toBeUndefined(); + expect(r['box-shadow']).toBeUndefined(); + }); + + test.each(['.plan-card', '.group-card', '.upcoming-card'])('%s matches the card surface', (sel) => { + const r = rule(sel); + expect(r['border']).toBe('1px solid var(--cudly-border)'); + expect(r['box-shadow']).toBe('var(--cudly-shadow-sm)'); + expect(r['border-radius']).toBe('var(--cudly-r-md)'); + expect(r['background']).toBe('var(--cudly-surface)'); + }); +}); diff --git a/frontend/src/styles/base.css b/frontend/src/styles/base.css index 74c206fbd..f9707c454 100644 --- a/frontend/src/styles/base.css +++ b/frontend/src/styles/base.css @@ -84,6 +84,18 @@ --cudly-topbar-h: 56px; --cudly-sidebar-w: 232px; --cudly-sidebar-w-collapsed: 64px; + + /* Dialogs (issue #743): one scrim, one dialog shadow and a named stacking + * ladder shared by .modal, .modal-overlay, confirmDialog and the Archera + * offer, which each used their own opacity, shadow and z-index. Values + * keep the existing order: a confirm can open on top of any modal. */ + --cudly-scrim: rgba(15, 23, 42, 0.5); + --cudly-shadow-dialog: 0 10px 30px rgba(15, 23, 42, 0.25); + --cudly-z-modal: 1000; /* .modal, .modal-overlay */ + --cudly-z-modal-raised: 1050; /* Archera offer, opened as a modal closes */ + --cudly-z-page-overlay: 1100; /* Archera education page, opened from a modal */ + --cudly-z-confirm: 2000; /* confirmDialog, may sit on any modal */ + --cudly-z-toast: 2100; /* toasts stay above everything */ } * { box-sizing: border-box; margin: 0; padding: 0; } diff --git a/frontend/src/styles/components.css b/frontend/src/styles/components.css index 5d76ec88e..b90618983 100644 --- a/frontend/src/styles/components.css +++ b/frontend/src/styles/components.css @@ -2,10 +2,13 @@ /* Cards */ .card { - background: white; + background: var(--cudly-surface); padding: 1.5rem; - border-radius: 8px; - box-shadow: 0 2px 4px rgba(0,0,0,0.1); + /* The one card surface (issue #743): hairline border + small shadow, + * shared with .kpi-tile, .plan-card, .group-card and .upcoming-card. */ + border: 1px solid var(--cudly-border); + border-radius: var(--cudly-r-md); + box-shadow: var(--cudly-shadow-sm); } /* --------------------------------------------------------------------------- @@ -15,10 +18,8 @@ * are referenced via `.card`). * ------------------------------------------------------------------------- */ .kpi-tile { + /* Border, radius and shadow come from .card (issue #743). */ padding: var(--cudly-sp-4); - border-radius: var(--cudly-r-md); - border: 1px solid var(--cudly-border); - box-shadow: var(--cudly-shadow-sm); display: grid; grid-template-columns: 1fr auto; grid-template-areas: @@ -356,7 +357,7 @@ button.secondary:hover { display: flex; flex-direction: column; gap: 0.5rem; - z-index: 2100; + z-index: var(--cudly-z-toast); pointer-events: none; max-width: 400px; } @@ -1043,18 +1044,18 @@ tr.rec-variant-row:hover td:not(.checkbox-col) { .modal-confirm-backdrop { position: fixed; inset: 0; - background: rgba(0, 0, 0, 0.45); + background: var(--cudly-scrim); display: flex; align-items: center; justify-content: center; - z-index: 2000; + z-index: var(--cudly-z-confirm); } .modal-confirm { position: relative; - background: white; - border-radius: 8px; - box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25); + background: var(--cudly-surface); + border-radius: var(--cudly-r-lg); + box-shadow: var(--cudly-shadow-dialog); max-width: min(480px, calc(100vw - 2rem)); min-width: 320px; padding: 1.5rem; diff --git a/frontend/src/styles/modals.css b/frontend/src/styles/modals.css index ac40fa77d..4dbdf7afb 100644 --- a/frontend/src/styles/modals.css +++ b/frontend/src/styles/modals.css @@ -5,11 +5,11 @@ left: 0; width: 100%; height: 100%; - background: rgba(0,0,0,0.5); + background: var(--cudly-scrim); display: flex; align-items: center; justify-content: center; - z-index: 1000; + z-index: var(--cudly-z-modal); } .modal.hidden { @@ -17,9 +17,11 @@ } .modal-content { - background: white; + background: var(--cudly-surface); padding: 2rem; - border-radius: 8px; + /* Same dialog surface as .modal-confirm (issue #743). */ + border-radius: var(--cudly-r-lg); + box-shadow: var(--cudly-shadow-dialog); width: 500px; max-width: 90%; max-height: 90vh; @@ -83,11 +85,11 @@ left: 0; width: 100%; height: 100%; - background: rgba(0, 0, 0, 0.5); + background: var(--cudly-scrim); display: flex; justify-content: center; align-items: center; - z-index: 1000; + z-index: var(--cudly-z-modal); } .modal-hint { @@ -247,7 +249,7 @@ /* 1100 sits above .modal/.modal-overlay (z-index: 1000) so the education page renders on top of open modals, which is how the overlay gets launched (user clicks the CTA link inside a modal). */ - z-index: 1100; + z-index: var(--cudly-z-page-overlay); overflow-y: auto; } @@ -363,7 +365,7 @@ #archera-offer-modal-container { position: fixed; inset: 0; - z-index: 1050; + z-index: var(--cudly-z-modal-raised); display: flex; align-items: center; justify-content: center; @@ -372,14 +374,14 @@ .archera-offer-backdrop { position: absolute; inset: 0; - background: rgba(0, 0, 0, 0.45); + background: var(--cudly-scrim); } .archera-offer-panel { position: relative; - background: #fff; - border-radius: 8px; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25); + background: var(--cudly-surface); + border-radius: var(--cudly-r-lg); + box-shadow: var(--cudly-shadow-dialog); max-width: 440px; width: calc(100% - 2rem); padding: 1.5rem 1.75rem; diff --git a/frontend/src/styles/plans.css b/frontend/src/styles/plans.css index 2a8253dbd..ccbc45bda 100644 --- a/frontend/src/styles/plans.css +++ b/frontend/src/styles/plans.css @@ -15,6 +15,7 @@ /* Plan cards */ .plan-card { background: var(--cudly-surface); + border: 1px solid var(--cudly-border); /* the shared card surface (#743) */ border-radius: var(--cudly-r-md); box-shadow: var(--cudly-shadow-sm); margin-bottom: 1rem; @@ -100,6 +101,7 @@ (issue #1776). */ gap: var(--cudly-sp-4); background: var(--cudly-surface); + border: 1px solid var(--cudly-border); /* shared card surface (#743); accent below */ padding: var(--cudly-sp-4) var(--cudly-sp-5); border-radius: var(--cudly-r-md); box-shadow: var(--cudly-shadow-sm); diff --git a/frontend/src/styles/settings.css b/frontend/src/styles/settings.css index ddae897a8..fbc64ead6 100644 --- a/frontend/src/styles/settings.css +++ b/frontend/src/styles/settings.css @@ -455,10 +455,11 @@ select.dirty { /* Group cards */ .group-card { border: 1px solid var(--cudly-border); - border-radius: 8px; + border-radius: var(--cudly-r-md); + box-shadow: var(--cudly-shadow-sm); /* the shared card surface (#743) */ padding: 1.25rem; margin-bottom: 1rem; - background: white; + background: var(--cudly-surface); } .group-card-header { display: flex; From e1fa861e43e0145017acd2bf448957595590a8c9 Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Sat, 24 Oct 2026 01:34:44 +0000 Subject: [PATCH 3/3] test(frontend): Playwright check that tiles and dialogs share one surface Compares computed styles in Chromium. The first Home and Opportunities summary tiles must match on border, radius, shadow, padding, background and title/value type. The New Plan .modal and the Delete-plan confirmDialog must match on scrim color, radius (12px) and shadow. Refs #743 Co-Authored-By: Claude Opus 5.5 --- frontend/tests-e2e/surfaces.spec.ts | 100 ++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 frontend/tests-e2e/surfaces.spec.ts diff --git a/frontend/tests-e2e/surfaces.spec.ts b/frontend/tests-e2e/surfaces.spec.ts new file mode 100644 index 000000000..463371718 --- /dev/null +++ b/frontend/tests-e2e/surfaces.spec.ts @@ -0,0 +1,100 @@ +/** + * One card and one dialog surface (issue #743), checked on computed styles in + * Chromium (jsdom has no cascade): + * + * - Home's and Opportunities' summary tiles render the same .kpi-tile + * component, so their border, radius, shadow, padding and type match. + * - A .modal dialog (New Plan) and a confirmDialog (Delete plan) share the + * scrim, radius and shadow; they used to differ in all three. + */ + +import { test, expect, type Page } from '@playwright/test'; +import { mockApi, seedAuth } from './fixtures/recs'; + +const DASHBOARD = { + potential_monthly_savings: 3320, + total_recommendations: 19, + active_commitments: 12, + committed_monthly: 8300, + current_coverage: 54.2, + target_coverage: 80, + ytd_savings: 21400, + by_service: {}, +}; + +const PLAN = { + id: 'p1', name: 'Plan', enabled: true, auto_purchase: false, notification_days_before: 3, services: {}, + ramp_schedule: { type: 'weekly', current_step: 1, total_steps: 4, step_interval_days: 7, percent_per_step: 25 }, +}; + +async function open(page: Page, path: string): Promise { + await seedAuth(page); + await mockApi(page); + await page.route('**/api/dashboard/summary**', (route) => route.fulfill({ json: DASHBOARD })); + await page.route('**/api/dashboard/upcoming', (route) => route.fulfill({ json: [] })); + await page.route('**/api/plans**', (route) => + route.request().method() === 'GET' ? route.fulfill({ json: { plans: [PLAN] } }) : route.fallback(), + ); + await page.route('**/api/purchases/planned', (route) => route.fulfill({ json: { purchases: [] } })); + await page.goto(path); +} + +/** The computed styles that make a tile look like a tile. */ +async function tileLook(page: Page, section: string): Promise> { + const tile = page.locator(`${section} > .card`).first(); + await expect(tile).toBeVisible(); + await expect(tile).not.toHaveClass(/skeleton-tile/); + return tile.evaluate((el) => { + const cs = getComputedStyle(el); + const h3 = getComputedStyle(el.querySelector('h3')!); + const value = getComputedStyle(el.querySelector('.value')!); + return { + classes: el.className, + border: cs.border, + borderRadius: cs.borderRadius, + boxShadow: cs.boxShadow, + padding: cs.padding, + background: cs.backgroundColor, + titleFont: `${h3.fontSize} ${h3.fontWeight} ${h3.color}`, + valueFont: `${value.fontSize} ${value.fontWeight}`, + }; + }); +} + +test('Home and Opportunities summary tiles look the same', async ({ page }) => { + await open(page, '/home'); + const home = await tileLook(page, '#summary'); + await page.goto('/opportunities'); + const opps = await tileLook(page, '#recommendations-summary'); + expect(opps).toEqual(home); + expect(home.classes).toContain('kpi-tile'); + expect(home.border).toMatch(/^1px solid/); +}); + +test('a .modal dialog and a confirmDialog share scrim, radius and shadow', async ({ page }) => { + await open(page, '/plans'); + + await page.locator('#new-plan-btn').click(); + const modal = page.locator('#plan-modal'); + await expect(modal).toBeVisible(); + const planLook = await modal.evaluate((el) => ({ + scrim: getComputedStyle(el).backgroundColor, + radius: getComputedStyle(el.querySelector('.modal-content')!).borderRadius, + shadow: getComputedStyle(el.querySelector('.modal-content')!).boxShadow, + })); + await page.locator('#close-plan-modal-btn').click(); + await expect(modal).toBeHidden(); + + await page.locator('[data-action="delete-plan"]').first().click(); + const confirm = page.locator('.modal-confirm'); + await expect(confirm).toBeVisible(); + const confirmLook = await confirm.evaluate((el) => ({ + scrim: getComputedStyle(el.closest('.modal-confirm-backdrop')!).backgroundColor, + radius: getComputedStyle(el).borderRadius, + shadow: getComputedStyle(el).boxShadow, + })); + + expect(confirmLook).toEqual(planLook); + expect(planLook.radius).toBe('12px'); + expect(planLook.shadow).not.toBe('none'); +});