Contractor CRM · Design partner · 2026
A Lead was never an entity. It was a job that hadn’t started yet.
A CRM, estimating, and field platform for roofing and exteriors, built to replace the incumbent every contractor already knows. It inherited that incumbent’s nouns — a Lead, a Schedule Entry, an Estimate Status — and each one turned out to be the same object in a different state. Every correction deleted code. Sole designer across three products on one design system.
The problem
An interface that looks like it works
Every CRM in the roofing trade shows the same nouns, because the incumbent does: a Lead, a Job, an Estimate, a Schedule Entry, a Work Order. Building against that vocabulary is the safe move. It is what customers can already read, and when you are replacing a tool people have used for a decade, legibility is worth a great deal.
It is also how you inherit somebody else’s model of the work.
Two commits describe what that cost, and the sentences are the whole section:
You could build a crew roster and then never assign a crew to anything.
The scoring model, the scoring rules, the nurture rules, the assignment rules, the aged sweep and the funnel analytics have all been configuring and operating on a permanently empty table.
Both describe software that looks like it works. Nothing errors. Nothing is red. Neither is on a bug list, because neither is a bug — the screens are all doing exactly what they were built to do. And in the second case the tests were green, which is the part worth keeping, because the commit says why: pure-function tests pass identically against an empty table. That is the blind spot that let it survive.
Neither problem is findable by looking at a screen, and neither is fixable by changing one.
The insight
One object, seen from three angles
The schema states it in the team’s own words:
“Material order”, “labor order” and “schedule entry” are not three systems. They are one object seen from three angles.
Once that lens is available it applies everywhere, and it applied three times:
| Built as an entity | Turned out to be | What the fix did |
|---|---|---|
| A backlog flag on a scheduling table | A labor order that has no dates yet | Flag deleted; the board reads the orders directly |
An estimate status column | A pure function of timestamps and adjacent records | Never built — forbidden by an architecture decision record |
A lead table, plus two route trees | A job at the lead milestone | Three tables dropped, seven engines repointed |
The third is the clearest. The commit is blunt about how thin the entity always was: it literally was the same rows — the lead list was already querying the jobs table filtered to one milestone. An entire section of the product existed to describe a state that the object was already carrying.
Nouns borrowed from the incumbent → each one is a state, not a thing → one object plus a state function → the entity, its routes and its tables are deleted → every engine points at the object that was always the real one
The output of the redesign was subtraction. That is the test I would apply to any claim of this kind: a model correction that only adds is usually a feature with a story attached.
One detail I liked more than the fix itself: when the scheduling model changed, the abandoned branch’s layout mathematics were salvaged wholesale, because — in the commit’s words — it had it right; what changed is the model underneath it. Good geometry survives a model change. That is worth knowing before you throw away a branch.
The design system
Density in a product made of money
A contractor reading an estimate holds all of this on one screen: line name, quantity, two different units, unit cost, extended cost, waste percentage, margin, a tax flag, and a section subtotal. Above that, a list of twenty jobs carrying milestone, rep, trade, contract value and dates. Density is the hard problem here, and the system answers it with rules that each name the failure they prevent.
The table is full-bleed, not a card
The list surface drops the border, radius and background it would otherwise inherit. Three reasons, and all three are user-facing before they are technical. The frame was spending 48 pixels of horizontal space on a decoration — on a table of money columns, 48 pixels is a column. It put the first column’s text on a different left edge than the heading above it, and on a dense list the left edge is the thing the eye tracks down. And the frame shipped an overflow rule, which quietly turned a decorative box into a scroll container that broke the sticky header: every overflow value except visible is a box the sticky header can pin to instead of the viewport.
One gutter number, mirrored
The page gutter is declared once per device size and then pushed into the outermost table cells, with the same value given to the sticky bar and the footer as padding rather than as a negative margin. It is the alignment rule from above, made durable — one number, so a change moves the whole page together instead of drifting one strip out of line.
Chrome never covers an overlay
A layer scale, stated in English before it is stated in numbers: persistent page furniture must never render above a transient overlay. That is the rule that stops a sticky footer from sitting on top of the dialog asking whether you meant to delete something.
These live as executable assertions rather than as documentation, and nearly every one carries a comment naming the user-facing consequence first: rows scrolling past above their own sticky header; a fifteen-pixel band with two rows visible in it. One of them names the fix that was refused and why — compensating with a fixed offset would have hidden the problem at one scroll position and left it at every other.
A Staff designer’s job is to make good decisions survive other people’s changes.
One system, three products
The same system runs the CRM, a 3D siding visualisation tool, and the customer-facing sales experiences, across three repositories on shared databases. Convergence into a single product is the next phase and is not built yet.
The evidence that the system is doing its job is a negative, which is the only kind a reader can falsify: a feature push that closed 25 automation rules across 24 files added zero new tokens and zero new primitives. A system that absorbs a feature push without growing is the entire argument for having built one.
The decision I’d defend hardest
Making the software worse for the person using it
The estimate has no status column — its state is derived from what has actually happened to it. Which states lock it against editing was a real decision, and it was deliberately widened past what was technically required:
“Sent” is now locked too — a proposal in the customer’s hands must not change underneath them.
A sent estimate is not a signed one. Nothing was broken. Letting a rep quietly adjust a number is more convenient for the only person who logs in. It was locked anyway, because a homeowner is holding a piece of paper with a number on it. The reopen path still exists — you may change it, you may not change it quietly, and doing so voids the signature packet.
The same instinct shows up twice more. An unconfigured measurement integration refuses rather than returning plausible sample roof measurements that would have flowed straight into a real price. Dashboard tiles with nothing behind them yet read “coming soon” instead of rendering a number.
Designing for the person who is not in the room, at a cost to the person who is. The homeowner never logs in and never sees this product. They are the one holding the consequence.
Reflection
What I’d do differently, and the thing I got lucky on
I built the product off the client’s current workflows and everything they had in the incumbent tool, because that was the only workflow that existed in writing. Mid-build they changed their pipeline, their automation and their scope, and sharpened how the product should work.
We pivoted. It was not bad, and it is still inside the right parameters — but that is partly luck, and I would rather name the luck than take credit for it. The automation layer had not been written yet, so most of the change was absorbed into the feature as it was built rather than retrofitted onto a finished one. Three weeks later and it would have been rework.
The lesson is not “expect change.” Everyone expects change. It is that a client’s intended future-state workflow is not a detail you can gather later, because it dictates the automation, the data you need to capture, and how records link to each other. That is the model, and I was modelling on an incumbent’s vocabulary while the real one was still being decided.
Which reframes the three corrections above. They are not three scattered cleanups. They are one story: the downstream cost of inheriting someone else’s nouns while the actual workflow was still open. I would force that conversation before the model next time, rather than being well-timed about when it arrives.
Status
Where it actually is
Feature parity with the incumbent plus 25 of 25 automation rules, now in full end-to-end testing with in-house users — running real jobs through the whole flow to confirm data travels correctly and is captured where it should be.
Pre-release. No external users, no adoption, no revenue, no outcomes, and nothing on this page should be read as implying any. A system built, not a result proven. The product and the client are unnamed because they haven’t launched, and the screenshots are taken inside a fabricated demo organisation — every name, address and dollar figure in them is invented.
What this one proves
That the model work holds up inside somebody else’s company, on somebody else’s roadmap, with a partner who owns the domain and the final say on what a roofing job is. And that a design system can be the thing that carries it — one system across three products, with the rules written where they cannot silently rot.