← Back to Blog

I Retrofitted A New Design Onto A Live 106-Page Site Without Touching Its SEO. Eleven Things Broke, And Two Of Them Were My Fault.

· 15 min read Precision measuring instruments laid out on a bone-white studio surface: brushed steel calipers, a stack of gauge blocks, a machinist square and a dial indicator, spaced generously apart in soft overhead light.

A while back I took apart seventeen high-end real estate websites and wrote down the seven numbers that make them look expensive: light heading weights, tight display leading, a bone ground instead of white, charcoal instead of black, one muted accent, an enormous spacing token, and three easing curves.

Then I did the obvious next thing, which was apply them to a real site.

Not a greenfield build. A live Eleventy site with 106 pages, three locales, 556 JSON-LD blocks and 23 machine-readable endpoints, whose whole competitive position rests on being legible to search engines and answer engines. The constraint was absolute: the presentation could change, and the machine-readable surface could not.

Applying the design took an afternoon. Finding out what it broke took considerably longer, and that part turned out to be the useful part. Here is the whole list, including the two failures I caused myself.

First, the architecture decision that made all of it cheap

Before anything, I looked at how the existing stylesheet handled theming. It turned out to switch themes by redefining the same CSS custom property names under a [data-theme="light"] selector.

That is the difference between a redesign being one file and being a month. Because the names were already the interface, I could write a single new stylesheet, load it after the existing one, redefine those same names, and re-skin every component on the site without rewriting a single component rule. Reverting is deleting one <link> tag.

Check for this before you scope the work. If a site themes through tokens, a full visual retheme can be additive and reversible. If it hardcodes colors per component, it cannot, and you should price the job an order of magnitude higher. Which brings us straight to the first failure, because this site did both.

Build the proof before you build the design

"It is presentation-only" is a claim. Claims about a site that earns money should be tests.

So before touching any CSS I wrote about fifty lines that walk a built site and record its machine-readable surface: every page URL, every JSON-LD block and the full set of @type values inside it however deeply nested, every canonical, every hreflang alternate, the meta tags, whether each page still has an h1, and how many images lack alt. Capture before, capture after, diff. Regressions fail the run. Additions are reported but never fail.

Baseline: 106 pages, 23 endpoints, 556 JSON-LD blocks, 33 distinct schema types, 0 unparseable blocks, 0 images missing alt.

I ran that diff after every single change below. It never moved. That is the only reason I can say "the SEO is untouched" as a fact rather than a hope.

If you take one thing from this post, take this one. It is fifty lines, and it converts the scariest part of the job into a green check.

The free win: check your fonts before you buy any

The measured spec calls for display type at weight 300 to 400. The site self-hosted its display face and declared it at 700, 800 and 900 only.

The four font files on disk turned out to be variable fonts covering the entire 400 to 900 range. The vendor serves the identical files for weight 400. Only the @font-face declarations were missing. Three new declarations pointing at files that were already there delivered the single highest-impact change in the whole design for zero additional bytes downloaded.

Before you conclude you cannot afford a lighter weight, check whether you already have it.

The images that were on disk and never served

Most images were written as a bare <img src="/images/x.jpg">. WebP versions of those exact images existed on disk and were never served, because nothing referenced them.

Bytes
JPEG, as served 4,671,429 4.46 MB
Best format available 1,894,448 1.81 MB
Reduction 2,776,981 59%

I generated AVIF for the 31 images being served as JPEG, then wrote one build transform that wraps a bare <img> in a <picture> with AVIF and WebP sources, only when those files exist on disk, leaving the <img> tag and therefore its alt, dimensions and loading attribute completely untouched. One reviewable change instead of edits to thirty templates.

Now the failures.

Failure 1: sixty-four rules that hardcode colors instead of using tokens

I fixed the footer. It had no effect. I looked closer: the existing stylesheet set it with [data-theme="light"] .site-footer, specificity (0,2,0), and my rule was a bare class at (0,1,0).

So I fixed the footer properly. Then the same thing happened with form inputs. Then again with prose headings on interior pages.

