How we tested Parking NL with simulated people: three personas, 17 scripted flows against production, a think-aloud for every step grounded in the real screenshot, then fixes and the exact same scripts again.

// why
Parking NL already had end-to-end tests, and they paid off. They caught a first load stuck on "Calculating prices…": two requests in one Worker isolate shared the same in-flight data promise, and the second one waited forever. The fix was to never share an in-flight load between requests, plus a regression test that fails if the page still says "Calculating prices" with no options listed. They also caught phone search losing focus: the vaul drawer didn't pass modal=false to the Radix Dialog underneath, so its focus trap stole focus from the search input.
Those are bugs with a clear yes/no. What scripted checks don't tell you is whether a commuter standing next to her car, phone in one hand, can actually compare prices and open a tariff. That's what this round was for: a cheap first pass at friction, before spending real people's time.
// 1 · personas
Each persona is a goal, a device, a language and constraints. The device and constraints become browser settings. The goal becomes the tasks.
| Persona (synthetic) | Device / context | Goal |
|---|---|---|
| Sanne, 34, commuter Delft → Den Haag | iPhone 14 (WebKit), Dutch UI, location allowed; one-handed, in a hurry | Cheapest or free spot near work; tomorrow a full 8 h day near Delft station |
| Tom, 41, visitor from the UK | Pixel 7 (Chromium), English, location denied | Ziggo Dome tomorrow 19:00 for 4 h; also Ahoy Rotterdam. Doesn't know Dutch zones or P+R |
| Priya, 38, PM at a fleet/navigation company | Desktop 1440×900, English | Evaluate the API/MCP, limits, pricing and licence terms; leave contact details |
A second round added a fourth persona, Henk, 68, on an iPhone with large text. More on that below.
// 2–3 · flows and evidence
The flows are one Playwright script, e2e/ux.ts: 17 of them (6 + 6 + 5), run against parking.davidbroza.dev. Sanne runs in WebKit with the iPhone 14 profile, Dutch locale and a fake geolocation in Den Haag. Tom runs in Chromium as a Pixel 7 with geolocation not granted. Priya runs on desktop and calls the REST and MCP examples exactly as written on /developers. Taps are real touchscreen taps; typing is real keystrokes.
const c = await b.newContext({
...devices["iPhone 14"], locale: "nl-NL",
timezoneId: "Europe/Amsterdam",
geolocation: { latitude: 52.0799, longitude: 4.3113 },
permissions: ["geolocation"],
recordVideo: { dir: `${OUT}/video/p1` },
})Every step records:
// 4 · think-aloud
Then Grok Bot writes each persona's think-aloud, one step at a time. The rule: every quote must be based on that step's screenshot and DOM observations. No imagined screens, no features that weren't there. A few, before the fixes:
“The date, time and 7 duration chips eat the whole sheet. '8 uur' was off-screen to the right; I only found it by swiping the row. I see exactly one result under all those controls, and the list won't scroll unless I drag the sheet all the way up.”
Sanne · tomorrow 08:30 for 8 h · synthetic
“I said no to location and now it shows me … Utrecht? 'now: Utrecht centrum'. I'm not in Utrecht. Nothing tells me why or what to do.”
Tom · location denied · synthetic
“There's only a Google Maps button. I use Apple Maps and Waze in the car.”
Sanne · navigate · synthetic
“Limits say 'Anonymous: 20 tool calls/min', but the OpenAPI spec says 20 and the API terms say 30/min + 500/day. Which one is true?”
Priya · developer docs · synthetic
At the end of their flows, each persona also gets a 0–100 "SUS-style" ease score. That's an estimate from the simulated perspective. It is not a measured SUS, and I only use it to compare before with after.
// 5 · findings
| Sev | Finding | Fix |
|---|---|---|
| Critical | On phones the bottom sheet showed day, time, 7 duration chips and 6 filter chips, and about one result. The list couldn't scroll until the sheet was dragged to full height, so comparing options and opening a card failed. | When + duration collapse into one summary button ("Morgen 08:30 · 8 uur ▾") that opens the pickers; chips wrap; the list scrolls at every sheet height. |
| Major | Chip rows cut off at the right edge with no hint there was more ("8 uur", "1 dag", "Blauwe zone"). | Durations wrap; the filter row fades at the edge. |
| Major | Location denied → silent Utrecht results, no explanation. | Banner: "Location is off. Search an address or place, or try again"; fallback labelled as an example. |
| Major | Navigation was Google Maps only. | Apple Maps by default on Apple devices; "Open in: Google Maps · Apple Maps · Waze". |
| Major | No way to share a place; the URL didn't identify it. | Share button (native sheet or copy link); links carry the place and reopen its card. |
| Minor | P+R not explained to visitors; API limits inconsistent between docs, OpenAPI and terms. | P+R explainer in the card (NL/EN); docs now say 30/min + 500/day anonymous. |
// 6 · fix and rerun
After deploying the fixes, all 17 flows ran again, unchanged, on production. The only change to the test was one extra tap on the new "when" summary button before using the pickers.
| Persona | Tasks before | Tasks after | Ease est. before → after |
|---|---|---|---|
| Sanne (iPhone, NL) | 3/6 | 6/6 | 58 → 79 |
| Tom (Android, EN) | 6/6 | 6/6 | 66 → 80 |
| Priya (desktop, API) | 5/5 | 5/5 | 80 → 83 |
| All | 14/17 | 17/17 | synthetic estimates, not SUS |
Tom passed all six flows even before the fixes, but only by the letter of the criteria. Being dropped in Utrecht passed the check and still confused him. That's exactly the kind of thing a pass/fail script misses and the think-aloud catches.




