# Keel: QA, accessibility and performance report

Scope: `prototype.html` (the app) and `index.html` (the case study), served locally, October 8, 2026. Headless Chrome via the DevTools protocol; probes from the ux-ui-audit skill (`probe-core`, `probe-focus`, `probe-heuristics`, `probe-perception`, `probe-ltr`) plus project scripts. Viewports: 320×640, 390×844 (mobile emulation), 1280×800, 1440×900; light and dark themes; reduced motion forced during probes so measurements read settled states.

Judged against: WCAG 2.2 AA, the project’s own tokens (`assets/tokens.css`) and motion rules (`docs/motion-system/README.md`).

## Summary

- **Accessibility:** 0 open AA failures after fixes. 10 defects found and fixed in this pass (below). 1 measured advisory kept with a reason.
- **Functional:** all primary flows pass the scripted run: onboarding, bill toggle, purchase check, recategorizing, adding to a goal, creating a goal, applying a plan, reset.
- **Performance:** 61fps with the CPU throttled 4×, worst frame 17ms, no long tasks during the hero and welcome animations.
- **Not done:** usability testing with people (planned, see the case study, section 12); a screen reader pass by a person (scripts can’t replace it).

## Findings fixed in this pass

| # | Severity | Where | Finding | Evidence | Fix |
| --- | --- | --- | --- | --- | --- |
| 01 | Accessibility, 1.4.10 | App, Steady, 320px wide | The purchase “Check” button overflowed the screen | `button.btn` right edge 366px in a 320px viewport | `.field { min-width: 0 }`, input `width: 100%` |
| 02 | Accessibility, 2.4.3 / modal | App, all sheets | Tab could leave an open dialog and reach the page behind it | Focus moved past the sheet into the screen behind | Background marked `inert` while a sheet is open; verified: 12 Tabs stay inside, Escape returns focus to the opener (`data-tx="t3"`) |
| 03 | Accessibility, 1.3.1 | App | No `main` landmark | `main=0 nav=1` | Screens wrapped in `<main>` |
| 04 | Accessibility, 1.3.1 / 2.4.6 | App, Steady and Spending | Section titles were styled text, not headings | Outline was only `H1: Your reading` | Real `h2`: Spending pace, Coming up, By category, Day by day, Transactions |
| 05 | Accessibility, 4.1.2 | App and case study | Decorative animations exposed as unnamed images | `role="img"` with `label=""` | Empty label now means `aria-hidden="true"`, no role |
| 06 | Accessibility, 1.3.1 | Case study, design system | Heading levels skipped | `h2 -> h4 at "Buttons"` | Cards use `h3` |
| 07 | Accessibility, 2.4.4 | Case study, research | Two different links with the same text | `"cmu.edu (pdf)" ×2`, different PDFs | Link text names the paper |
| 08 | Major | App | Text buttons ignored the `hidden` attribute after a style change | “Clear” visible with no purchase entered | `[hidden] { display: none !important }`; verified height 0 |
| 09 | Major | Case study | Before/after screenshots rendered stretched | 348×1688 for a 780×1688 image | `img { height: auto }`; now 348×751 |
| 10 | Accessibility, 1.4.10 | Case study header, 320px wide | The two toggles plus “Open the prototype” pushed the page to 386px | `scrollWidth 386, clientWidth 320`, from `div.right` | Under 400px the toggle labels become visually hidden (still their accessible names); now `scrollWidth 320` |

## Responsive and mobile-first (October 10, 2026)

All three pages are written mobile-first: base styles are the phone layout, and wider layouts are added with `min-width` queries. The app always was; the case study and system page were converted from 33 desktop-first `max-width` overrides.

- **The conversion changed nothing it shouldn’t.** Every element’s box, display, grid, borders and padding was recorded at 24 widths (320 to 1440) before and after. The case study is identical at all 24 widths; the system page differs only at 320px, where the fix below applies.
- **Checked at 10 widths** (320, 360, 390, 414, 600, 768, 820, 1024, 1280, 1440): page overflow, elements off-screen, clipped text, text under 12px, controls under 24px. The app was checked on all 9 screens at each width: 90 of 90 clean.