Three rounds in, I stopped patching and counted. The stylesheet contained 64 rules under [data-theme="light"] that hardcode literal color values rather than referencing tokens: one particular gold, a near-black, a mid gray, several light grays, and pure white. Every one of them outranks a token-based override, because attribute-plus-class beats class.

That is the actual shape of the problem, and one remap block at matching specificity fixed all of them at once.

The lesson is diagnostic, not technical. When the same class of bug surfaces three times in three different components, stop fixing components. Count the instances and find the pattern. I lost three deploy cycles learning that in a single afternoon.

Failure 2: one color token cannot be both ink and fill

This is the one worth the price of admission.

The measured palette wants a muted accent. To use that accent as text on a bone ground and clear the 4.5:1 minimum, I had to darken it. Fine. That took accent text to 6.15:1.

Then a full sweep found the header button at 2.88:1. And the skip link. And the cookie buttons. And the plan-card buttons in all three locales.

Every one of them used that same token as a background with near-black text on top. Darkening the token for legibility as ink had simultaneously made it useless as a fill.

Worse, the sweep found the site's primary button was white text on the original gold at 4.0:1, which is below AA. That was every primary call to action on the site, and it had been true long before I arrived.

The fix is two tokens, not one: an ink accent dark enough to read on a light ground, and a fill accent light enough that dark text reads on it. They cannot be the same value.

For the primary button I went further and made it charcoal rather than any gold. The accent was already carrying eyebrows, stat numerals, rules, links and hover states. Putting it on every button too spends the one-accent budget the entire design rests on. Ink means "do this", accent means "notice this", and the button went from 4.0:1 to 16.5:1 on the way.

Failure 3: compute contrast with alpha compositing, or you will chase ghosts

My first automated sweep reported seven failures on pill buttons and outline buttons.

All seven were wrong. The script had read rgba(179, 146, 78, 0.14) off the computed style and treated it as an opaque color. The actual rendered background is that wash composited over whatever is behind it, which was very nearly the page ground, and the real ratios passed comfortably.

A contrast checker that does not walk up the ancestor chain compositing translucent layers until it hits an opaque one will invent failures that do not exist and, worse, will miss real ones where a translucent overlay darkens something. Both directions matter.

Failure 4: fix the ground, not the text

One component nested three darkening layers: a section background, then a panel background, then a slightly darker inner panel, then a translucent zebra wash on alternating table rows.

Muted body text on that innermost ground measured 4.06:1. My instinct was to darken the text in that component. I did, and it moved exactly the cells I had named while every paragraph, table header and label in the same panel kept failing.

Raising the inner ground by a few points fixed everything sitting on it in one line.

When several different elements fail against the same background, the background is the bug.

Failure 5 (mine): a late :root declaration silently beats every earlier theme block

Here is the one that genuinely got me.

Fixing failure 4 meant re-declaring a token in :root. I put that declaration near the bottom of my stylesheet, which is where I had been appending everything.

:root and [data-theme="dark"] have the same specificity, (0,1,0). When the dark attribute is set, both selectors match the same element, so source order decides. My late :root declaration sat after the dark palette and overrode it.

Dark mode got a bone-colored panel with bone-colored text on it. 1.1:1. Eighty-one contrast failures on one page.

I only caught it because I audited dark mode separately instead of assuming it followed light. Any token you re-declare late needs its theme overrides restated below it, and any themed site needs its themes tested independently.

Failure 6 (also mine): a blanket color override has to enumerate its dark contexts

To fix hardcoded inline colors I wrote a broad rule: any element with a particular inline color gets the corrected token.

It worked, and it also hit a small credential band that sits on a near-black inline background. Correcting its text for a light ground turned it into dark text on a dark bar at 2.88:1.

CSS cannot ask "what color is behind me". A blanket color rule must name every dark surface it should skip. There is no clever selector for this, only a list, and you find the list by measuring.

Failure 7: do not globally style an element you did not author

I gave blockquote the display-serif pull-quote treatment from the measured spec: large, light, tight, narrow measure.

