From 46c515324f90b49844c64eaa87bf3f5400e38e69 Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Fri, 23 Oct 2026 23:59:26 +0000 Subject: [PATCH 1/9] fix(frontend): name ladder accounts and guard Auto Approve saves The ladder config table listed each configuration by its internal account UUID, Auto Approve could be saved without any hint that later ladder purchases skip human approval, and a rejected save replaced the server's validation message with a generic failure. The table now loads the registered account list alongside the configs and shows "name (external ID)", keeping the UUID as the cell title and falling back to it when the list is unavailable. Selecting Auto Approve shows an inline warning, and a save that newly turns on unattended purchasing (enabled + Auto Approve) asks for confirmation first, the same pattern the RI exchange Fully Automated mode uses. Save failures now show the API's error text. The table renderer moves to ladder-config-table.ts to keep ladder.ts under 500 lines. Refs #742 Co-Authored-By: Claude Opus 5.5 --- frontend/src/__tests__/ladder.test.ts | 92 ++++++++++++++- frontend/src/ladder-config-table.ts | 77 +++++++++++++ frontend/src/ladder.ts | 109 +++++++++--------- .../tests-e2e/ladder-account-selector.spec.ts | 33 ++++++ 4 files changed, 252 insertions(+), 59 deletions(-) create mode 100644 frontend/src/ladder-config-table.ts diff --git a/frontend/src/__tests__/ladder.test.ts b/frontend/src/__tests__/ladder.test.ts index af96ae8cb..e8c42ae91 100644 --- a/frontend/src/__tests__/ladder.test.ts +++ b/frontend/src/__tests__/ladder.test.ts @@ -12,6 +12,7 @@ import type { LadderConfig } from '../api'; import { waitFor } from '@testing-library/dom'; import { listAccountsMinimal, type AccountSummary } from '../api/accounts'; import { canAccess } from '../permissions'; +import { confirmDialog } from '../confirmDialog'; jest.mock('../api/accounts', () => ({ listAccountsMinimal: jest.fn() })); jest.mock('../ladder-timeline', () => ({ initLadderTimeline: jest.fn() })); @@ -23,6 +24,8 @@ jest.mock('../api', () => ({ updateConfig: jest.fn(), })); +jest.mock('../confirmDialog', () => ({ confirmDialog: jest.fn() })); + jest.mock('../permissions', () => ({ canAccess: jest.fn(() => true), })); @@ -67,6 +70,7 @@ describe('ladder.ts', () => { jest.clearAllMocks(); mockShowToast.mockReset(); (canAccess as jest.Mock).mockReturnValue(true); + (confirmDialog as jest.Mock).mockResolvedValue(true); (listAccountsMinimal as jest.Mock).mockResolvedValue([ { id: 'acct-1', name: 'AWS account', external_id: 'aws-external', provider: 'aws' }, { id: 'azure-id', name: 'Azure account', external_id: 'azure-external', provider: 'azure' }, @@ -255,8 +259,12 @@ describe('ladder.ts', () => { const status = (): HTMLElement => document.getElementById('ladder-cfg-account-status')!; const saveButton = (): HTMLElement => document.getElementById('ladder-config-save-btn')!; const changeProvider = (value: string): void => { provider().value = value; provider().dispatchEvent(new Event('change')); }; - async function open(): Promise { + // pendingAccounts lets a test hold the modal's account request open. It is + // installed after the section renders because the config table also loads + // account names during init. + async function open(pendingAccounts?: (mock: jest.Mock) => void): Promise { await renderSection(); + pendingAccounts?.(listAccountsMinimal as jest.Mock); document.getElementById('ladder-add-config-btn')!.click(); } async function ready(): Promise { await waitFor(() => expect(account()).toBeEnabled()); } @@ -304,8 +312,7 @@ describe('ladder.ts', () => { test('blocks save while loading and filters the provider selected before response', async () => { let resolve!: (accounts: AccountSummary[]) => void; - (listAccountsMinimal as jest.Mock).mockReturnValue(new Promise(done => { resolve = done; })); - await open(); + await open(mock => mock.mockReturnValue(new Promise(done => { resolve = done; }))); expect(account()).toBeDisabled(); expect(saveButton()).toBeDisabled(); expect(status()).toHaveTextContent(/loading/i); @@ -319,8 +326,7 @@ describe('ladder.ts', () => { test('a response from a closed modal cannot replace a reopened form', async () => { let resolve!: (accounts: AccountSummary[]) => void; - (listAccountsMinimal as jest.Mock).mockReturnValueOnce(new Promise(done => { resolve = done; })); - await open(); + await open(mock => mock.mockReturnValueOnce(new Promise(done => { resolve = done; }))); document.getElementById('ladder-modal-cancel-btn')!.click(); document.getElementById('ladder-add-config-btn')!.click(); await ready(); @@ -368,9 +374,85 @@ describe('ladder.ts', () => { (canAccess as jest.Mock).mockReturnValue(false); await saveLadderConfig(); expect(api.upsertLadderConfig).not.toHaveBeenCalled(); + (listAccountsMinimal as jest.Mock).mockClear(); await renderSection(); expect(document.getElementById('ladder-add-config-btn')).toBeNull(); + // Only the table's name lookup runs; no account selector is opened. expect(listAccountsMinimal).toHaveBeenCalledTimes(1); }); }); + + // Issue #742: readable account labels, auto-approve impact, server errors. + describe('account labels and auto-approve safeguards', () => { + const mode = (): HTMLSelectElement => document.getElementById('ladder-cfg-mode') as HTMLSelectElement; + const warning = (): HTMLElement => document.getElementById('ladder-cfg-mode-warning')!; + const setMode = (value: string): void => { mode().value = value; mode().dispatchEvent(new Event('change')); }; + async function openNew(): Promise { + await renderSection(); + document.getElementById('ladder-add-config-btn')!.click(); + await waitFor(() => expect(document.getElementById('ladder-cfg-account')).toBeEnabled()); + (document.getElementById('ladder-cfg-account') as HTMLSelectElement).value = 'acct-1'; + (api.upsertLadderConfig as jest.Mock).mockImplementation(async (cfg: LadderConfig) => cfg); + } + + test('table shows the registered account name and keeps the ID as the title', async () => { + await renderSection([baseConfig({ cloud_account_id: 'acct-1' })]); + const cell = document.querySelector('#ladder-configs-table-container tbody td')!; + expect(cell).toHaveTextContent('AWS account (aws-external)'); + expect(cell).toHaveAttribute('title', 'acct-1'); + expect(document.querySelector('#ladder-configs-table-container th')).toHaveTextContent('Account'); + }); + + test('table falls back to the stored ID when the account list fails', async () => { + (listAccountsMinimal as jest.Mock).mockRejectedValue(new Error('Forbidden')); + await renderSection([baseConfig({ cloud_account_id: 'acct-1' })]); + expect(document.querySelector('#ladder-configs-table-container tbody td')).toHaveTextContent('acct-1'); + }); + + test('the warning follows the Approval Mode selection', async () => { + await openNew(); + expect(warning()).not.toBeVisible(); + setMode('auto_approve'); + expect(warning()).toBeVisible(); + expect(warning()).toHaveTextContent(/skips human approval/i); + setMode('email_approval'); + expect(warning()).not.toBeVisible(); + }); + + test('editing an Auto Approve config shows the warning immediately', async () => { + await renderSection([baseConfig({ id: 'c', cloud_account_id: 'acct-1', mode: 'auto_approve' })]); + document.querySelector('.ladder-edit-btn')!.click(); + expect(warning()).toBeVisible(); + }); + + test.each([[true, 1], [false, 0]])('turning on Auto Approve asks first (confirmed=%s)', async (confirmed, saves) => { + (confirmDialog as jest.Mock).mockResolvedValue(confirmed); + await openNew(); + setMode('auto_approve'); + (document.getElementById('ladder-cfg-enabled') as HTMLInputElement).checked = true; + await saveLadderConfig(); + expect(confirmDialog).toHaveBeenCalledWith(expect.objectContaining({ title: 'Enable Auto Approve?', destructive: true })); + expect(api.upsertLadderConfig).toHaveBeenCalledTimes(saves); + expect(document.getElementById('ladder-config-modal')!.classList.contains('hidden')).toBe(confirmed); + expect(document.getElementById('ladder-config-save-btn')).not.toBeDisabled(); + }); + + test('no confirmation when Auto Approve is already active or the config stays disabled', async () => { + await openNew(); + setMode('auto_approve'); + await saveLadderConfig(); + await renderSection([baseConfig({ id: 'c', cloud_account_id: 'acct-1', mode: 'auto_approve' })]); + document.querySelector('.ladder-edit-btn')!.click(); + await saveLadderConfig(); + expect(confirmDialog).not.toHaveBeenCalled(); + expect(api.upsertLadderConfig).toHaveBeenCalledTimes(2); + }); + + test('a failed save shows the server error text', async () => { + await openNew(); + (api.upsertLadderConfig as jest.Mock).mockRejectedValue(new Error('ramp_schedule fractions must sum to 1.0')); + await saveLadderConfig(); + expect(mockShowToast).toHaveBeenCalledWith({ message: 'Failed to save ladder config: ramp_schedule fractions must sum to 1.0', kind: 'error' }); + }); + }); }); diff --git a/frontend/src/ladder-config-table.ts b/frontend/src/ladder-config-table.ts new file mode 100644 index 000000000..71710f0ec --- /dev/null +++ b/frontend/src/ladder-config-table.ts @@ -0,0 +1,77 @@ +/** + * Per-account ladder configuration table (issue #742). + * + * Shows each configuration's registered account name and external ID instead + * of the internal account UUID. The UUID stays available as the cell's title + * so support can still match it against API payloads. When the account list + * is unavailable (still loading, forbidden, or the account was removed) the + * cell falls back to the stored ID so the row is never blank. + */ + +import type { AccountSummary } from './api/accounts'; +import type { LadderConfig } from './api/ladder'; +import { escapeHtml, formatDate } from './utils'; + +export interface LadderConfigTableOptions { + accounts: readonly AccountSummary[]; + canEdit: boolean; + onEdit: (config: LadderConfig) => void; +} + +export function ladderAccountLabel(config: LadderConfig, accounts: readonly AccountSummary[]): string { + const account = accounts.find(item => item.id === config.cloud_account_id && item.provider === config.provider); + return account ? `${account.name} (${account.external_id})` : config.cloud_account_id; +} + +export function renderLadderConfigTable(container: HTMLElement, configs: readonly LadderConfig[], options: LadderConfigTableOptions): void { + if (configs.length === 0) { + container.innerHTML = '