| # | Where | Finding | Fix |
| --- | --- | --- | --- |
| R1 | System page, 320px | The animation library grid needed a 320px column inside a 288px space: page 336px wide | Column minimum is now `min(100%, 320px)` |
| R2 | System page header, phones | Seven links wrapped into four rows: a 195px sticky header, 28% of a 640px screen | Links sit in one sideways-scrolling row under the wordmark: 98px |
| R3 | Case study header, 401 to 720px | “Reduce motion” wrapped and “Open the prototype” was cut off at the edge | Icon-only toggles and a “prototype” link until 721px; full labels stay as accessible names |
| R4 | Case study, Rive toggle, up to 820px | Negative margins put the button 11px past the left edge | The button is a 72 × 44 window onto the canvas |
| R5 | Case study, hero, phones | Three fixed headline lines each wrapped again: a ragged five-line headline, and the intro held to 26 characters | Below 641px the headline wraps naturally and the intro uses the full width |
| R6 | Case study header link | 22px tall on phones | 44px |
| R7 | Rive demo labels | Dropped to 11px in narrow cards; “Payday” touched the waterline | 12px always; “Payday” sits under the line |
| R8 | Text buttons | A disabled “Back” still looked clickable | Disabled style for text buttons |

Also fixed while converting: a garbled block of hero animation CSS, where a selector could never match and the tagline’s fade-in was always switched off.

## Prototype in React, with the guided tour (October 12, 2026)

Headless Chrome, scripted. What was run:

- **Every journey in the brief.** New visitor through the welcome, overview, all 8 steps (with the interaction in each), finish and Launch app; Explore on my own; returning visitor opening the app directly; Guide, then Restart tour; Back, Next, Skip tour, Exit (close button and Escape); resume after a reload mid-tour; screen guides from the overview and the Guide; the embedded copy in the case study. All pass.
- **Regression.** The ported app gives the same results as the vanilla version (see `docs/react-migration.md`), and onboarding, recategorizing, adding to and creating goals, applying a plan and reset all work.
- **Layout.** Every tour step and the welcome and overview at 320, 375, 390, 430, 768, 1024, 1280 and 1440px: the panel stays inside the app, and the highlighted element is either fully clear of the panel or, when it is taller than the space above a docked panel (the category list, the goal card and the daily-spending card on phones), its top is shown and the rest scrolls; the panel can also be collapsed to one bar. All 10 app screens at those 8 widths: no overflow, no clipped or sub-12px text, no control under 44px.
- **Accessibility.** No contrast failures on the welcome, overview or tour in either theme; no unnamed controls; one h1 per screen; focus moves to each step heading, returns to the opener when a sheet closes, and the finish dialog traps focus. Focus ring in the panel: 2px, 5.84:1 light, 5.89:1 dark.

| # | Where | Finding | Fix |
| --- | --- | --- | --- |
| T1 | Tour, phones | A short filtered list could not scroll the highlighted row clear of the docked panel | While the tour runs, screens get bottom space equal to the panel |
| T2 | Tour, ring | A dim layer around the highlight lowered text contrast below 4.5:1 (3.57:1 at 28%; even 12% fails) | No dimming: a double outline and a "Step n" tag mark the element |
| T3 | Tour, finish | After Launch app, focus did not reach the Steady heading (the page was still inert, and staying on the same screen skipped the focus) | Inert released synchronously; focus moves run even when the screen doesn't change |
| T4 | Tour, 1024px | The popover covered part of the goal card | Wider popover, lift the element when there's no side room, and use partial room below with internal scroll |
| T5 | Welcome | "Keel" appeared three times at once | The signature animation became a quiet keel illustration |

Not verified: Safari and Firefox, real devices, and a screen reader pass by a person.

## Automated suite (October 12, 2026)

`npm test` (`tests/run.mjs`) now repeats these checks on every page, so they don't depend on someone remembering to run them. Coverage: 14 page and app states, light and dark, at 320, 390, 768 and 1280px (112 audits, about 25,000 text elements measured); every internal URL; 185 controls pressed; 1,160 Tab stops across 6 pages at two widths; 37 journey assertions; 14 media files.

