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.querySelectorSavings, coverage and upcoming purchases across your connected accounts.
+Reserved Instance, Savings Plan and committed use discount recommendations for your connected accounts.
+Purchase plans buy commitments on a schedule, ramping coverage up step by step.
+Individual purchases scheduled from your plans. You can run, pause, edit, or delete each purchase.
- +Approve pending purchases and review what has been bought and saved.
+Commitments you own, how much of your spend they cover, and how savings trend over time.
+