---
target: portfolio reporting surface
total_score: 29
p0_count: 0
p1_count: 3
timestamp: 2026-07-08T11-21-56Z
slug: src-app-portfolio-slug-portfolio-reporting
---
## Design Health Score — 29/40 (Good; consistency + error-recovery are the drags)

| # | Heuristic | Score | Key issue |
|---|-----------|-------|-----------|
| 1 | Visibility of System Status | 3 | Scenario fetch swaps KPI content behind stale `keepPreviousData` numbers with only a text "Loading scenario…"; no dimming/pending signal. |
| 2 | Match System / Real World | 4 | Domain language is excellent and board-legible ("Skip already-compliant homes", "≈ N cars", "was EPC C"). |
| 3 | User Control & Freedom | 3 | Drill collapses, combobox Escapes — but compare columns are frozen to the first 4 scenarios with no way to change them. |
| 4 | Consistency & Standards | 2 | Money formatting genuinely inconsistent (moneyFull / moneyCompact / moneyShort / formatNumber / raw toLocaleString); chip heights differ (h-7 / h-8 / h-9). |
| 5 | Error Prevention | 3 | Filters apply reversibly; nothing destructive. Date input unvalidated. |
| 6 | Recognition over Recall | 3 | Filter chips show full state inline; but 3 EPC filters demand a lodged-vs-modelled mental model. |
| 7 | Flexibility & Efficiency | 3 | Drill-everywhere + CSV + deep links; no keyboard shortcuts. |
| 8 | Aesthetic & Minimalist | 3 | Restrained, density-appropriate; 5-across KPI band + delta pills + units + sublinks is a lot of simultaneous signal. |
| 9 | Error Recovery | 2 | Error states are bare one-liners with no retry and no `role="alert"`. |
| 10 | Help & Documentation | 3 | Tooltips carry real methodology — but hover-only, so keyboard/touch can't reach the definitions. |

## Anti-Patterns Verdict — NOT AI slop

**LLM review:** This reads as authored, not generated. It actively avoids the tells — no gradient text, no ghost-cards, no over-rounding, no side-stripe borders, no hero-metric template. Colour discipline is genuinely strong and documented in code (EPC ramp + semantic green/red + navy/tan is the whole palette; `DeltaPill` decouples colour from sign). The one real tell: a tiny uppercase tracked eyebrow (`text-[0.68rem] font-bold uppercase tracking-wide`) on nearly every section (KpiBand, FilterChips ×3, MeasureAllocation, InvestmentLedger GroupLabel, DrillDownShelf, DataQuality), compounded by rendering several of them at `text-gray-400`.

**Deterministic detector:** `detect.mjs` → exit 0, **0 findings**, across all 18 .tsx files. Verified genuine (re-ran `--no-config` and per-file; no ignore config). The detector corroborates the "not slop" verdict — none of the mechanical tells are present. It found nothing the review missed; the review found what a static scan can't (semantics, interaction, control flow).

**Where the two agree, with hard numbers:** the detector quantifies the review's soft concerns — **43× `text-gray-400`** on content-bearing text, **0 focus styles across 17 buttons**, **0 reduced-motion guards**, **2 total `aria-` attributes and 0 `role=`** on custom controls including a component literally named ScenarioCombobox.

## Overall Impression

A genuinely well-composed, restrained B2B reporting surface that respects its numbers — the colour system and the drill-everywhere pattern are the real wins. It's let down almost entirely on the **accessibility layer**, which is not decorative here: WCAG AA is a stated product requirement for housing-sector procurement, and the surface currently ships with no custom focus states, sub-AA label contrast, and unlabelled custom controls. The single biggest opportunity is one focused accessibility pass — it would move three heuristics at once and is mostly mechanical.

## What's Working

1. **A single, honest colour system enforced in code.** `getEpcColorClass` drives every EPC surface; `DeltaPill.improved` makes colour mean "good/bad" not "up/down" (a bill fall and a SAP rise are both green); `EPC_TEXT_ON` hand-tunes text colour per band for the hard yellow-D and lime-C. This is the "figures treated with more care than decoration" bar, met.
2. **The compare table refuses to crown a winner** — per-row best-marker, no hero recommendation. That's a deliberate institutional-trust choice for a board audience, correctly capped at 4 columns for desk readability.
3. **One drill pattern for every count.** The same `DrillDownShelf` serves the reporting page, data-quality, measures and ladder bands; `tabular-nums` on every figure column. The uniform interaction is why differing panels don't read as an "identical card grid."

## Priority Issues