| # | Where | Finding | Fix |
| --- | --- | --- | --- |
| S1 | Case study, research | The ostrich effect citation linked to a CMU PDF that now returns 404 | Links to the DOI, 10.1007/s11166-009-9060-6 (checked against Crossref) |
| S2 | Case study, light theme | Secondary text on the surface panel measured 4.22:1 in four places: illustration captions, motion principle notes, system notes and the Phone/Desktop switch | They use `--ink-2-on-surface` (5.38:1), the token the system already defines for this |
| S3 | Prototype tour, step 2 | "Less room" and "More room" were the same 4.22:1 pairing | Same token |
| S4 | Case study, contrast table | Non-text pairs (hairlines, control borders, chart series) showed an "Aa" sample, which reads as text in a color that is never used for text (1.39 to 4.48:1) | Non-text rows show a stroke sample; the sample is hidden from screen readers, since the row label names the pair |
| S5 | Case study, research; LinkedIn carousel slide 2 | The 72% money-stress figure was given a time window (“in the prior month”) that the APA report attaches only to the 22% extreme-stress figure | Wording matches the report (checked against the PDF text); slide 2 re-exported |
| S6 | Live site (Netlify) | `prototype.html` next to `prototype/index.html` made Netlify's pretty URLs loop, so the prototype and its embed never loaded | The old address is a redirect rule in `netlify.toml` |

Checked and left as is: when tabbing at machine speed, focus can reach the lazily loaded prototype frame before the app inside has mounted. At human pace (one Tab every 250ms) focus went straight to the first control, with its ring, in 4 of 4 runs.

## Harbor & Signal redesign (October 13, 2026)

Keel moved from the warm Mineral & Ember identity to a cool fintech one: a grey-white canvas, white cards, deep navy type, one lime signal (source palette MP174 by Alex Cristache), Switzer in three levels (display, data with tabular figures, body), pill-shaped glass buttons, and a motion layer with a generated spring curve and a staggered screen entrance. Every Lottie, Rive file, static fallback, product screenshot, portfolio image and the LinkedIn video was regenerated. The research section and the reference list are unchanged, byte for byte.