// the real report
This is the full report the test produced: personas, method, every flow before and after, the observed DOM facts, the think-aloud, the findings table and the before/after evidence. It's unedited, just compressed for the web.
Task text is verbatim from the Playwright script (e2e/ux.ts); there were no spoken prompts. The script did the steps and checked pass/fail in the page. Success checks per task are in the PDF.
| Persona | Device | Scenario | Tasks |
|---|---|---|---|
| P1 Sanne (commuter, iPhone, NL) | iPhone 14 | Opens the app next to her car in Den Haag; works near Delft station tomorrow. |
|
| P2 Tom (visitor, Android, EN) | Pixel 7 | UK visitor going to a Ziggo Dome concert tomorrow 19:00 for 4 h; also checks Ahoy Rotterdam. |
|
| P3 Priya (fleet PM, desktop) | Desktop 1440×900; locale en-GB | Product manager evaluating the API/MCP, limits, pricing and licence terms for business use. |
|



Pages 1, 5 and 7. Click to open the full PDF.
// what we changed because of the test
All of these fixes shipped in one Parking NL commit, f1ca657 ("Synthetic persona UX test (report + PDF); fixes: list-first phone sheet, wrapping durations, location-off banner, Apple Maps/Waze, share + deep link, P+R explainer, consistent API limits"). The screenshots are the same Playwright step before and after that deploy.
The date, time and 7 duration chips used to fill the phone sheet, leaving about one result, and the list wouldn't scroll until the sheet was dragged to full height. Now arrival and duration are one summary button ("Morgen 08:30 · 8 uur ▾") that opens the pickers, and the list scrolls at every height. Sanne's 8-hour task went from ❌ to ✅, with 2 → 1 off-screen swipes.


"8 uur" and "1 dag" were cut off at the right edge with no hint there was more. The durations now wrap inside the picker, so none are hidden, and the filter row gets a fade at the right edge. You can see the fade after "Terrein" in the after shot above.
With location denied, Tom silently got Utrecht. Now a banner explains it, and the fallback is labelled as an example:
loc_off: "Location is off. Search an address or place, or"
loc_try: "try again"
example_loc: "example: Utrecht centrum"

Navigation was Google Maps only, and there was no way to share a place. The main button now opens Apple Maps on Apple devices and Google elsewhere, with an "Open in: Google Maps · Apple Maps · Waze" row. A Share button opens the native share sheet or copies the link. Links now carry the place (a=<id>) and reopen its card. Sanne's navigate and share tasks both went from ❌ to ✅.




