fix(qa): resolve a11y nested-interactive on Categories & Snowball rows
- CategoriesPage: category rows are now a plain container with a dedicated chevron toggle button, instead of role=button rows nesting action buttons - PlanStatusBanner: split the collapsible header into a name/progress toggle, sibling action buttons, and a chevron toggle (actions no longer nested in the trigger button) - add e2e/categories.spec.js expand regression; all 8 authed pages now pass axe - docs: archive QA-B14-02 to HISTORY v0.41.0; QA plan status/cycle-log Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
127b69ffc2
commit
98c8fab176
|
|
@ -7,6 +7,7 @@
|
||||||
- **[Bills] `expected_amount` was unvalidated** — POST/PUT `/api/bills` accepted negative amounts, non-numeric input (silently coerced to $0), and absurd values (`1e15` → cents past `Number.MAX_SAFE_INTEGER`). `validateBillData` (`services/billsService.js`) now rejects non-numeric / negative / out-of-range with a structured `VALIDATION_ERROR`, accepting `0`…`$100,000,000`. Regression assertions in `e2e/api.probe.spec.js`. (was QA-B13-01)
|
- **[Bills] `expected_amount` was unvalidated** — POST/PUT `/api/bills` accepted negative amounts, non-numeric input (silently coerced to $0), and absurd values (`1e15` → cents past `Number.MAX_SAFE_INTEGER`). `validateBillData` (`services/billsService.js`) now rejects non-numeric / negative / out-of-range with a structured `VALIDATION_ERROR`, accepting `0`…`$100,000,000`. Regression assertions in `e2e/api.probe.spec.js`. (was QA-B13-01)
|
||||||
- **[UI] Negative amounts rendered as "$-50.00"** — client `fmt()` (`client/lib/utils.js`) and server `formatUSD()` (`utils/money.js`) placed the minus sign after the currency symbol; now render the conventional "-$50.00". Test added in `client/lib/utils.test.js`. (was QA-B6-01)
|
- **[UI] Negative amounts rendered as "$-50.00"** — client `fmt()` (`client/lib/utils.js`) and server `formatUSD()` (`utils/money.js`) placed the minus sign after the currency symbol; now render the conventional "-$50.00". Test added in `client/lib/utils.test.js`. (was QA-B6-01)
|
||||||
- **[a11y] Icon-only controls and chart SVGs had no accessible name** — Radix Select filter/sort triggers (Tracker, Bills) and the Spending month-nav buttons rendered with no discernible text (screen readers announced a bare "button"); Analytics chart `<svg role="img">`s had no name; a Snowball drag-handle `<div>` used a prohibited `aria-label`. Added `aria-label`s to the triggers/buttons and a `label` prop to the Analytics `SvgFrame`, and switched the drag-handle to `title`. Clears axe **critical `button-name`** and **serious `svg-img-alt` / `aria-prohibited-attr`** across those pages; guarded by `e2e/a11y.authed.spec.js`. (was QA-B14-01, part of QA-B14-02)
|
- **[a11y] Icon-only controls and chart SVGs had no accessible name** — Radix Select filter/sort triggers (Tracker, Bills) and the Spending month-nav buttons rendered with no discernible text (screen readers announced a bare "button"); Analytics chart `<svg role="img">`s had no name; a Snowball drag-handle `<div>` used a prohibited `aria-label`. Added `aria-label`s to the triggers/buttons and a `label` prop to the Analytics `SvgFrame`, and switched the drag-handle to `title`. Clears axe **critical `button-name`** and **serious `svg-img-alt` / `aria-prohibited-attr`** across those pages; guarded by `e2e/a11y.authed.spec.js`. (was QA-B14-01, part of QA-B14-02)
|
||||||
|
- **[a11y] Nested interactive controls in Categories & Snowball rows** — Categories rows were `role="button"` (and draggable) yet wrapped their own move/edit/delete buttons, and the Snowball plan-status header wrapped its action buttons *inside* the collapsible trigger button (axe **serious `nested-interactive`**). Restructured both: Categories now use a plain container with a dedicated chevron toggle button (click-anywhere still expands via mouse; keyboard/SR use the chevron); the Snowball header splits into a name/progress toggle, sibling action buttons, and a chevron toggle. Expand/collapse verified by `e2e/categories.spec.js`; all 8 authenticated pages now pass axe. (completes QA-B14-02)
|
||||||
|
|
||||||
### 🧹 QA Cleanup
|
### 🧹 QA Cleanup
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -145,11 +145,14 @@ export default function PlanStatusBanner({ plan, onPause, onResume, onComplete,
|
||||||
<div className="mb-4 rounded-xl border border-emerald-400/25 bg-emerald-500/[0.05] dark:bg-emerald-400/[0.04] shadow-sm overflow-hidden">
|
<div className="mb-4 rounded-xl border border-emerald-400/25 bg-emerald-500/[0.05] dark:bg-emerald-400/[0.04] shadow-sm overflow-hidden">
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<CollapsibleTrigger asChild>
|
{/* Header row. The name/progress area and the chevron are the collapsible
|
||||||
<button type="button" className="w-full text-left">
|
toggles; the action buttons are siblings (not nested inside a trigger
|
||||||
<div className="flex items-center gap-3 px-4 py-3">
|
button) so they don't trip axe nested-interactive (a11y QA-B14-02). */}
|
||||||
|
<div className="flex items-center gap-3 px-4 py-3">
|
||||||
|
|
||||||
{/* Status dot + name */}
|
{/* Status dot + name + progress — toggle */}
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<button type="button" className="flex min-w-0 flex-1 items-center gap-3 text-left">
|
||||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||||
{isActive ? (
|
{isActive ? (
|
||||||
<span className="relative flex h-2.5 w-2.5 shrink-0">
|
<span className="relative flex h-2.5 w-2.5 shrink-0">
|
||||||
|
|
@ -178,38 +181,44 @@ export default function PlanStatusBanner({ plan, onPause, onResume, onComplete,
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs font-mono text-muted-foreground">{overallPct}%</span>
|
<span className="text-xs font-mono text-muted-foreground">{overallPct}%</span>
|
||||||
</div>
|
</div>
|
||||||
|
</button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions — siblings of the triggers, not nested inside them */}
|
||||||
<div className="flex items-center gap-1 shrink-0" onClick={e => e.stopPropagation()}>
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
{isActive && (
|
{isActive && (
|
||||||
<>
|
<>
|
||||||
<Button size="sm" variant="ghost" className="h-7 px-2 text-xs gap-1" onClick={onPause}>
|
<Button size="sm" variant="ghost" className="h-7 px-2 text-xs gap-1" onClick={onPause}>
|
||||||
<Pause className="h-3 w-3" /> Pause
|
<Pause className="h-3 w-3" /> Pause
|
||||||
</Button>
|
|
||||||
<Button size="sm" variant="outline" className="h-7 px-2 text-xs gap-1 border-emerald-400/40 text-emerald-600 hover:bg-emerald-500/10 dark:text-emerald-400" onClick={() => confirm('complete', 'Mark plan as complete?', 'This will record your plan as successfully completed.', onComplete)}>
|
|
||||||
<CheckCircle2 className="h-3 w-3" /> Complete
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{isPaused && (
|
|
||||||
<>
|
|
||||||
<Button size="sm" variant="outline" className="h-7 px-2 text-xs gap-1 border-emerald-400/40 text-emerald-600 hover:bg-emerald-500/10 dark:text-emerald-400" onClick={onResume}>
|
|
||||||
<Play className="h-3 w-3" /> Resume
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" variant="ghost" className="h-7 px-2 text-xs gap-1 text-destructive hover:bg-destructive/10" onClick={() => confirm('abandon', 'Abandon this plan?', 'This plan will be moved to history. Your debt data stays unchanged.', onAbandon)}>
|
|
||||||
<X className="h-3 w-3" /> Abandon
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<Button size="sm" variant="ghost" className="h-7 px-2 text-xs gap-1 text-muted-foreground" onClick={() => confirm('new', 'Start a new plan?', 'Your current plan will be abandoned and moved to history. Your debt data stays unchanged.', onNewPlan)}>
|
|
||||||
<Zap className="h-3 w-3" /> New Plan
|
|
||||||
</Button>
|
</Button>
|
||||||
<ChevronDown className={cn('h-4 w-4 text-muted-foreground transition-transform', open && 'rotate-180')} />
|
<Button size="sm" variant="outline" className="h-7 px-2 text-xs gap-1 border-emerald-400/40 text-emerald-600 hover:bg-emerald-500/10 dark:text-emerald-400" onClick={() => confirm('complete', 'Mark plan as complete?', 'This will record your plan as successfully completed.', onComplete)}>
|
||||||
</div>
|
<CheckCircle2 className="h-3 w-3" /> Complete
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{isPaused && (
|
||||||
|
<>
|
||||||
|
<Button size="sm" variant="outline" className="h-7 px-2 text-xs gap-1 border-emerald-400/40 text-emerald-600 hover:bg-emerald-500/10 dark:text-emerald-400" onClick={onResume}>
|
||||||
|
<Play className="h-3 w-3" /> Resume
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" className="h-7 px-2 text-xs gap-1 text-destructive hover:bg-destructive/10" onClick={() => confirm('abandon', 'Abandon this plan?', 'This plan will be moved to history. Your debt data stays unchanged.', onAbandon)}>
|
||||||
|
<X className="h-3 w-3" /> Abandon
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Button size="sm" variant="ghost" className="h-7 px-2 text-xs gap-1 text-muted-foreground" onClick={() => confirm('new', 'Start a new plan?', 'Your current plan will be abandoned and moved to history. Your debt data stays unchanged.', onNewPlan)}>
|
||||||
|
<Zap className="h-3 w-3" /> New Plan
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
{/* Chevron — also a toggle */}
|
||||||
</button>
|
<CollapsibleTrigger asChild>
|
||||||
</CollapsibleTrigger>
|
<button type="button" aria-label="Toggle plan details" className="shrink-0 rounded text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||||
|
<ChevronDown className={cn('h-4 w-4 transition-transform', open && 'rotate-180')} />
|
||||||
|
</button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Collapsible body — per-debt rows */}
|
{/* Collapsible body — per-debt rows */}
|
||||||
<CollapsibleContent>
|
<CollapsibleContent>
|
||||||
|
|
|
||||||
|
|
@ -565,16 +565,16 @@ export default function CategoriesPage() {
|
||||||
dragProps?.isDropTarget && 'ring-2 ring-inset ring-primary/35',
|
dragProps?.isDropTarget && 'ring-2 ring-inset ring-primary/35',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
{/* Plain container (not role=button) so the nested action
|
||||||
|
buttons aren't interactive-in-interactive (a11y QA-B14-02).
|
||||||
|
Mouse click-anywhere still expands; keyboard/SR users use
|
||||||
|
the dedicated chevron toggle button below. */}
|
||||||
<div
|
<div
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
aria-expanded={isExpanded}
|
|
||||||
title={preview}
|
title={preview}
|
||||||
onClick={() => toggleCategory(cat.id)}
|
onClick={() => toggleCategory(cat.id)}
|
||||||
onKeyDown={event => onRowKeyDown(event, cat.id)}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'group grid cursor-pointer gap-4 px-4 py-4 transition-colors sm:px-5 md:grid-cols-[minmax(0,1fr)_auto] md:items-center',
|
'group grid cursor-pointer gap-4 px-4 py-4 transition-colors sm:px-5 md:grid-cols-[minmax(0,1fr)_auto] md:items-center',
|
||||||
'hover:bg-muted/35 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset',
|
'hover:bg-muted/35',
|
||||||
isExpanded && 'bg-muted/25',
|
isExpanded && 'bg-muted/25',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
@ -615,12 +615,20 @@ export default function CategoriesPage() {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ChevronDown
|
<button
|
||||||
className={cn(
|
type="button"
|
||||||
'mt-0.5 h-4 w-4 shrink-0 text-muted-foreground transition-transform',
|
onClick={event => { event.stopPropagation(); toggleCategory(cat.id); }}
|
||||||
isExpanded && 'rotate-180 text-foreground',
|
aria-expanded={isExpanded}
|
||||||
)}
|
aria-label={`${isExpanded ? 'Collapse' : 'Expand'} ${cat.name}`}
|
||||||
/>
|
className="mt-0.5 shrink-0 rounded text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
<ChevronDown
|
||||||
|
className={cn(
|
||||||
|
'h-4 w-4 transition-transform',
|
||||||
|
isExpanded && 'rotate-180 text-foreground',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
|
|
|
||||||
|
|
@ -101,7 +101,7 @@ before cross-cutting; regression last). Update **Status** and **Findings** every
|
||||||
| B11 | Admin panel | users, login mode, auth methods, backups, cleanup, status, onboarding | admin | ⬜ | 0 / 0 |
|
| B11 | Admin panel | users, login mode, auth methods, backups, cleanup, status, onboarding | admin | ⬜ | 0 / 0 |
|
||||||
| B12 | Settings, Profile & global UI | `/settings`, `/profile`, static pages, command palette, sidebar/nav | any | ⬜ | 0 / 0 |
|
| B12 | Settings, Profile & global UI | `/settings`, `/profile`, static pages, command palette, sidebar/nav | any | ⬜ | 0 / 0 |
|
||||||
| B13 | API / backend direct | all `/api/*`: auth, CSRF, validation, rate limits, error shape, IDOR, cents | via HTTP client | 🔄 | 0 / 1 |
|
| B13 | API / backend direct | all `/api/*`: auth, CSRF, validation, rate limits, error shape, IDOR, cents | via HTTP client | 🔄 | 0 / 1 |
|
||||||
| B14 | Non-functional | a11y, performance, PWA/offline, XSS/secrets, timezone/DST | large + adversarial | 🔄 | 1 / 2 |
|
| B14 | Non-functional | a11y, performance, PWA/offline, XSS/secrets, timezone/DST | large + adversarial | 🔄 | 0 / 3 |
|
||||||
| B15 | Regression & sign-off | full smoke on **production build**, exit criteria | seeded | ⬜ | 0 / 0 |
|
| B15 | Regression & sign-off | full smoke on **production build**, exit criteria | seeded | ⬜ | 0 / 0 |
|
||||||
|
|
||||||
> After B15, if any batch is 🔁 or has open S1/S2, loop back. Then start a new
|
> After B15, if any batch is 🔁 or has open S1/S2, loop back. Then start a new
|
||||||
|
|
@ -115,7 +115,7 @@ until you get a clean cycle.
|
||||||
|
|
||||||
| Cycle | Started | Build / commit | Findings logged | Fixed / archived | Result |
|
| Cycle | Started | Build / commit | Findings logged | Fixed / archived | Result |
|
||||||
|-------|---------|----------------|-----------------|------------------|--------|
|
|-------|---------|----------------|-----------------|------------------|--------|
|
||||||
| 1 | 2026-07-02 | `bdbf231` (dev) | 9 (find pass ongoing) | 7 → HISTORY v0.41.0 (B9-01, B13-01, B6-01, B14-01, B14-03, B0-01, B7-02; + a11y svg/aria of B14-02) | 🔄 in progress — B0/B1/B3/B4/B6/B7/B8/B9/B13/B14 probed. Solid: auth-isolation, CSRF, payment/date validation, **recurrence (quarterly/annual gating, Feb-31 clamp, leap year — all correct)**, **transaction matching/dedup guards**, subscription+spending math, XSS. **Fixed: seed 100× cents (S2), bill-amount validation, negative-money format, a11y labels, vendor-bundle split, unused-dep removal, dead-code removal.** Open: 2 (B7-01 rounding S3 [float-inherent], B14-02 nested-interactive S3 [architectural]) |
|
| 1 | 2026-07-02 | `bdbf231` (dev) | 9 (find pass ongoing) | 8 → HISTORY v0.41.0 (B9-01, B13-01, B6-01, B14-01, B14-02, B14-03, B0-01, B7-02) | 🔄 in progress — B0/B1/B3/B4/B6/B7/B8/B9/B13/B14 probed. Solid: auth-isolation, CSRF, payment/date validation, **recurrence (quarterly/annual gating, Feb-31 clamp, leap year)**, **transaction matching/dedup**, subscription+spending math, XSS. **Fixed: seed 100× cents (S2), bill-amount validation, negative-money format, all a11y (button-name/svg/aria/nested-interactive — 8/8 pages pass axe), vendor-bundle split, unused-dep + dead-code removal.** Open: 1 (B7-01 rounding S3 [float-inherent, deferred]) |
|
||||||
|
|
||||||
**Result key:** 🔄 in progress · 🔁 findings fixed, re-run required · ✅ clean (zero findings — QA complete)
|
**Result key:** 🔄 in progress · 🔁 findings fixed, re-run required · ✅ clean (zero findings — QA complete)
|
||||||
|
|
||||||
|
|
@ -135,7 +135,6 @@ fixing. Keep only **Open / Fixing / Fixed** rows here. Once a finding is
|
||||||
| ID | Sev | Area (`file:line`) | Summary | Status | Notes / repro |
|
| ID | Sev | Area (`file:line`) | Summary | Status | Notes / repro |
|
||||||
|----|-----|--------------------|---------|--------|---------------|
|
|----|-----|--------------------|---------|--------|---------------|
|
||||||
| QA-B7-01 | S3 | `utils/money.js:29` | `toCents` mis-rounds fractional cents: `toCents(1.005)` → 100 (`$1.00`) not 101 | 🔴 Open | see write-up (deferred — float-inherent) |
|
| QA-B7-01 | S3 | `utils/money.js:29` | `toCents` mis-rounds fractional cents: `toCents(1.005)` → 100 (`$1.00`) not 101 | 🔴 Open | see write-up (deferred — float-inherent) |
|
||||||
| QA-B14-02 | S3 | `/categories` (8), `/snowball` (1) | axe **serious** `nested-interactive`: draggable/expandable rows are `role=button` yet contain nested buttons | 🔴 Open | see write-up (deferred — architectural) |
|
|
||||||
|
|
||||||
**Finding template** (paste a new row above; keep the full write-up here until archived):
|
**Finding template** (paste a new row above; keep the full write-up here until archived):
|
||||||
|
|
||||||
|
|
@ -175,26 +174,6 @@ Impact: bounded to sub-cent, and only when a 3+ decimal dollar value reaches the
|
||||||
Fix (deferred): round on a string/scaled-integer basis, or add epsilon before round.
|
Fix (deferred): round on a string/scaled-integer basis, or add epsilon before round.
|
||||||
```
|
```
|
||||||
|
|
||||||
```
|
|
||||||
ID: QA-B14-02 (partially fixed — nested-interactive remains)
|
|
||||||
Severity: S3 (accessibility — WCAG 2 A/AA, axe "serious")
|
|
||||||
Environment: authenticated app; found/verified via e2e/a11y.authed.spec.js
|
|
||||||
FIXED this cycle (archived to HISTORY.md, was part of B14-02 + all of B14-01):
|
|
||||||
- button-name (critical) → Tracker/Bills Select triggers + Spending month-nav +
|
|
||||||
Analytics chart svgs now have aria-labels. All 4 pages pass.
|
|
||||||
- svg-img-alt → /analytics chart svgs got aria-labels (SvgFrame `label` prop). Pass.
|
|
||||||
- aria-prohibited-attr → /snowball drag-handle div aria-label → title. Pass.
|
|
||||||
REMAINING (deferred — architectural, needs manual drag/keyboard/SR verification):
|
|
||||||
- nested-interactive → /categories (8): each row is <div role="button" tabindex=0
|
|
||||||
aria-expanded> AND draggable AND contains nested edit/delete/drag buttons.
|
|
||||||
- nested-interactive → /snowball (1): a <button class="w-full text-left"
|
|
||||||
aria-controls aria-expanded> accordion trigger wraps other interactive controls.
|
|
||||||
Fix (deferred): don't make the whole row/trigger a button — use a dedicated
|
|
||||||
expand/collapse control and keep the row a plain container, so interactive
|
|
||||||
children aren't nested inside an interactive ancestor. Re-verify drag + keyboard
|
|
||||||
after. Guard: e2e/a11y.authed.spec.js (currently red on these two pages by design).
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Archiving fixed findings to HISTORY.md
|
## 3. Archiving fixed findings to HISTORY.md
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
// Regression for the QA-B14-02 a11y refactor: category rows are now plain
|
||||||
|
// containers with a dedicated chevron toggle button (instead of role=button rows
|
||||||
|
// that nested action buttons). Verify expand/collapse still works both ways.
|
||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
const { STORAGE_STATE } = require('./constants');
|
||||||
|
|
||||||
|
test.use({ storageState: STORAGE_STATE });
|
||||||
|
|
||||||
|
test('category expands via the chevron toggle and via row click (QA-B14-02)', async ({ page }) => {
|
||||||
|
await page.goto('/categories');
|
||||||
|
|
||||||
|
// Dedicated toggle button, collapsed initially.
|
||||||
|
const expandBtn = page.getByRole('button', { name: /^Expand / }).first();
|
||||||
|
await expect(expandBtn).toBeVisible();
|
||||||
|
await expect(expandBtn).toHaveAttribute('aria-expanded', 'false');
|
||||||
|
|
||||||
|
// Clicking it expands the row (button flips to "Collapse …").
|
||||||
|
await expandBtn.click();
|
||||||
|
await expect(page.getByRole('button', { name: /^Collapse / })).not.toHaveCount(0);
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue