diff --git a/frontend/src/__tests__/auth-dialog-a11y.test.ts b/frontend/src/__tests__/auth-dialog-a11y.test.ts index 155e88a5a..3282493da 100644 --- a/frontend/src/__tests__/auth-dialog-a11y.test.ts +++ b/frontend/src/__tests__/auth-dialog-a11y.test.ts @@ -63,6 +63,17 @@ describe('auth gate dialogs (#731)', () => { expect(document.getElementById('login-error')?.getAttribute('role')).toBe('alert'); }); + test('signed-out gates render as a standalone screen with a decorative brand', async () => { + await showLoginModal(); + const modal = document.getElementById('login-modal')!; + expect(modal.classList.contains('auth-screen')).toBe(true); + const brands = modal.querySelectorAll('.modal-overlay > .auth-brand'); + expect(brands.length).toBe(1); + expect(brands[0]!.getAttribute('aria-hidden')).toBe('true'); + // Outside the dialog, so it does not change the dialog's name or content. + expect(modal.querySelector('[role="dialog"] .auth-brand')).toBeNull(); + }); + test('Tab wraps inside the login modal and Escape does not dismiss it', async () => { await showLoginModal(); const modal = document.getElementById('login-modal')!; diff --git a/frontend/src/__tests__/auth.test.ts b/frontend/src/__tests__/auth.test.ts index 1fb5a600c..70f5b4855 100644 --- a/frontend/src/__tests__/auth.test.ts +++ b/frontend/src/__tests__/auth.test.ts @@ -1,7 +1,7 @@ /** * Auth module tests */ -import { showLoginModal, showResetPasswordModal, updateUserUI, logout } from '../auth'; +import { showLoginModal, showResetPasswordModal, updateUserUI, logout, emailInitials } from '../auth'; import { ADMINISTRATORS_GROUP_ID } from '../permissions'; import { reloadPage } from '../utils'; @@ -460,6 +460,16 @@ describe('Auth Module', () => { const userEmail = document.getElementById('user-email-display'); expect(userEmail?.textContent).toBe('test@example.com'); + // The avatar initials ride on a data attribute (drawn by CSS), so the + // button's text stays exactly the address. + expect(userEmail?.dataset['initials']).toBe('TE'); + }); + + test('emailInitials takes one letter from each of the first two name parts', () => { + expect(emailInitials('dana.okafor@example.com')).toBe('DO'); + expect(emailInitials('priya_raman@example.com')).toBe('PR'); + expect(emailInitials('admin@example.com')).toBe('AD'); + expect(emailInitials('x@example.com')).toBe('X'); }); test('shows user info when logged in', () => { diff --git a/frontend/src/__tests__/error-callout.test.ts b/frontend/src/__tests__/error-callout.test.ts new file mode 100644 index 000000000..b5b5e70bc --- /dev/null +++ b/frontend/src/__tests__/error-callout.test.ts @@ -0,0 +1,36 @@ +/** + * Section load-failure callout: keeps the failure line as p.error, is + * announced, and offers "Try again" that re-runs the section's load. + */ +import { buildErrorCallout, renderErrorCallout, ERROR_CALLOUT_HINT } from '../lib/error-callout'; + +describe('error callout', () => { + test('keeps the exact failure line in p.error and is announced', () => { + const el = buildErrorCallout({ message: 'Failed to load plans: boom' }); + expect(el.getAttribute('role')).toBe('alert'); + const line = el.querySelector('p.error'); + // textContent, not innerHTML: markup in the reason stays text. + expect(line?.textContent).toBe('Failed to load plans: boom'); + expect(line?.querySelector('b')).toBeNull(); + expect(el.querySelector('.error-callout-hint')?.textContent).toBe(ERROR_CALLOUT_HINT); + expect(el.querySelector('svg')?.getAttribute('aria-hidden')).toBe('true'); + }); + + test('has no button without a retry handler', () => { + const el = buildErrorCallout({ message: 'Failed to load plans: x' }); + expect(el.querySelector('button')).toBeNull(); + }); + + test('"Try again" re-runs the load', () => { + const onRetry = jest.fn(); + const host = document.createElement('div'); + host.innerHTML = '

stale

'; + renderErrorCallout(host, { message: 'Failed to load recommendations: internal error', onRetry }); + expect(host.children.length).toBe(1); + const btn = host.querySelector('button')!; + expect(btn.textContent).toBe('Try again'); + expect(btn.type).toBe('button'); + btn.click(); + expect(onRetry).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/src/__tests__/tablist.test.ts b/frontend/src/__tests__/tablist.test.ts index d062ca862..37bc9f4c6 100644 --- a/frontend/src/__tests__/tablist.test.ts +++ b/frontend/src/__tests__/tablist.test.ts @@ -155,8 +155,11 @@ describe('index.html tab semantics (issue #730)', () => { pages.forEach(page => { expect(page.getAttribute('role')).toBeNull(); expect(page.getAttribute('aria-labelledby')).toBeNull(); - const heading = page.firstElementChild as HTMLElement; - expect(heading.matches('h2.page-heading.sr-only')).toBe(true); + // The visible page header opens every page; its h2 is the heading. + const header = page.firstElementChild as HTMLElement; + expect(header.matches('.page-header')).toBe(true); + const heading = header.querySelector('h2.page-heading') as HTMLElement; + expect(heading).not.toBeNull(); expect(heading.getAttribute('tabindex')).toBe('-1'); expect(heading.textContent?.trim()).toBeTruthy(); }); diff --git a/frontend/src/auth.ts b/frontend/src/auth.ts index 1c6735f63..19ca5d981 100644 --- a/frontend/src/auth.ts +++ b/frontend/src/auth.ts @@ -943,6 +943,20 @@ function showResetEmailConfirmation(): void { document.getElementById('reset-confirmation-close')?.focus(); } +/** + * Up to two letters for the topbar avatar: the first letters of the first + * two dot/underscore/dash separated parts of the address's local part + * ("dana.okafor@..." -> "DO"), else its first two characters. + */ +export function emailInitials(email: string): string { + const local = email.split('@')[0] ?? ''; + const parts = local.split(/[._\-+]+/).filter(Boolean); + const letters = parts.length >= 2 + ? `${parts[0]!.charAt(0)}${parts[1]!.charAt(0)}` + : local.slice(0, 2); + return letters.toUpperCase(); +} + /** * Update user UI after login */ @@ -958,6 +972,8 @@ export function updateUserUI(): void { // Update the user email display with click-to-edit functionality if (userEmailEl) { userEmailEl.textContent = currentUser.email; + // Drawn as an avatar by CSS (::before), so it stays out of the text. + userEmailEl.dataset['initials'] = emailInitials(currentUser.email); userEmailEl.removeAttribute('aria-label'); userEmailEl.hidden = false; userEmailEl.title = 'Click to edit your profile'; diff --git a/frontend/src/authDialog.ts b/frontend/src/authDialog.ts index e49816bf7..dd9763a06 100644 --- a/frontend/src/authDialog.ts +++ b/frontend/src/authDialog.ts @@ -15,7 +15,53 @@ import { openModal } from './modal'; +const SVG_NS = 'http://www.w3.org/2000/svg'; + +/** + * Decorative CUDly logo + name shown above the card on the signed-out + * screens. aria-hidden: the dialog's heading already names the screen. + */ +function buildAuthBrand(): HTMLElement { + const brand = document.createElement('div'); + brand.className = 'auth-brand'; + brand.setAttribute('aria-hidden', 'true'); + + const mark = document.createElementNS(SVG_NS, 'svg'); + mark.setAttribute('class', 'auth-brand-mark'); + mark.setAttribute('viewBox', '0 0 24 24'); + mark.setAttribute('width', '28'); + mark.setAttribute('height', '28'); + mark.setAttribute('fill', 'none'); + mark.setAttribute('stroke', 'currentColor'); + mark.setAttribute('stroke-width', '2'); + mark.setAttribute('stroke-linecap', 'round'); + mark.setAttribute('stroke-linejoin', 'round'); + const path = document.createElementNS(SVG_NS, 'path'); + path.setAttribute('d', 'M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z'); + mark.appendChild(path); + + const name = document.createElement('span'); + name.className = 'auth-brand-name'; + name.textContent = 'CUDly'; + + const tagline = document.createElement('span'); + tagline.className = 'auth-brand-tagline'; + tagline.textContent = 'Cloud commitment optimizer'; + + brand.append(mark, name, tagline); + return brand; +} + export function openAuthDialog(modal: HTMLElement, initialFocus?: string): void { + // Signed-out screens render as a standalone page: shell.css hides the app + // chrome while an .auth-screen is in the document and centres the card on + // a plain branded background, with the logo above it. + modal.classList.add('auth-screen'); + const overlay = modal.querySelector('.modal-overlay'); + if (overlay && !overlay.querySelector(':scope > .auth-brand')) { + overlay.prepend(buildAuthBrand()); + } + // The role goes on the visible card, not the zero-size wrapper or the // full-screen overlay; the focus trap goes on the wrapper, which survives // the innerHTML re-renders of the reset flow. diff --git a/frontend/src/dashboard.ts b/frontend/src/dashboard.ts index d30d46eee..a4c7d1186 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 { buildErrorCallout } from './lib/error-callout'; import { filterByEnabledProviders } from './lib/enabled-providers'; import { buildKpiTile } from './lib/kpi-tile'; import { scopeUpcomingToProvider, upcomingEmptyMessage, UPCOMING_ACCOUNT_SCOPE_NOTE } from './lib/upcoming-scope'; @@ -255,10 +256,10 @@ export async function loadDashboard(): Promise { teardownSkeleton(summary); const err = error as Error; while (summary.firstChild) summary.removeChild(summary.firstChild); - const p = document.createElement('p'); - p.classList.add('error'); - p.textContent = `Failed to load dashboard: ${err.message}`; - summary.appendChild(p); + summary.appendChild(buildErrorCallout({ + message: `Failed to load dashboard: ${err.message}`, + onRetry: () => void loadDashboard(), + })); } // Clear the upcoming-list skeleton too — shimmer next to a dashboard // error reads as a fresh fetch in-flight, which is misleading. diff --git a/frontend/src/index.html b/frontend/src/index.html index 826e91b14..9ce5b6e63 100644 --- a/frontend/src/index.html +++ b/frontend/src/index.html @@ -16,7 +16,7 @@ - + CUDly - Cloud Commitment Optimizer @@ -40,12 +40,16 @@

CUDly

+
- - API Docs - + + + +
-

Home

+ + @@ -132,14 +144,21 @@

Upcoming Scheduled Purchases

-

Opportunities

+
- -
-

Plans

-
-

Purchase Plans

- - -
+ +
-
+

Planned Purchases

Individual purchases scheduled from your plans. You can run, pause, edit, or delete each purchase.

-
+
-

Purchases

+
-

Inventory & Coverage

+
@@ -291,15 +324,15 @@

Inven The table is rendered into #active-commitments-list by loadActiveCommitments in inventory.ts. -->
-
-
-

Active commitments

-
- -
+
+
+ +
+

@@ -308,52 +341,52 @@

Active commitments

Active Convertible RIs

@@ -376,16 +409,25 @@

Exchange History

-

Admin

+
-
-

Global Configuration

-

Configure CUDly settings for commitment purchases across all cloud providers.

+
+
@@ -484,9 +526,13 @@

Global Configuration

-