**[P1] No focus indicators anywhere — 0 focus styles across 17 buttons, and the ladder's focus box is structurally broken.** Detector: zero `focus:`/`focus-visible:` in the entire surface; every control relies on the UA default ring. Worse, EPC ladder bands are `<button className="contents">` (EpcLadder.tsx:51) — `display:contents` removes the button's box, so even a default ring has nothing to draw on, on the page's primary chart interaction.
- *Why it matters:* WCAG 2.4.7 fail; a keyboard user (Sam) can't see where they are. AA is a stated requirement.
- *Fix:* add a consistent `focus-visible:` ring token to every interactive element; make each ladder row the focusable element (grid item / inner wrapper with its own ring), not `contents`.

**[P1] Load-bearing text at `text-gray-400` (~2.5:1) — 43 occurrences.** Not decoration: KPI labels and units (KpiBand.tsx:25,31), the combobox "VIEW" label and every scenario sub-line, and the data-quality issue *definitions* (DataQualityClientArea.tsx:151) — prose a board member must read to interpret the numbers.
- *Why it matters:* AA body-text fail on interpretive labels.
- *Fix:* promote content-bearing `text-gray-400` to `gray-600` (#4b5563 ≈ 7:1); reserve gray-400 for the "· est" suffix and genuinely decorative meta.

**[P1] Compare columns are frozen to the first 4 scenarios.** `useState(() => scenarios.slice(0,4))` (CompareClientArea.tsx:142) with no setter exposed — the user cannot choose *which* scenarios to compare, which is the entire job of the compare page.
- *Why it matters:* the page's core action is impossible; a portfolio with >4 scenarios can't compare the ones it cares about.
- *Fix:* add/replace columns through the same searchable combobox (as the wireframe promised), keeping the 4-column cap.

**[P2] Three divergent money formatters on one screen.** `moneyShort` (MeasureAllocation.tsx:25, no sign handling) vs `moneyCompact` (primitives.tsx:100) vs `moneyFull` vs raw `toLocaleString`. The allocation bar shows "£2.48m" while the ledger beside it shows "£2,477,000" for conceptually paired money.
- *Why it matters:* undermines "numbers are the product" trust; a numerate user reads it as a data bug.
- *Fix:* delete `moneyShort`, route everything through `moneyCompact`/`moneyFull` with one rule (compact for chart labels, full for ledgers).

**[P2] Custom controls lack semantics & state announcements.** ScenarioCombobox has no `role="combobox"/"listbox"/"option"`, `aria-expanded`, or arrow-key navigation (it's a button-list in a popover); filter toggles carry no `aria-pressed`; error messages ("Could not load measures.") have no `role="alert"`; `animate-pulse` skeletons have no `motion-reduce:animate-none`.
- *Why it matters:* screen-reader users get no state changes; vestibular-sensitive users get unguarded motion.
- *Fix:* wire ARIA roles/state on the combobox and chips, `role="alert"` on error regions, `motion-reduce:animate-none` on skeletons, and a text/glyph (not colour-only 0.5rem dot) on the compare best-marker.

## Persona Red Flags

**Alex (power user):** compare columns hard-frozen (can't pick scenarios); money-format drift reads as a bug; no keyboard shortcut to open the combobox or page the drill shelf; "Open in property table →" lands unfiltered (DrillDownShelf.tsx:90) so the filtered set he expected is gone.

**Sam (accessibility-dependent):** no visible focus box on the ladder (the primary interaction); load-bearing definitions live in hover-only tooltips whose `Info` triggers are non-focusable `<span>`s; KPI labels/definitions fail 4.5:1; error messages aren't announced; compare "best" is colour + a near-invisible dot.

## Minor Observations

- Semantic green `#0c6b4a` and tan `#a07c42`/`#9a5b10` are hardcoded hex (6× and 5×) rather than Tailwind tokens, despite `epc_*` and brand navy/tan being real tokens — they can drift.
- Combobox option list has no `aria-activedescendant`; arrow-keys don't move between options.
- Drill shelf renders below the fold with no scroll-into-view on open — clicking a ladder band can look like nothing happened.
- `tabular-nums` applied to GoalCallout prose (ordinary words) — a copy-paste tell.
- Three chip radii in play (`rounded`, `rounded-[5px]`, `rounded-full`) and three chip heights (h-7/h-8/h-9).
- The uppercase eyebrow appears on ~7 sections; deleting them and letting the navy `font-semibold` headings stand would remove the one AI tell and the one place accent colour does decoration work.

## Questions to Consider

1. The KPI band is 5-across to fill the row, but a board commits budget on ~2 of those numbers (average EPC, net cost). Is the 5th ("Energy use", no delta, no drill) earning its place, or is it there for grid symmetry?
2. Every section carries the same uppercase eyebrow. If you deleted them all, would anyone miss them — or is the eyebrow doing decoration the palette rules forbid?
3. The compare table proudly refuses to crown a winner, yet the reporting page's default view is "Recommended plans — the engine's best plan per home." Do those two stances contradict, and which does the board actually trust?
