diff --git a/frontend/src/__tests__/kpi-tile.test.ts b/frontend/src/__tests__/kpi-tile.test.ts new file mode 100644 index 00000000..f5f26b21 --- /dev/null +++ b/frontend/src/__tests__/kpi-tile.test.ts @@ -0,0 +1,65 @@ +/** + * Issue #743: lib/kpi-tile builds the one KPI tile used by Home and + * Opportunities. + */ +import { buildKpiTile, renderKpiTiles } from '../lib/kpi-tile'; +import { NO_VALUE } from '../utils'; + +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('keeps an absent (NO_VALUE) savings figure neutral, not green (issue #739)', () => { + const tile = buildKpiTile({ kpi: 'savings', title: 'Potential Monthly Savings', value: NO_VALUE, valueSavings: true }); + const value = tile.querySelector('p.value')!; + expect(value.textContent).toBe(NO_VALUE); + expect(value.classList.contains('savings')).toBe(false); + }); + + 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 00000000..74da42fb --- /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/__tests__/surface-css.test.ts b/frontend/src/__tests__/surface-css.test.ts new file mode 100644 index 00000000..4312cb38 --- /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/dashboard.ts b/frontend/src/dashboard.ts index c3ca5465..96999e1e 100644 --- a/frontend/src/dashboard.ts +++ b/frontend/src/dashboard.ts @@ -16,6 +16,7 @@ import { groupRecsByCell, pageLevelRange, formatSavingsRange, triggerAutoRefresh import { getRecommendationsFreshness } from './api/recommendations'; import { buildFirstRunEmptyState, isScopeFiltered } from './first-run-empty-state'; import { showSkeletonTiles, showSkeletonBlock, teardownSkeleton } from './lib/skeleton'; +import { buildKpiTile } from './lib/kpi-tile'; import { scopeUpcomingToProvider, upcomingEmptyMessage, UPCOMING_ACCOUNT_SCOPE_NOTE } from './lib/upcoming-scope'; import { formatTrendAxisTick, formatTrendTooltipTitle, formatSparseTrendMessage, MIN_TREND_POINTS } from './modules/chart-utils'; import { setChartSummary, renderChartTable, removeChartTable } from './lib/chart-a11y'; @@ -329,31 +330,9 @@ 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'); - // An absent figure is not a saving: keep the placeholder neutral rather - // than green (issue #739). - if (t.valueSavings && t.value !== NO_VALUE) 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). buildKpiTile keeps an + // absent (NO_VALUE) figure neutral rather than green (issue #739). + 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 00000000..d45eb20b --- /dev/null +++ b/frontend/src/lib/kpi-tile.ts @@ -0,0 +1,76 @@ +/** + * 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. + */ + +import { NO_VALUE } from '../utils'; + +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. An absent figure + * (NO_VALUE) is not a saving, so it stays neutral (issue #739). + */ + 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 && spec.value !== NO_VALUE) 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 4270aff9..5985f838 100644 --- a/frontend/src/recommendations.ts +++ b/frontend/src/recommendations.ts @@ -25,6 +25,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 { captureFocus, restoreFocus } from './lib/focus-restore'; import { canAccess } from './permissions'; import { buildFirstRunEmptyState, isScopeFiltered } from './first-run-empty-state'; @@ -924,24 +925,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 diff --git a/frontend/src/styles/base.css b/frontend/src/styles/base.css index b8037589..bf665d4d 100644 --- a/frontend/src/styles/base.css +++ b/frontend/src/styles/base.css @@ -94,6 +94,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 8e6ebee7..19bfaea0 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; } @@ -1069,18 +1070,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 56a97653..c30e3282 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; @@ -101,11 +103,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 { @@ -265,7 +267,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; } @@ -381,7 +383,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; @@ -390,14 +392,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 ed4cdc40..579e850b 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 d78fed3d..cecea239 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; diff --git a/frontend/tests-e2e/surfaces.spec.ts b/frontend/tests-e2e/surfaces.spec.ts new file mode 100644 index 00000000..46337171 --- /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'); +});