The site used blockquote for customer reviews. Some of those reviews run past 700 characters. They rendered at 30px display serif and were close to unreadable.

Pull-quote styling is for pull-quotes. A testimonial is body copy with an attribution. Before styling a bare element selector on a codebase you did not write, go and look at what actually uses that element.

Failure 8: things can hide from grep

I searched the built HTML for emoji characters and got zero results. Twice. I concluded, in writing, that the page had none.

It has seven. They ship as numeric HTML entities, so a search for the literal pictograph never matches. I only found them by looking at a native-resolution screenshot instead of a scaled-down one.

Two habits from that. Search for entity forms as well as literal characters. And review screenshots at full resolution, because downscaling turns fine-line SVG icons into things that look like emoji and color emoji into things that look like icons. I got both of those backwards at different points in the same afternoon.

Failure 9: full-page screenshots do not trigger lazy loading

A full-page capture showed a grid of cards with empty gray rectangles where the photographs should be. It looked exactly like a broken image path.

Nothing was broken. loading="lazy" images below the fold never entered the viewport, so they never loaded, so they never painted. Scroll the page in steps first, wait, then capture. Images inside collapsed <details> elements legitimately never load at all, and that is correct behavior rather than a bug to chase.

Failure 10: two parallel API calls, one silent timeout

A rate widget rendered a placeholder instead of a number. The API worked when called directly.

The data file fired two requests concurrently through Promise.allSettled. Intermittently one of them timed out while the other succeeded, so one value rendered and the other showed the fallback, which looks like a template bug rather than a network one. Sequential with a single retry costs about a second at build time and is reliable.

Failure 11: an environment variable with no fallback

The same widget had a second, independent cause. The data file read its key from process.env only. The host had that variable set, so production was always fine, and every local build silently rendered the fallback placeholder for every value.

If a build-time secret has a non-secret fallback available in the repo already, use it. A local build that quietly renders wrong is worse than one that fails.

The one thing I got right first time, and why

I wanted reveal-on-scroll, which the reference sites all buy a JavaScript library for. Modern CSS does it natively with animation-timeline: view().

The obvious implementation sets opacity: 0 and animates to 1. In a browser without support, that is a page where the content never appears.

So the entire block sits inside @supports (animation-timeline: view()). A browser that has not confirmed it can run the animation never sees the opacity: 0 rule at all and renders everything normally. I verified afterwards that zero elements were stranded below full opacity anywhere in the viewport.

A progressive enhancement that can hide your content is not an enhancement. Guard the hiding, not just the animating. Pair it with a prefers-reduced-motion block that switches the whole thing off.

The scoreboard

The site-wide sweep across seven pages and roughly 1,900 text nodes:

Failures
First sweep 68
After the token remap 38
After splitting ink from fill 14
After fixing the nested ground 3
Final, light theme 0
Dark theme, first audit 81
Dark theme, final 0

Zero contrast failures in both themes across all three locales. Zero regressions in the machine-readable diff, every single time: still 106 pages, 23 endpoints, 556 JSON-LD blocks, 33 schema types, 0 images missing alt.

What to take away

  1. Check how the site themes before you scope the work. Token-based means additive and reversible. Hardcoded means a rewrite.
  2. Write the proof harness before the design. Fifty lines turns "presentation-only" from a promise into a test you can run after every change.
  3. Check whether your fonts already contain the weights you want. Variable files often do.
  4. Check whether modern image formats are on disk but unreferenced. Mine were, and serving them cut 59%.
  5. When the same bug appears three times, count the instances. Fix the pattern, not the component.
  6. Never let one color token be both ink and fill. It is the single most expensive mistake on this list.
  7. Composite alpha when you measure contrast, or you will fix things that were never broken.
  8. When several elements fail on the same background, fix the background.
  9. Re-declared tokens beat earlier theme blocks. Restate theme overrides below any late declaration, and audit each theme separately.
  10. Blanket color overrides must enumerate their dark contexts.
  11. Guard the hiding, not just the animating.

None of this required a framework, a dependency, or a build step beyond what the site already had. It required measuring instead of looking, which is slower for an afternoon and much faster after that.