Accessibility, measured: `source/contrast_audit.py` checks 94 pairings in both themes, 0 failing. Lime is 1.1:1 on the light surfaces, so on light it is only a fill behind navy (13.7:1) and every lime shape carries a navy edge. Glass buttons are measured with their translucent fill composited over each surface: labels 8.97:1 or more, the secondary button edge 3.66:1 or more; less transparency or more contrast in system settings gives solid buttons. `npm test` after the change: 112 page audits (25,646 text elements, 0 contrast failures), every link, 185 controls, 1,161 Tab stops, 37 journeys, 14 media files, all passing. Reduced motion still turns every new animation into a state change (checked: the cascade's duration resolves to 0ms).

| # | Where | Finding | Fix |
| --- | --- | --- | --- |
| H1 | Charts | Highlights used the hull classes; with a lime hull the plan line, today's bar and the next-bill dot would have nearly vanished on light | Charts use the data highlight (Ocean Blue) through new `f-hi` and `s-hi` classes |
| H2 | Prototype build | Vite bundled the stylesheets with the glass layer first, so older button rules won | Glass selectors are prefixed with `html`, independent of load order |
| H3 | Animations | Lime and white solids disappeared on the light canvas | A 1.25px navy edge on light-theme solids (Lottie, Rive and the app's hull) |
| H4 | Check animation | A lime ring on light was invisible | A lime disc with a navy edge and a navy check, the same in both themes |
| H5 | `source/lottie/gen_specs.js` | Broken since the React setup made the package an ES module | Renamed to `gen_specs.cjs`; references updated |
| H6 | Fallback export | No saved script; a first attempt wrote a duplicate `xmlns`, invalid XML | `source/lottie/export-fallbacks.mjs`; all 24 SVGs validate |
| H7 | Video recorder | Pointed at the removed `/prototype.html`, used selectors the React app no longer has, and set the slider in a way React ignores (the Plan scene stayed at $0) | New address, label-based taps, the native value setter, and the slider scrolled into view |
| H8 | `assets/tokens.css` | `--illo-ballast` declared twice in the dark media block | One declaration |

Every screen checked: a new `palette` suite in `npm test` visits 116 screens and states (onboarding, every tab, every sheet, the guide, each tour step, the finish dialog, the case study, design system and animation library) in both themes at phone and desktop widths, and scans every element, pseudo-element and SVG paint for a color from the earlier identities. The first run found one: the Slate material swatch in the case study's illustration section (fixed). The Lottie JSON, SVG fallbacks, favicon and Rive binaries were scanned as files too: clean. The only images left from before are the two 60/30/10 before-and-after screenshots, kept on purpose and labeled as the earlier identity.

Not re-measured: the 60/30/10 split. `source/weight_check.py` has the new palette, but it needs Pillow, which isn't installed; the case study labels its numbers as measured in the earlier palette.

## Vercel, the US and Canada, and research synthesis (October 14, 2026)

- **Hosting:** live on Vercel at keel-casestudy.vercel.app, rebuilt on every push to `main`. Vite 6.4.4 and plugin-react 4.7.0: `npm audit` 0 vulnerabilities; esbuild's install script approved for npm's `allowScripts` check. The one warning left in Vercel's build log (`npm warn Unknown env config "cache-dir"`) comes from Vercel's build image, not this project.
- **US and Canada:** a region switch in the prototype (Guide, `?region=ca|us`, or the browser locale) changes chequing or checking, paycheque or paycheck, CAD or USD, and the page language (`en-CA` or `en-US`). Four journey checks cover it.
- **Research synthesis:** proto-personas (labeled as assumptions), pain points, a bad-week journey, key flows and a competitor comparison including availability in Canada, added after the existing research, which is unchanged. New sources [10] to [13], each checked against the primary page: FP Canada's 2026 Financial Stress Index (43%), BetaKit on Monarch's Canadian launch, Copilot's FAQ (U.S. accounts only) and YNAB's supported countries.
- **Reflection:** a summary and six hypothesized outcomes in use, each with the measure that would test it. None is presented as a result.
- **Sketch to screen:** hovering the comparison sweeps the divider; 120fps on a Mac, a locked 60fps at 4x CPU slowdown; never with reduced motion.
- **Assets:** an 8.2s animated Wix thumbnail that loops without a visible seam (start and end frames match within 15 of 1.92 million pixels after a 0.4s cross-fade into frame zero); carousel slide 2 and the showcase video cite both countries; both videos re-recorded from the current prototype.

## Advisory kept

- **Switch height 28px (2.5.5, AAA).** Bill switches measure 46×28. They pass 2.5.8 (24px minimum) and their touch area is 46×46 through an invisible hit area, which the probe can’t see because it measures the element box.

## Measured results

The text contrast, target, name and overflow rows are from `npm test` (October 12, 2026: light and dark at 320, 390, 768 and 1280px); the focus ring and reduced motion rows are from the probe passes above. The case study figure reported before the automated suite (400 per theme, 0 failures) missed four places at 4.22:1; see S2 above.

| Check | App | Case study |
| --- | --- | --- |
| Text contrast measured | 2,668 text elements across 11 states and 88 audits, 0 failures | 12,556 across 8 audits (1,592 per page, per theme), 0 failures |
| Contrast pairings (tokens) | 68 across both themes, 0 failures (`source/contrast_audit.py`) | same tokens |
| Focus ring | 2px solid, 5.01 to 5.89:1, passes 1.4.11 and the 2px guidance in 2.4.13 | 2px, 5.89:1 |
| Targets under 24px | 0 | 0 non-inline after fixes (inline links are exempt under 2.5.8) |
| Unnamed controls | 0 | 0 |
| Horizontal overflow at 320px | none | none (data tables scroll inside their own containers) |
| Reduced motion | every duration token 0; Lotties hold the settled frame | same, plus a page switch |

## Performance

| Measure | Value |
| --- | --- |
| Hero animation, CPU 4× throttled | 61fps, worst frame 17ms, 0 long tasks |
| App first load, gzip | about 110 KB before fonts (HTML 5, CSS 10, lottie-web 74, app scripts 21) |
| All 24 Lottie files | 229 KB raw, 30 KB gzip; largest 16.3 KB |
| Lottie requests, case study | 12 unique fetches for 28 players (was 28 before the fetch cache) |
| Static fallbacks | 24 SVGs, 36.5 KB gzip |
| Rive runtime (case study only) | 51 KB JS + 431 KB WASM gzip, loaded only near the Rive demos; not loaded by the app |
| Keel Rive files | 4 files, 24 KB raw total (equilibrium 4.9, goal 3.7, onboarding 10.1, scenario 5.6), light and dark artboards in each |
| Screenshots | WebP, 31 to 47 KB each, served at 2.6 to 3.6× displayed size (sharp on high-density screens) |

## Not verified

- Real devices (tested in Chrome with mobile emulation and CPU throttling only).
- Safari and Firefox rendering of the Lottie SVG output.
- A human screen reader pass.
