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');
+});