If you are looking at your own site wondering whether any of this applies, the contrast sweep is the cheapest place to start, and it is the one that found a real AA failure on every primary button of a site that had been live for months. For the full build-it-yourself version of this, from a blank domain to a working accessible site for about a hundred dollars, that is what The $97 Launch covers end to end.

Take the whole thing as a file your AI can use

All eleven failure modes, the reconnaissance steps, the proof-harness spec and the definition of done are condensed into a single LLM-ready file:

design-retrofit-audit-kit.md

Paste it into Claude, ChatGPT, Gemini or any coding agent and add one line: "retrofit this design onto my site at ./src, work through the kit in order." It is structured as five phases (reconnaissance, build the proof harness, apply, the eleven failure modes, then a definition of done the assistant is told not to declare victory before), with the grep commands for finding hardcoded literals and inline styles included.

Its companion carries the design values themselves, measured from the seventeen-site teardown: premium-design-system-spec.md, from the previous post.

Both are free, need no signup, and you are welcome to adapt them.

Two further files cover measuring somebody else's site in the first place: parse-a-site-starter-kit.md (beginner, with the full measured reference dataset) and parse-a-site-advanced-pipeline.md (the fetch cascade, extraction spec and verification harnesses).

Fact-check notes and sources

  • Contrast thresholds are from WCAG 2.2: 4.5:1 for normal text and 3:1 for large text (defined as 24px, or 18.66px at 700 weight or heavier) under Success Criterion 1.4.3 Contrast (Minimum); 3:1 for user interface components and graphical objects under Success Criterion 1.4.11 Non-text Contrast. Input borders fall under 1.4.11.
  • Contrast ratios quoted here were computed with the standard WCAG relative-luminance formula against backgrounds composited through the ancestor chain, not read off a design file. Figures are for the specific color pairs on the specific site described and are not general claims.
  • Specificity behavior ([data-theme="x"] .class at (0,2,0) beating .class at (0,1,0), and :root tying with an attribute selector at (0,1,0) so source order decides) follows the CSS Cascade and Inheritance specification.
  • animation-timeline: view() is part of CSS Scroll-driven Animations. Browser support is partial at time of writing, which is the entire reason for the @supports guard described above. Verify current support before relying on it.
  • Image byte figures are the sum of file sizes on disk for the 31 images that were being served as JPEG on one site, comparing the JPEG against the smallest available format of the same image. Your ratio will differ with your source material.
  • The site described is an Eleventy site I build and maintain. It is deliberately not named, and neither is the client, the design vendor whose measured values informed the palette, nor any of the reference sites: the point is the failure modes, which are general, not any one business.
  • The baseline and final machine-readable counts (106 pages, 23 endpoints, 556 JSON-LD blocks, 33 schema types) are the actual output of the diff harness described, run on that site.

Related reading

This post is informational, not design, legal or accessibility-compliance advice. Contrast figures and specificity behavior are accurate as measured and as specified in August 2026. A passing automated contrast sweep is a floor, not proof of accessibility: it does not test keyboard operability, screen-reader semantics, focus order, or anything else a real audit covers.

← Back to Blog

Accessibility Options

Text Size
High Contrast
Reduce Motion
Reading Guide
Link Highlighting
Accessibility Statement

J.A. Watte is committed to ensuring digital accessibility for people with disabilities. This site conforms to WCAG 2.1 and 2.2 Level AA guidelines.

Measures Taken

  • Semantic HTML with proper heading hierarchy
  • ARIA labels and roles for interactive components
  • Color contrast ratios meeting WCAG AA (4.5:1)
  • Full keyboard navigation support
  • Skip navigation link
  • Visible focus indicators (3:1 contrast)
  • 44px minimum touch/click targets
  • Dark/light theme with system preference detection
  • Responsive design for all devices
  • Reduced motion support (CSS + toggle)
  • Text size customization (14px–20px)
  • Print stylesheet

Feedback

Contact: jwatte.com/contact

Full Accessibility StatementPrivacy Policy

Last updated: April 2026