Tom found P+R Duivendrecht at €10.76 but nothing explained what P+R is. P+R cards now carry a short explanation in Dutch and English. That text is in the card, below where the test screenshots end, so here's the shipped string:
pr_explain: "Park + Ride: park at the edge of town and continue
by public transport. Often cheaper when …"The docs and OpenAPI said 20 per minute, but the terms and the actual limiter said 30 per minute plus 500 per day. The /developers page and the OpenAPI description now match the limiter:
- Anonymous: 20 tool calls / minute per IP …
+ Anonymous: 30 calls / minute and 500 / day per IP …
+ API key: 600 calls / minute and 50,000 / day …The report left four minor items open. A later commit, a201def ("Landmark index, display names, blue zone explainer, …"), went after three of them: landmark search, clean display names and the blue-zone explainer. The draft legal pages still need a lawyer.
// round 2 · clarity
Round 2 ran the same way, scripted Playwright sessions on production, but asked a different question. Can people tell what they're looking at? It added a fourth persona, Henk, 68: non-technical, on an iPhone with large text (the page is rendered at 130% text size to approximate iOS "Larger Text"). It also took screenshots at zoom 12, 14 and 16 in Den Haag, Amsterdam and Haarlem.
| Before | After | |
|---|---|---|
| Tasks passed | 7/28 (25%) | 38/40 (95%) |
| Tasks per persona | T1–T7 | T1–T10 (adds T8 walking ranking, T9 isochrones, T10 door-to-door) |
The before run had 28 tasks (7 tasks × 4 personas). The after run had 40, because three new tasks tested the new walking and door-to-door features. On the same 28 tasks, after the fixes, 27 passed.




The zone screenshots showed Scheveningen with outlines but no colour. MapLibre was rejecting the zones-fill layer because a zoom expression was nested inside a "case". The fix shipped with a style validation unit test and an e2e check (zones-shots.ts) that fails if no fill renders.



Task text is verbatim from the Playwright script (e2e/ux2.ts); there were no spoken prompts. The script did the steps and checked pass/fail in the page. Success checks per task are in the PDF. All four personas got the same tasks; T8–T10 were added in the after run.
| Persona | Device | Scenario | Tasks |
|---|---|---|---|
| Sanne (34, commuter, NL) | iPhone 14 | Den Haag Centrum (5-second test also at Dam Amsterdam, Grote Markt Haarlem) |
|
| Henk (68, non-technical, NL, large text) | iPhone 14 · 130 % text | Den Haag Centrum (5-second test also at Dam Amsterdam, Grote Markt Haarlem) |
|
| Tom (tourist, EN) | Pixel 7 | Den Haag Centrum (5-second test also at Dam Amsterdam, Grote Markt Haarlem) |
|
| Priya (fleet PM, EN) | Desktop 1440×900; en-GB | Den Haag Centrum (5-second test also at Dam Amsterdam, Grote Markt Haarlem) |
|



Round 2 report, pages 2, 1 and 6. Click to open the full PDF.
// round 3 · information hierarchy
My brief after round 2 was: "It's super busy. Focus on information hierarchy, and use the usability test results as input." Round 3 added a fifth persona: Lisa, a parent driving from Pijnacker to an event at Ahoy, who needs a door-to-door answer. It ran the same 59 tasks on the shipped UI and then on the redesign.
The baseline had every feature but a crowded first view: 10–12 floating controls on a phone and 33 on desktop, 18 of our own map layers, and 2 taps to start navigating. The redesign uses three levels. What you always see: search, one best card, the map. One tap away: the list, sort and time. On demand: filters, walking rings, restrictions and the legend, all in one map menu.
| Round 3 | Before | After |
|---|---|---|
| Tasks passed | 48/59 | 55/59 |
| Taps to start navigation | 2 | 1 |
| Time to first decision (median) | 2.3 s | 1.4 s |
| 5-second comprehension | 5/5 | 5/5 |
| Floating controls on a phone | 6–7 | 4 |
One tap opens your default maps app from the card's navigate icon; choosing Apple Maps, Google Maps or Waze is still 2 taps, inside the detail card. The only failure left on phones was the strict "≤ 3 floating controls" check: the phone had 4 (search, ×, map menu, and the attribution ⓘ).


