jgpt-automated/bnr-power-washing
Reviewed against Rams quality heuristics: accessibility, color, typography, spacing, components, motion, UX, and craft.
10 files reviewed·July 21, 2026
Elevated
Accessibility needs attention.
More findings
Verdict
A solid design system undone by inconsistent follow-through, tokens and reduced-motion logic exist but get ignored in the newest layers. The biggest risk is accessibility drift compounding silently across every slide, filter, and dot the team adds next.
Files Rams reviewed
gallery.html
index.html
proposals/phase2/index.html
services/commercial.html
services/driveway-concrete.html
services/house-washing.html
services/patio-deck-fence.html
services/roof-cleaning.html
services/window-cleaning.html
styles.css
Accessibility
proposals/phase2/index.html:113
Gold eyebrow and tag labels fail contrast on every light slide
The `.eyebrow` labels (e.g. "Phase I Recap", "The Strategic Position") and `.card .tag` labels (e.g. "Right Now, Today", "Tier 2") render in `var(--gold)` (#B8943F) at 11-12px on `var(--paper)`/`var(--cream)` backgrounds (#FDFCF9 / #F8F7F4). Computed contrast is ~2.7:1.
Why it matters
Every eyebrow and card tag across slides 2-8 falls well below the 4.5:1 small-text minimum and even below the 3:1 large-text floor: these micro-labels are effectively illegible for a large share of readers, and they're the first thing scanned on each slide.
Fix
Darken the gold used for text on light backgrounds while keeping the lighter gold reserved for dark slides.
.eyebrow {
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--gold);
margin-bottom: 16px;
}.eyebrow {
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: #7A5B22;
margin-bottom: 16px;
}styles.css:1
Fine-print text in ink-3 fails contrast on white surfaces
`--ink-3: #8E919A` on `--surface`/white backgrounds computes to roughly 3.2:1, below the 4.5:1 small-text minimum. It shows up repeatedly in new components: - `.reviews-link-wrap .arrow` (11px) - `.rq-finepr` (12px) - `.svc-price-detail` (12px)
Why it matters
These aren't decorative flourishes, they're the review-link arrow, form disclaimer, and price-detail copy: real reading content that low-vision users can't reliably read at these values.
Fix
Move fine-print text to a text tone with at least 4.5:1 against its background.
.reviews-link-wrap .arrow{font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--ink-3);transition:transform 200ms var(--ease)}.reviews-link-wrap .arrow{font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--ink-2);transition:transform 200ms var(--ease)}gallery.html:1
Filter buttons change look on click but never announce selection to screen readers
The six `.gx-filter` buttons ("All", "House Washing", "Driveway"...) toggle an `active` class via JS to show the current filter, but none carries `aria-pressed` or `aria-selected`. The only signal of which filter is active is a visual style change.
Why it matters
A screen reader user tabbing through the filter row hears six identical buttons with no indication of which one is currently applied, so they can't confirm their filter choice took effect.
Fix
Expose the toggle state programmatically with aria-pressed, updated in the same JS that toggles the active class.
<button class="gx-filter active" data-filter="all" type="button">All<span class="count">56</span></button><button class="gx-filter active" data-filter="all" type="button" aria-pressed="true">All<span class="count">56</span></button>index.html:1
Utility bar's aria-label hides the visible phone number from screen readers
`<a href="tel:+18324010741" aria-label="Call us">` wraps an icon and the visible text "(832) 401-0741", but the aria-label replaces that entire visible text for screen reader users, so the actual number is never announced.
Why it matters
A screen reader user hears only "Call us, link" and never gets the phone number itself, even though sighted users can read it directly off the page: an inconsistent experience for a piece of information this business leads with.
Fix
Remove the redundant aria-label so the link's own visible text (the phone number) becomes its accessible name.
<a href="tel:+18324010741" aria-label="Call us"><svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M22 16.92v3a2 2 0 01-2.18 2 19.79 19.79 0 01-8.63-3.07 19.5 19.5 0 01-6-6 19.79 19.79 0 01-3.07-8.67A2 2 0 014.11 2h3a2 2 0 012 1.72 12.84 12.84 0 00.7 2.81 2 2 0 01-.45 2.11L8.09 9.91a16 16 0 006 6l1.27-1.27a2 2 0 012.11-.45 12.84 12.84 0 002.81.7A2 2 0 0122 16.92z"/></svg> (832) 401-0741 </a><a href="tel:+18324010741"><svg class="icon" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M22 16.92v3a2 2 0 01-2.18 2 19.79 19.79 0 01-8.63-3.07 19.5 19.5 0 01-6-6 19.79 19.79 0 01-3.07-8.67A2 2 0 014.11 2h3a2 2 0 012 1.72 12.84 12.84 0 00.7 2.81 2 2 0 01-.45 2.11L8.09 9.91a16 16 0 006 6l1.27-1.27a2 2 0 012.11-.45 12.84 12.84 0 002.81.7A2 2 0 0122 16.92z"/></svg> (832) 401-0741 </a>proposals/phase2/index.html:456
Carousel dot navigation is unlabeled, unfocusable spans
The dot indicators in `#dots` are built as bare `<span>` elements with an `onclick` handler and no `aria-label`, `role`, or keyboard affordance: only the `‹`/`›` buttons are real, labeled controls.
Why it matters
Keyboard and screen-reader users can't jump directly to a slide via the dots; they only learn a dot exists once a mouse hovers it, and none of them announce which slide they represent.
Fix
Build the dot indicators as real buttons with a descriptive accessible name.
for (let i = 0; i < total; i++) {
const d = document.createElement('span');
if (i === 0) d.classList.add('active');
d.onclick = () => goTo(i);
dotsEl.appendChild(d);
}for (let i = 0; i < total; i++) {
const d = document.createElement('button');
d.type = 'button';
d.setAttribute('aria-label', `Go to slide ${i + 1}`);
if (i === 0) d.classList.add('active');
d.onclick = () => goTo(i);
dotsEl.appendChild(d);
}Motion
proposals/phase2/index.html:349
Image placeholder pulse animation ignores prefers-reduced-motion
`.ha-img-placeholder` runs `animation: ha-img-pulse 1.5s ease-in-out infinite` with no `prefers-reduced-motion` guard anywhere in the stylesheet.
Why it matters
Anyone with vestibular sensitivity who has set reduced motion at the OS level still gets a looping opacity pulse on every placeholder image, with no way to opt out.
Fix
Disable looping animation for users who have requested reduced motion.
.ha-img-placeholder{display:flex;align-items:center;justify-content:center;flex-direction:column;gap:6px;background:#f4f4f5;border:1px dashed #d4d4d8;border-radius:8px;color:#71717a;font-size:12px;font-family:system-ui,sans-serif;min-height:80px;padding:16px;box-sizing:border-box;animation:ha-img-pulse 1.5s ease-in-out infinite}.ha-img-placeholder{display:flex;align-items:center;justify-content:center;flex-direction:column;gap:6px;background:#f4f4f5;border:1px dashed #d4d4d8;border-radius:8px;color:#71717a;font-size:12px;font-family:system-ui,sans-serif;min-height:80px;padding:16px;box-sizing:border-box;animation:ha-img-pulse 1.5s ease-in-out infinite}
@media (prefers-reduced-motion: reduce){.ha-img-placeholder{animation:none}}Typography
Color
Spacing
Components
UX
Craft
Working well
- The single `@media (prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:0ms !important;transition-duration:0ms !important}}` rule near the base of the file is smart because it's universal: every animation added later in the V2 and service-page passes (the coverage pin pulse, the gallery blur-up, the AI-quote spinner) is automatically covered without anyone having to remember to add a reduced-motion branch to each new keyframe.
- The hero's three CTAs are correctly differentiated by weight, not just color: "Call (832) 401-0741" is a filled navy .btn-primary, "Request a Quote" is a bordered .btn-ghost, and "Or get an AI quote in 60 seconds" is a plain text link with an arrow. That's a real three-tier hierarchy: primary action, secondary action, tertiary escape hatch: and it reads instantly without anyone needing to think about it.
- The before/after slider (`.ba-range`) is built on a real native `<input type=range>` under an invisible layer, with a `:focus-visible` ring that draws a visible outline around the knob (`box-shadow:0 0 0 4px #fff,0 0 0 7px var(--blue)`) instead of the fake custom-drag-handle pattern most sliders like this ship with: keyboard and screen-reader users get full control for free.
- The nav's own CSS comment says it best: "Nav buttons: PEER pills: same height, padding, radius. Only color/role differs." The call button (.btn-outline-gold) and the Instant Quote button (.btn-primary) share identical geometry and differ only in fill and role, which is exactly how a button system should scale: one shape, meaning carried by color and weight alone.
Scored July 21, 2026 with Rams Engine v0.0.2 · Engine changelog
Score your own repo.
Free on public repos, no account. The same engine that scored this page reads your UI code and mints a score page like this one.
Public repos only. The full engine reviews the UI code and mints a public score page — we email you the link too. Already-scored repos open instantly.
Or get this review on every PR.