The directory where an agent finds a photographer, a stager or a locksmith —
everyone who works a listing without working for the brokerage. It was eleven identical
full-width cards in one pile, a locksmith given exactly as much room as a title company, with
the trade an agent actually browses by set as the smallest text on the card. It is now
shelved: the trade is the heading, vendors are rows beneath it, and the figures line up down
the whole page so two photographers can be compared without scrolling between them. A second
pass then put a map beside the shelves, which is where most of the thinking went: a vendor is
not a point the way a job is, so the map is about reach rather than location. Same data layer,
same tokens, nothing renamed.
Scope The Vendors page, the row that lists a vendor, and the coverage map beside themStatus Committed as 44f2888 and a72220a on 2026-09-04; both deployed
01
What was wrong with it
The page had one job — answer "who is even out here?" — and it answered by
handing you eleven slabs and asking you to read them all.
Before. Eleven vendors, 2,891px of page, and no two facts in the same place twice.
Eleven slabs, one pile. Every vendor got the same tall card whatever the trade, so a locksmith and a title company occupied identical space and the page ran 2,891px for eleven people. Nothing in the layout helped you scan, which meant reading all eleven or none.
Nothing lined up. The four facts sat in a row that stretched to whatever width the card had, so distance on one card was nowhere near distance on the next. Comparing two photographers meant scrolling between two cards a hundred pixels apart and holding the numbers in your head.
The trade was the smallest thing on the card. An agent browses by trade — "who shoots listings around here" — and the trade was 12px of muted grey under the name, with no grouping by it anywhere, though the trade filter already grouped trades by moment in the transaction.
Eleven stars in the accent. The rating star was fill-primary-ink on every row, which made the accent the most repeated colour on a page whose only primary action is the Create button in the header.
A vendor nobody had reviewed scored zero. Curtis Wend showed a filled star at "0.0 (0)". That reads worse than showing nothing at all.
"Saved first" was claimed and never shown. The count line ended with "· saved first" and three vendors were saved, but the card gave no mark: the bookmark glyph sat at the same weight either way.
Four badges of equal weight. "Insured", "Licence NJ-LK-4471", "17 jobs" and "Covers 25 mi", all the same grey. Two restated the figures directly above them, and a vendor with 0 jobs was badged exactly like one with 31.
The business name was thrown away. A provider's title is their company — Hollis Lock & Key, Brightline Detail — and the card overrode it with the trade's professional title, so every locksmith read "Locksmith" and nobody's business appeared anywhere.
Door access was missing.has_lockbox_key and mls_boards answer whether a vendor can get into the house on their own, which on a showings marketplace is a hiring fact. The card never mentioned them.
A panel around three self-explanatory controls. The Filters card had a border, a heading and a slider icon wrapped around a search box and two dropdowns that each say what they do.
A spinner for loading, where every other redesigned page now uses a skeleton in the shape of what is coming.
02
What it is now
A shelf per trade. An agent does not browse people, they browse a trade: the
question is "who shoots listings around here", and only then "which of these two". So the
trade is the heading and the vendors are rows beneath it, which puts the two photographers
next to each other where they can actually be compared. The shelves run in transaction order
— everything you need before it lists, then while it is on market, then to close it out —
the same order the hire wizard's picker uses, with the moment named once above its shelves
instead of repeated on every one.
After, at 1440px. Distance, rating, turnaround and rate hold the same four columns from the first row to the last.
The trade is the heading. A left rail names the trade with its mark, and says how many vendors are on the shelf when there is more than one. Ten shelves for eleven vendors is not an accident of the sample data: seeing that there is exactly one mover and exactly one title company in range is the answer to "who is even out here?"
The moment is named once. "Before it lists", "While it's on market", "Closing it out", each with the line the hire wizard uses to explain it, separated by a hairline. Three headings carry the order of the whole page.
The figures are columns. Each row is a fixed grid, so distance, rating, turnaround and rate sit at the same four x-positions on every row on the page, and a column of prices adds up by eye. The Hire button and the bookmark hold their own columns too, which is what stops the right edge going ragged when one vendor is Gina and the next is Priyanka.
Nothing in the list is in the accent. Eleven vendors are eleven candidates and none of them is the page's primary action. The star that carried fill-primary-ink is muted, and the units — mi, h, $ — do the labelling the icons were doing.
Bio and door access sit below the figures. Keeping the vendor's own line about their work out of the grid is what lets the grid stay aligned. It runs as one truncated line under the row, with the lockbox and MLS marks ahead of it.
The controls sit on the page. No panel, no heading, no icon. "Clear filters" moved down beside the "Saved only" pill, where the filters are.
Loading is skeleton shelves shaped like the shelves they stand in for, so the page does not jump when the vendors land.
A phone wraps rather than stacks. Below the breakpoint the four figures fold into one wrapped row instead of four full-width lines. The wrapper renders as display: contents at the breakpoint so its children become grid items again, the same trick the tab strip uses on My Opportunities.
03
What a row says now
Moving the trade into the heading freed the two lines that were spending
themselves on it, and three facts already in the database went on the page for the first
time.
The business name. Kovacs Interiors Photography, Hollis Lock & Key, Wend Brothers Moving. The trade's professional title is the shelf heading now, so the row can name who you would actually be writing the cheque to.
Saved is visible. A mark beside the name and a heavier border on the card. The list was already ordered saved-first within each shelf; this is what makes that ordering something you can see rather than something the count line asserts.
"No reviews" instead of 0.0. A vendor nobody has rated says so, with no star, rather than rendering a zero score.
Lockbox key and MLS boards. Stated when true and silent otherwise. An empty list means we were never told, never "no access", and most trades never touch an MLS — so it is never drawn as a check somebody failed.
Insurance and licence read as text, on the same line as the business, rather than as two of four identical grey badges. The job count and service radius came off entirely: the first restated the reviews, the second restated the distance.
Light. Every surface and hairline comes from the same tokens, so the saved row's heavier border reads in both themes without a second definition.Phone. The four figures wrap into one row instead of stacking into four, which is what keeps the page from getting taller than the one it replaced.
04
What it measures
Same eleven vendors, same demo account, measured on the full page.
Width
Before
After the shelf pass
1440px
2,891px
1,842px
390px
3,715px
3,228px
Verified in both themes at 1440px and 390px on the demo account, which is the
only account with vendors in range. Typecheck and production build both clean.
05
The coverage map
Nobody hires a cleaner because of where they sleep. A pin cloud of vendor home
ZIPs would have been decoration, and the question a directory actually has to answer is
reach: does this person travel far enough to get to my listing? So the map is a coverage
map. Highlighting a row draws that vendor's service radius over the agent's own base.
Hovering a locksmith. The overlay states the conclusion; the circle is the evidence for it.
The map says the answer in words. "Teddy covers you, with 12 miles to spare", or "stops 4 miles short of you". A 25-mile circle over a viewport this size is a wash of colour rather than a readable edge, so leaving the reader to infer coverage from geometry would have failed exactly when it mattered. The sentence is computed from the distance and the radius the row already carries.
The two surfaces point at each other. Hovering a row lights its pin, hovering a pin lights its row, and clicking a pin selects that row and scrolls it into view. The same relationship the board has with its map.
No new colour and no legend panel. Pins are hollow rings, the shape channel the board already uses for provider work, and their hue is the trade's moment family. That is the same before / on market / closing split the sections are grouped by, so each section heading carries its own hue as a swatch and the sections double as the legend.
The fit is the pins and your base, not the search ring. The column is far taller than it is wide and fitBounds solves for the tighter axis, so including a 50-mile-wide circle zoomed out until the vendors were one unreadable clump. The ring stays drawn and runs off-frame; the count line states the radius in words.
The service radius came off the row. It was on the business line as "covers 20 mi" until the map started drawing it, at which point the row was saying the same thing twice and the extra width was pushing the business name into an ellipsis.
Light. The coverage circle takes the highlighted vendor's own family hue, so it never needs a colour of its own.
06
Coverage gaps
A directory that lists who exists can only show an absence by drawing nothing.
If there is no handyman within range, the old page simply had no handyman shelf, and the
agent had no way to notice. A trade with nobody in it now keeps its place in the transaction
order as an empty shelf, with the one useful action on it.
Deep clean and Handyman have nobody in range, and say so where their vendors would have been. Captured with a 15-mile step added to the radius control for the purpose: every sample vendor sits between 11 and 19 miles out, so the shipped steps of 10, 25, 50 and 100 cannot produce a partial result on this data.
The count line carries it. "9 vendors across 8 trades within 15 miles · 2 trades with nobody", and "· every trade covered" when there are none.
Gaps are suppressed under a filter. A trade you filtered away is not a hole in the market.
And suppressed when nothing at all is in range. Ten identical "nobody in range" rows say worse what the empty state says well, and the empty state can offer to widen the search.
The action is to post openly, which hands the wizard the trade with nobody named. A gap in supply is the one place on this page where asking for the work is the right move.
07
One thing left open
The schema comments stripe_payouts_enabled as "the one that
matters: false means this provider cannot be booked". Nothing in the application reads it —
not this page, not the hire wizard, not the checkout. So a vendor who has never finished
Stripe onboarding is offered with a Hire button everywhere, and finds out later.
It was left out of this pass on purpose. Surfacing it only in the directory
would imply a guarantee the rest of the app does not keep, and it is a question about the
booking path rather than about how vendors are listed. It wants its own change.
08
Files
File
What changed
src/components/VendorShelf.tsx
New. Shelves by trade in transaction order, with the moment named once above its shelves and each vendor a fixed-grid row whose figures align down the page.
src/pages/Contractors.tsx
Renders the shelf; filters lost their panel; the spinner became skeleton shelves; the count line reports trades as well as vendors.
src/components/VendorMap.tsx
New. The coverage map: hollow-ring pins in the trade's moment hue, the agent's base, the dashed search ring, and the highlighted vendor's service radius drawn as a circle.
src/components/ProviderCard.tsx
Keeps the hire wizard's selectable shortlist and loses the onHire prop, which the directory was its only user of.