No per-account configurations yet. Click Add Account Config to create one.

'; + return; + } + + const rows = configs.map(cfg => ` + + ${escapeHtml(ladderAccountLabel(cfg, options.accounts))} + ${escapeHtml(cfg.provider.toUpperCase())} + ${cfg.enabled ? 'Yes' : 'No'} + ${escapeHtml(cfg.mode === 'email_approval' ? 'Email Approval' : 'Auto Approve')} + ${escapeHtml(cfg.cadence === 'daily' ? 'Daily' : 'Weekly')} + ${cfg.target_coverage.toFixed(1)}% + ${cfg.updated_at ? escapeHtml(formatDate(cfg.updated_at)) : 'N/A'} + ${options.canEdit + ? `` + : ''} + + `).join(''); + + container.innerHTML = ` + + + + + + + + + + + + + + ${rows} +
AccountProviderEnabledModeCadenceTarget CoverageUpdated
+ `; + + // Wire edit buttons via querySelectorAll so no user data is ever injected + // into a JS string context (data-* attributes are HTML-attribute-context only). + container.querySelectorAll('.ladder-edit-btn').forEach(btn => { + btn.addEventListener('click', () => { + const accountID = btn.dataset['accountId'] ?? ''; + const provider = btn.dataset['provider'] ?? ''; + const cfg = configs.find(c => c.cloud_account_id === accountID && c.provider === provider); + if (cfg) options.onEdit(cfg); + }); + }); +} diff --git a/frontend/src/ladder.ts b/frontend/src/ladder.ts index 96fdac226..031ab87ff 100644 --- a/frontend/src/ladder.ts +++ b/frontend/src/ladder.ts @@ -14,11 +14,13 @@ */ import * as api from './api'; -import { escapeHtml, formatDate } from './utils'; import { showToast } from './toast'; +import { confirmDialog } from './confirmDialog'; import { canAccess } from './permissions'; import { initLadderTimeline } from './ladder-timeline'; import { initLadderAccountSelector } from './ladder-account-selector'; +import { renderLadderConfigTable } from './ladder-config-table'; +import { listAccountsMinimal, type AccountSummary } from './api/accounts'; // ========================================== // MODULE STATE @@ -32,6 +34,9 @@ let configModalSaveInFlight = false; // Cached configs from the last successful load. let cachedConfigs: api.LadderConfig[] = []; +// Registered accounts used to label table rows; empty when the list could not +// be loaded, in which case rows fall back to the stored account ID. +let tableAccounts: AccountSummary[] = []; let selectedAccount: ReturnType | undefined; // ========================================== @@ -59,7 +64,12 @@ export async function initLadderingSettings(globalEnabled: boolean): Promise []), + ]); + cachedConfigs = configs; + tableAccounts = accounts; renderConfigTable(cachedConfigs); const timeline = document.getElementById('ladder-purchase-timeline'); if (timeline) await initLadderTimeline(timeline, cachedConfigs, globalEnabled); @@ -169,6 +179,11 @@ function renderLadderingSection(globalEnabled: boolean): string { +
@@ -251,57 +266,10 @@ function renderLadderingSection(globalEnabled: boolean): string { export function renderConfigTable(configs: api.LadderConfig[]): void { const container = document.getElementById('ladder-configs-table-container'); if (!container) return; - - if (configs.length === 0) { - container.innerHTML = '

No per-account configurations yet. Click Add Account Config to create one.

'; - return; - } - - const canEdit = canAccess('update', 'config'); - const rows = configs.map(cfg => ` - - ${escapeHtml(cfg.cloud_account_id)} - ${escapeHtml(cfg.provider.toUpperCase())} - ${cfg.enabled ? 'Yes' : 'No'} - ${escapeHtml(cfg.mode === 'email_approval' ? 'Email Approval' : 'Auto Approve')} - ${escapeHtml(cfg.cadence === 'daily' ? 'Daily' : 'Weekly')} - ${cfg.target_coverage.toFixed(1)}% - ${cfg.updated_at ? escapeHtml(formatDate(cfg.updated_at)) : 'N/A'} - ${canEdit - ? `` - : ''} - - `).join(''); - - container.innerHTML = ` - - - - - - - - - - - - - - ${rows} -
Account IDProviderEnabledModeCadenceTarget CoverageUpdated
- `; - - // Wire edit buttons via querySelectorAll so no user data is ever injected - // into a JS string context (data-* attributes are HTML-attribute-context only). - container.querySelectorAll('.ladder-edit-btn').forEach(btn => { - btn.addEventListener('click', () => { - const accountID = btn.dataset['accountId'] ?? ''; - const provider = btn.dataset['provider'] ?? ''; - const cfg = cachedConfigs.find(c => c.cloud_account_id === accountID && c.provider === provider); - if (cfg) openLadderConfigModal(cfg); - }); + renderLadderConfigTable(container, configs, { + accounts: tableAccounts, + canEdit: canAccess('update', 'config'), + onEdit: cfg => openLadderConfigModal(cfg), }); } @@ -377,6 +345,7 @@ function openLadderConfigModal(existing?: api.LadderConfig): void { // Cancel is inside the cloned form; the outside Close button keeps its listener. newForm.querySelector('#ladder-modal-cancel-btn') ?.addEventListener('click', closeLadderModal); + newForm.querySelector('#ladder-cfg-mode')?.addEventListener('change', syncAutoApproveWarning); } // Populate modal fields. @@ -384,6 +353,7 @@ function openLadderConfigModal(existing?: api.LadderConfig): void { setSelectValue('ladder-cfg-provider', existing?.provider ?? 'aws'); setChecked('ladder-cfg-enabled', existing?.enabled ?? false); setSelectValue('ladder-cfg-mode', existing?.mode ?? 'email_approval'); + syncAutoApproveWarning(); setSelectValue('ladder-cfg-cadence', existing?.cadence ?? 'daily'); setValue('ladder-cfg-target-coverage', String(existing?.target_coverage ?? 100)); setValue('ladder-cfg-buffer-fraction', String(existing?.buffer_fraction ?? 0.10)); @@ -449,6 +419,7 @@ export async function saveLadderConfig(): Promise { if (saveBtn) saveBtn.disabled = true; try { + if (activatesAutoApprove(cfg) && !(await confirmAutoApprove())) return; const saved = await api.upsertLadderConfig(cfg); // Update the in-memory cache and re-render the table. @@ -465,7 +436,10 @@ export async function saveLadderConfig(): Promise { closeLadderModal(); showToast({ message: 'Ladder config saved.', kind: 'success' }); } catch (err) { - showToast({ message: 'Failed to save ladder config. Check inputs and try again.', kind: 'error' }); + // Surface the server's validation text (apiRequest copies the response's + // `error` field into err.message) instead of a generic failure. + const detail = err instanceof Error && err.message ? err.message : 'Check inputs and try again.'; + showToast({ message: `Failed to save ladder config: ${detail}`, kind: 'error' }); console.error('saveLadderConfig:', err); } finally { configModalSaveInFlight = false; @@ -473,6 +447,33 @@ export async function saveLadderConfig(): Promise { } } +// ========================================== +// AUTO-APPROVE SAFEGUARDS +// ========================================== + +function syncAutoApproveWarning(): void { + const mode = document.getElementById('ladder-cfg-mode') as HTMLSelectElement | null; + const warning = document.getElementById('ladder-cfg-mode-warning'); + if (warning) warning.hidden = mode?.value !== 'auto_approve'; +} + +// True when this save turns on unattended purchasing for the account: the +// saved config would be enabled with Auto Approve and the stored one was not. +function activatesAutoApprove(cfg: api.LadderConfig): boolean { + const unattended = (c?: api.LadderConfig): boolean => !!c && c.enabled && c.mode === 'auto_approve'; + const previous = cachedConfigs.find(c => c.cloud_account_id === cfg.cloud_account_id && c.provider === cfg.provider); + return unattended(cfg) && !unattended(previous); +} + +function confirmAutoApprove(): Promise { + return confirmDialog({ + title: 'Enable Auto Approve?', + body: 'Ladder purchases for this account will run without human approval. Commitments are long-term and usually cannot be cancelled once bought. Check the target coverage and per-run caps before continuing.', + confirmLabel: 'Enable Auto Approve', + destructive: true, + }); +} + // ========================================== // DOM HELPERS // ========================================== diff --git a/frontend/tests-e2e/ladder-account-selector.spec.ts b/frontend/tests-e2e/ladder-account-selector.spec.ts index ea1d1cfef..94259d55b 100644 --- a/frontend/tests-e2e/ladder-account-selector.spec.ts +++ b/frontend/tests-e2e/ladder-account-selector.spec.ts @@ -131,3 +131,36 @@ test('provider changes during loading and stale reopened responses keep the curr expect(errors).toEqual([]); expect(failures).toEqual([]); }); + +test('table names the account, Auto Approve warns and confirms, and save errors keep the server text', async ({ page }) => { + const { saves, errors } = await setup(page); + await page.route('**/api/accounts/list', route => route.fulfill({ json: [...accounts, { id: 'unavailable-saved-account', name: 'Azure saved', external_id: 'sub-saved', provider: 'azure' }] })); + await page.reload(); + const firstCell = page.locator('#ladder-configs-table-container tbody td').first(); + await expect(firstCell).toHaveText('Azure saved (sub-saved)'); + await expect(firstCell).toHaveAttribute('title', 'unavailable-saved-account'); + + await page.locator('#ladder-add-config-btn').click(); + const warning = page.locator('#ladder-cfg-mode-warning'); + await expect(warning).toBeHidden(); + await page.locator('#ladder-cfg-account').selectOption('registered-aws'); + await page.locator('#ladder-cfg-mode').selectOption('auto_approve'); + await expect(warning).toBeVisible(); + await expect(warning).toContainText('skips human approval'); + await page.locator('#ladder-cfg-enabled').check(); + await page.locator('#ladder-config-save-btn').click(); + const confirm = page.getByRole('dialog', { name: 'Enable Auto Approve?' }); + await expect(confirm).toBeVisible(); + await confirm.getByRole('button', { name: 'Cancel' }).click(); + await expect(confirm).toBeHidden(); + await expect(page.locator('#ladder-config-modal')).toBeVisible(); + expect(saves).toEqual([]); + + await page.route('**/api/ladder/configs', route => route.fulfill({ status: 400, json: { error: 'max_actions_per_run must be between 1 and 50' } })); + await page.locator('#ladder-config-save-btn').click(); + await confirm.getByRole('button', { name: 'Enable Auto Approve' }).click(); + await expect(page.getByText('Failed to save ladder config: max_actions_per_run must be between 1 and 50')).toBeVisible(); + await expect(page.locator('#ladder-config-modal')).toBeVisible(); + // The rejected PUT logs its 400 and the handler's console.error; nothing else may fail. + expect(errors.filter(error => !error.includes('400') && !error.startsWith('saveLadderConfig:'))).toEqual([]); +}); From 24dea97f995d9ddcc295a36669bf0ad499ba693b Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Sat, 24 Oct 2026 12:33:43 +0000 Subject: [PATCH 2/9] refactor(frontend): move ladder config modal markup to its own module ladder.ts sits at 494 lines, just under the 500-line rule, and the config modal's form layout needs reworking (review feedback: huge inputs and word-wrapped labels). Moving the static markup out first keeps that change reviewable and leaves ladder.ts room. No markup changes. Refs #742 Co-Authored-By: Claude Opus 5.5 --- frontend/src/ladder-config-modal.ts | 132 ++++++++++++++++++++++++++++ frontend/src/ladder.ts | 123 +------------------------- 2 files changed, 134 insertions(+), 121 deletions(-) create mode 100644 frontend/src/ladder-config-modal.ts diff --git a/frontend/src/ladder-config-modal.ts b/frontend/src/ladder-config-modal.ts new file mode 100644 index 000000000..2e3431852 --- /dev/null +++ b/frontend/src/ladder-config-modal.ts @@ -0,0 +1,132 @@ +/** + * Markup for the per-account Commitment Laddering config modal (Add/Edit + * Account Config). Kept apart from ladder.ts so that module stays focused on + * loading, wiring and saving. Field IDs are the contract with ladder.ts and + * ladder-account-selector.ts. + */ + +export function renderLadderConfigModal(): string { + return ` + + +`; +} diff --git a/frontend/src/ladder.ts b/frontend/src/ladder.ts index 031ab87ff..a674737fb 100644 --- a/frontend/src/ladder.ts +++ b/frontend/src/ladder.ts @@ -20,6 +20,7 @@ import { canAccess } from './permissions'; import { initLadderTimeline } from './ladder-timeline'; import { initLadderAccountSelector } from './ladder-account-selector'; import { renderLadderConfigTable } from './ladder-config-table'; +import { renderLadderConfigModal } from './ladder-config-modal'; import { listAccountsMinimal, type AccountSummary } from './api/accounts'; // ========================================== @@ -137,127 +138,7 @@ function renderLadderingSection(globalEnabled: boolean): string {
- - +${renderLadderConfigModal()} `; } From a01a2775a932b3de94cfa3e54f54842cd5029ce1 Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Sat, 24 Oct 2026 12:42:50 +0000 Subject: [PATCH 3/9] style(frontend): compact two-column layout for the ladder config modal Review feedback: the Add/Edit Account Config form laid each label and input side by side, so long labels wrapped word by word, every input stretched to the wrapped label's height, input edges were ragged, the Ramp Schedule textarea was squeezed next to its help text, and on short viewports the title scrolled away with the form. Labels now sit above standard-height inputs, and the short fields pair up in an aligned two-column grid (one column under 640px). Range and unit qualifiers such as "Minimum 10" or "Leave blank for no cap" move to muted hints under the inputs, linked with aria-describedby. The ramp textarea spans the full width with its help below. The header stays fixed while the body scrolls, with Cancel / Save pinned to the bottom. Field IDs, validation and save behaviour are unchanged. Refs #742 Co-Authored-By: Claude Opus 5.5 --- frontend/src/__tests__/ladder.test.ts | 1 + frontend/src/ladder-config-modal.ts | 95 ++++++----- frontend/src/styles/index.css | 1 + frontend/src/styles/ladder-form.css | 149 ++++++++++++++++++ .../tests-e2e/ladder-account-selector.spec.ts | 39 +++++ 5 files changed, 245 insertions(+), 40 deletions(-) create mode 100644 frontend/src/styles/ladder-form.css diff --git a/frontend/src/__tests__/ladder.test.ts b/frontend/src/__tests__/ladder.test.ts index e8c42ae91..d9943b562 100644 --- a/frontend/src/__tests__/ladder.test.ts +++ b/frontend/src/__tests__/ladder.test.ts @@ -81,6 +81,7 @@ describe('ladder.ts', () => { test('lookback input advertises the 10-day baseline minimum', async () => { await renderSection([baseConfig({ id: 'config-1' })]); expect(document.getElementById('ladder-cfg-lookback-days')).toHaveAttribute('min', '10'); + expect(document.getElementById('ladder-cfg-lookback-days')).toHaveAccessibleDescription('Minimum 10'); }); test.each([ diff --git a/frontend/src/ladder-config-modal.ts b/frontend/src/ladder-config-modal.ts index 2e3431852..639b1b271 100644 --- a/frontend/src/ladder-config-modal.ts +++ b/frontend/src/ladder-config-modal.ts @@ -3,6 +3,11 @@ * Account Config). Kept apart from ladder.ts so that module stays focused on * loading, wiring and saving. Field IDs are the contract with ladder.ts and * ladder-account-selector.ts. + * + * Layout (styles/ladder-form.css): labels sit above their inputs, the short + * fields pair up in a two-column grid that collapses to one column on narrow + * screens, and range/unit qualifiers live in a muted hint under each input. + * The header stays put while the body scrolls on short viewports. */ export function renderLadderConfigModal(): string { @@ -10,23 +15,23 @@ export function renderLadderConfigModal(): string {