Task text is verbatim from the Playwright script (e2e/ux3.ts); there were no spoken prompts. The script did the steps and checked pass/fail in the page. Success checks per task are in the PDF. Same script before and after the redesign.
Every persona:
| Persona | Device | Scenario | Extra tasks |
|---|---|---|---|
| Sanne (34, commuter, iPhone, NL) | iPhone 14 | Destination Den Haag Centraal. |
|
| Tom (visitor, Android, EN, location off) | Pixel 7 | Opens the app with no location, searches the Rijksmuseum. |
|
| Priya (fleet PM, desktop, EN, + MCP) | Desktop 1440×900; en-GB; no location | Destination Utrecht Centraal; also checks the MCP answer. |
|
| Henk (68, iPhone, large text, NL) | iPhone 14 | Destination Markt Delft. |
|
| Lisa (parent, iPhone, NL, Pijnacker → Ahoy event, door-to-door) | iPhone 14 | Event at Rotterdam Ahoy, Friday 16 Oct 19:00 for 3 h; searches “Ahoy”. |
|
// round 4 · modes and "price here"
Round 4 kept the five personas and the round 3 script, and added four checks. Is the mode clear (my location / destination / map area)? Does the card show the price here first and then a cheaper alternative? Are there type icons? Does the sheet chevron work? It also checked that door-to-door only switches on when you ask for it. Result: 55/59 → 77/79 tasks passed. Time to first decision fell for four of the five personas (Henk: 1,357 → 938 ms; Lisa: 1,073 → 701 ms). Priya went from 1,341 to 1,413 ms.
To be honest about the measurement: between rounds 3 and 4 I changed how the floating-control check counts. It now leaves out the map attribution, which is legally required, and the desktop side panel. Part of the improvement in that check comes from that change, not from the UI. Even so, the check still fails twice: for Priya on desktop, and for Henk with large text.
Bugs found and fixed during the round:
Task text is verbatim from the Playwright script (e2e/ux4.ts); there were no spoken prompts. The script did the steps and checked pass/fail in the page. Success checks per task are in the PDF. Round 3 tasks plus MO1, H1, I1 and SC1.
Every persona:
| Persona | Device | Scenario | Extra tasks |
|---|---|---|---|
| Sanne (34, commuter, iPhone, NL) | iPhone 14 | Destination Den Haag Centraal. |
|
| Tom (visitor, Android, EN, location off) | Pixel 7 | Opens the app with no location, searches the Rijksmuseum. |
|
| Priya (fleet PM, desktop, EN, + MCP) | Desktop 1440×900; en-GB; no location | Destination Utrecht Centraal; also checks the MCP answer. |
|
| Henk (68, iPhone, large text, NL) | iPhone 14 | Destination Markt Delft. |
|
| Lisa (parent, iPhone, NL, Pijnacker → Ahoy event, door-to-door) | iPhone 14 | Event at Rotterdam Ahoy, Friday 16 Oct 19:00 for 3 h; searches “Ahoy”. |
|
// accessibility audit
Not synthetic: an automated axe-core run against the WCAG 2.2 AA rules. It covered the app (map, detail card, phone search) and ten subpages, in light and dark, on a Pixel 7 and on desktop. Serious or critical violations went from 71 to 0. Lighthouse mobile accessibility scored 98. The biggest fixes:
| Rule | Nodes before | Fix |
|---|---|---|
| color-contrast | 63 | Darker amber notes, occupancy colours with dark-mode variants, theme-aware /about |
| scrollable-region-focusable | 50 | Scrollable code blocks and tables are keyboard-focusable |
| label-content-name-mismatch | 46 | Accessible names contain the visible text (search, language, map menu) |
| select-name / aria-valid-attr-value / link-in-text-block | 8 / 4 / 8 | Labelled selects, valid combobox aria-controls, underlined attribution links |
Also checked: keyboard order and visible focus, a skip link, 200% text at 320 px with no horizontal scroll, reduced motion, and a colour-blind simulation of the map palette (no confusable pairs). Screen-reader behaviour was only checked through the accessibility tree. No real VoiceOver or TalkBack session was run. The axe check is now one of the seven deploy checks, so any serious violation rolls the deploy back.
// next step
Synthetic personas find the obvious friction quickly. They can't tell me what a real driver expected, or why they hesitated. So the next round uses Verbatim, an async AI interviewer I built, plus a small SDK panel that runs inside Parking NL itself. Every participant gets the same core questions, word for word. The AI only adds guarded follow-ups, and it never gives hints.
The SDK does nothing for normal visitors. It only switches on for invited ?vb= sessions, and it masks inputs by default.
Honest status: both videos are a synthetic test run of the participant flow. The answers are scripted, not from a real person. There are no real-user results yet. They'll get their own write-up when they exist.
How the two fit together: synthetic personas run first and on every change, because they're cheap and repeatable and catch regressions. Verbatim comes after, with a handful of real people per segment, to find out what the scripts can't tell me: expectations, wording and reasons. More on Verbatim pricing and for developers.
// limits
What it is good for: catching obvious friction fast and cheap. It took one morning and a handful of fixes to go from a core phone task that failed to one that works. The next step is the real one: about five real people per segment (commuters in Den Haag and Delft, event visitors, a fleet PM) before calling it done.
Try the result at parking.davidbroza.dev — or read what Parking NL is →
Note: this article was written by an AI from the test report and scripts. The test sessions and numbers are real; the personas, quotes and ease scores are synthetic. The prose isn't hand-written.