If every page should use the same shell, loading pattern, and width policy, consistency should be checked instead of remembered.
Product Polish Is Usually an Unenforced Contract
Applications become visually inconsistent one reasonable page at a time. One route uses a narrow container because its first design had a form. Another adds a custom spinner because the shared loading state was hard to find. A third places its error message outside the page shell. Every local choice looks defensible, but the product gradually feels assembled rather than designed.
Coding agents accelerate this drift. They optimise the route in front of them, find nearby examples, and produce a plausible implementation. If the repository contains three competing page shells, the model treats all three as valid precedent.
A design system is not a constraint until the repository can reject a violation. I turn repeated page-level expectations into a route registry and a lint rule.
Pixel Diffs and UI Lint Answer Different Questions
A visual regression test asks whether rendered pixels changed. A UI consistency lint asks whether a page is built from the approved structural primitives.
Pixel diffs are excellent for spacing, colour, alignment, clipping, and parity with a reference. They are expensive to capture and sensitive to rendering state. They also tell me that two pages differ without telling me whether the difference is intentional.
A structural lint can cheaply prove that every parent route uses ParentPageShell, every tutor route uses TutorPageShell, and each route declares approved loading, empty, and error states. It cannot prove that the result looks good, but it eliminates whole classes of drift before rendering.
I use both. Lint proves construction. Screenshots prove presentation.
Start With a Route-Level Contract
I make the policy explicit in a committed registry:
{
"routes": {
"/parent/dashboard": {
"family": "parent",
"shell": "ParentPageShell",
"width": "full",
"loading": "ParentPageSkeleton",
"error": "PageErrorState",
"empty": "DashboardEmptyState"
},
"/tutor/students": {
"family": "tutor",
"shell": "TutorPageShell",
"width": "full",
"loading": "TutorStudentsSkeleton",
"error": "PageErrorState",
"empty": "StudentsEmptyState"
}
}
}
The registry is not a screenshot manifest or a duplicate router. It records product invariants that the router cannot express: which shell, width policy, and state components make a route feel like part of its family.
The logical unit is the route family, not the entire application. A learning experience may intentionally use an immersive shell while parent administration uses conventional full-width pages. Consistency means coherent rules within each product context, not forcing every surface into one template.
What the Lint Checks
The first version can be a repository script rather than a sophisticated ESLint plugin. It reads the router, the registry, and the page modules, then checks:
- Every in-scope route appears in the registry.
- Every registry entry maps to a real route.
- The page imports and renders the declared shell.
- Width variants come from an approved enum or component prop.
- Loading, error, and empty states use registered components.
- Exceptions include a reason instead of silently bypassing policy.
- Shared overlays and account controls use approved stacking layers.
The gate should report the product rule, not only the syntax error. I make the diagnostic carry the route, expected contract, observed implementation, and repair:
function reportViolation(input: {
route: string;
rule: string;
expected: string;
actual: string;
fix: string;
}) {
process.stderr.write([
`[ui-consistency/${input.rule}] ${input.route}`,
`Expected: ${input.expected}`,
`Actual: ${input.actual}`,
`Fix: ${input.fix}`,
].join("\n") + "\n");
process.exitCode = 1;
}
reportViolation({
route: "/tutor/students",
rule: "route-width-policy",
expected: 'TutorPageShell width="full"',
actual: 'PageContainer size="content"',
fix: 'Use TutorPageShell width="full" or register an approved exception.',
});
The resulting failure reads like a product rule:
[ui-consistency/route-width-policy] /tutor/students
Expected: TutorPageShell width="full"
Actual: PageContainer size="content"
Fix: Use TutorPageShell width="full" or register an approved exception.
This gives an agent a direct trajectory correction. It does not need to infer why the page is inconsistent or search screenshots for the intended convention.
Loading and Failure States Belong in the Contract
Teams often standardise the successful page and forget the states users spend significant time seeing. A route can use the correct shell after data loads while displaying a one-off centered spinner, unbounded skeleton, or full-page error before that point.
I therefore lint the state envelope, not just the final component. The loading state should reserve the same broad geometry as the loaded page. Errors should retain navigation and recovery actions. Empty states should use the same content width and hierarchy as populated states.
This catches a subtle form of product drift: the application looks coherent only when everything succeeds quickly.
Exceptions Must Be Explicit
Some routes should break the rule. A diagnostic, editor, game, full-screen lesson, or media canvas may need an immersive layout. The lint should support exceptions without becoming optional.
{
"/learn": {
"exception": "immersive-learning-shell",
"reason": "Course content must own the viewport without dashboard chrome"
}
}
An exception has a named category and a reason. I can review the list, detect accidental growth, and decide whether a repeated exception deserves a new route family.
Escape hatches should produce an inventory, not a blind spot.
Roll It Out as a Ratchet
On an inconsistent application, turning the full policy on at once creates hundreds of failures and guarantees an ignore list. I start by inventorying routes and marking their current state. Then I choose one family, standardise it, and make regressions blocking for that family.
The registry can carry migration status:
{
"status": "enforced",
"verifiedViewports": ["mobile", "desktop"]
}
Unmigrated routes remain visible debt. Enforced routes cannot regress. The gate becomes stricter as the migration advances.
The Registry Also Improves Review
Once the route contract exists, code review becomes more precise. A reviewer can compare the diff with the declared family policy instead of offering taste-based feedback. Browser verification can enumerate the registry and capture every enforced route at selected states and viewports. Documentation can generate a coverage table from the same file.
The registry therefore connects three layers:
- Product intent: related routes should feel coherent.
- Static enforcement: pages use approved structural primitives.
- Live evidence: rendered states remain usable and polished.
What Not to Lint
I avoid encoding every pixel or design preference in AST rules. Lint is good for discrete facts: approved component, declared variant, required state, registered exception. It is poor at subjective composition, balance, and final readability.
If a rule needs image interpretation, it belongs in visual QA. If it needs product judgment, it belongs in review. The lint should own the deterministic middle.
The Rule
Whenever I repeat “all pages in this area should use the same…” during review, I ask whether the expectation can become a finite registry field and a deterministic check.
UI consistency compounds when the intended structure is declared once and every future route is forced to acknowledge it. That is how polish stops depending on memory and becomes a property of the repository.

