Case study
Building a marketing website from scratch with Claude Code
A leadership consultant and author needed a credibility site: one long page that a separate paid funnel, on a different domain, could point at. It was built from scratch as hand-written HTML, CSS and vanilla JavaScript, with no framework and no build step, by Kenneth Villar of BrewedOps working with Claude Code, the terminal coding tool from Anthropic. The repository carries 100 commits and no package.json at all.
What follows is the build log rather than the brochure: the stack, how the work split between a person and a model, four bugs with the wrong answers included, and what was cut.
In short
- One long marketing page for a professional-services practice: 18 sections and 26,988 pixels of scroll at a 1,440 pixel viewport, plus 7 supporting pages.
- Hand-written HTML, CSS and vanilla JavaScript: 9,310 lines, of which the stylesheet is 6,190. With no
package.json, there are zero npm dependencies. - The motion layer went on as CDN scripts over static HTML rather than by converting to a framework: one 256-line file that can switch itself off.
- Images ship as WebP for everything rendered, with PNG and JPG kept only as social-share and schema targets, which share crawlers do not reliably render.
- Hosting is Cloudflare Pages with no build command, chosen on a licensing term rather than a technical one.
- There is no test runner and no continuous integration. That is a gap, not a principle: a smoke test would have caught both expensive bugs here.
What problem did this site have to solve?
The site had to be the credibility surface for a leadership consultant and author whose paid offer already lived in a separate funnel on another domain. That job produced one page of 18 sections and 26,988 pixels of scroll height at a 1,440 pixel viewport.
The funnel does conversion. The website does authority.The repository README
That line settled most of the design arguments, and it carries one cost: two domains describing one practice compete for the same terms unless something names the original. So the canonical tag, og:url and og:image were repointed at the credibility site, which also fixed a broken Open Graph image the funnel domain had been 404ing.
Why build it by hand rather than use a template?
It was hand-built because the payload budget and the per-element motion were the product. A template would have been faster to stand up and would have arrived with a responsive grid somebody else had tested, which matters more than it sounds: this build needed 45 media queries and 226 uses of clamp() before the layout held everywhere. A component framework was refused too: eight static pages do not need a runtime.
- A template solves responsive behaviour on day one. Here it was earned one breakpoint at a time: 22 media queries to 45, 139 uses of
clamp()to 226, since May. - A template has an update path: somebody else patches the grid and you take the patch. A hand-written stylesheet has one maintainer.
- A template is legible to the next developer. 6,190 lines of bespoke CSS is not, and the worst bug here came from that.
The stack, and why each piece is there
The stack is hand-written HTML5, one 6,190-line stylesheet, three vanilla JavaScript files totalling 933 lines, and two CDN libraries for motion. There is no framework, no bundler and no package.json, so the npm dependency count is zero.
| Layer | Choice | Rejected | Reason |
|---|---|---|---|
| Markup | Hand-written HTML5, 8 pages | A CMS or templating engine | A CMS puts a database behind a site that changes monthly. |
| Styling | One stylesheet, 6,190 lines, 32 custom properties | Tailwind CSS or a preprocessor | No build step was the constraint, and the properties centralise the tokens. |
| Behaviour | Vanilla JavaScript, 3 files, 933 lines | A component framework | The behaviour is a drawer, a carousel, a banner and two forms. |
| Motion | GSAP 3.12.5 with ScrollTrigger, plus Lenis 1.1.13 | CSS-only animation, or the paid GSAP SplitText plugin | CSS could not sequence the reveals, and SplitText was behind the paid tier. |
| Images | WebP rendered; PNG and JPG as metadata targets | One format for both jobs | Share crawlers do not reliably render WebP, so originals stay unrequested. |
| Video | 13 MP4 files, H.264, +faststart | Animated GIF, or autoplaying everything | It moves the index forward, so playback starts before the download ends. |
| Hosting | Cloudflare Pages, no build command | The previous host on its free tier | That tier forbids commercial use. Cloudflare Pages permits it free. |
One part runs somebody else code: a GoHighLevel chat widget, webhooks for both forms, Meta Pixel and Google Analytics 4. That is 10 third-party hosts, most of them the widget alone.
The whole motion system is 256 lines, and it can switch itself off
All the scroll-driven motion lives in one 256-line file holding 18 GSAP calls and 9 distinct ScrollTrigger configurations. It opens with a guard that disables the entire layer if the visitor prefers reduced motion, or if any of the three libraries failed to load.
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduced || !window.gsap || !window.Lenis || !window.ScrollTrigger) return;Treating a failed CDN load as the same event as a reduced-motion preference is what makes the layer genuinely optional, because a third-party script will eventually not arrive. The page is three layers that each work alone: static HTML carries every word, an IntersectionObserver adds the reveal class with no library, and GSAP is the only part allowed to disappear.
opacity: 0, so a slow script left the section empty. If the lower layers are not complete on their own, the guard blanks the page.Open full sizeHow the work actually split between a person and the model
Claude Code wrote most of the lines and almost none of the decisions. Across 100 commits the model was fastest at bulk mechanical work, and reliably wrong about one thing: whether a change had worked in production. Only an executed check against the served artefact was allowed to close a bug.
The commit breakdown shows where time went: 33 feat against 28 fix, plus five each of style, refactor, redesign, perf, content and chore. Roughly as much of it was correcting the build as extending it. This is the method now used to build Kape Tools.
- Good at volume work with a clear specification: breakpoints, a tablet range between 721 and 1,199 pixels, fluid type, and the repetitive half of an accessibility pass.
- Good at throwaway diagnostics and encoding pipelines, where the job is knowing the right
awkorffmpeginvocation rather than inventing anything. - Not able to decide information architecture. Moving the About section to thirteenth of eighteen came from an argument about a specific audience, and a model with no access to that audience gives a plausible order rather than a correct one.
What went wrong, including the fixes that were wrong
Four bugs cost more than the rest of the project combined: an <img> nested inside a <video> that could never paint, an !important block at line 5,531 that silently beat three correct fixes, a form that reported success while delivering nothing, and scroll lag with two different causes.
The problem
The client opened the site on his phone and the hero was solid black. The markup was a <video> holding a <source> and an <img class="hero-photo">, swapped on mobile by display.
What was tried
CSS fixes to the display swap, more than once, against a mechanism that could not work.
What worked
Content inside a <video> is fallback content for browsers that cannot play video. No browser paints it, so hiding the video revealed nothing. Moving the <img> out to sit as a sibling fixed it, and the poster was brightened from a mean luma of 27 to 49.
The problem
Mobile buttons rendered side by side and clipped. Three commits removed width: 100% from the mobile containers, each verified live with curl against the deployed stylesheet, and the client kept returning the same broken screenshot.
What was tried
Three rounds of deleting the property that appeared responsible. All three were correct and all three were invisible.
What worked
A leftover block at styles.css:5531-5547, inside a later @media (max-width: 720px) query, carried flex-direction: row !important and width: auto !important, beating every fix made 5,000 lines above it.
curl -sL "https://…/styles.css?cb=$(date +%s)" \
| awk '/@media/ { last_media=$0 } /\.hero-actions/ { print "---",last_media,"---";print;for(i=0;i<4;i++){getline;print} }'
The problem
The referral form had been recorded as wired and verified live in an earlier session. It was failing silently on every submission, while showing the visitor a confirmation panel.
What was tried
The handler posted to the CRM inbound webhook as Content-Type: text/plain, a deliberate trick to avoid a CORS preflight.
What worked
application/json succeeded, text/plain was rejected with "Error: Request has invalid data: Body payload", and an OPTIONS preflight returned Access-Control-Allow-Origin: *. The preflight had never been a problem, and the trick invented to dodge it was the sole cause.
That process failure is more useful than the fix: the earlier check confirmed the webhook URL was in the source, never that the CRM parsed the body, and holding a Certified GHL Admin credential on that CRM helped not at all. Re-verification ran with the webhook blocked at the network level, so nothing junk reached a live system.
The problem
Scroll lag, reported twice by a third party, months apart.
What was tried
Configuration only: smooth-scroll duration 1.15 to 0.7, wheelMultiplier 1.0 to 1.1, touchMultiplier 1.2 to 1.5. That was right, because the complaint was the page continuing to move after the wheel stopped rather than dropped frames.
What worked
The second round measured first. The transfer was 1.37 MB, so weight was not the cause. It was backdrop-filter: blur(18px) saturate(140%) on a nav already 92% opaque, re-blurring full-width content every frame for no visible gain.
The same habit caught a layout bug nothing else would. The full-bleed idiom overshoots on mobile because 100vw includes the scrollbar gutter: driven with Playwright at 390 pixels, the left edge measured -9 pixels, and +18 pixels after.
Is a hand-built static page easier for an AI crawler to read?
Yes, for one structural reason rather than any technique: every word of all 18 sections is already in the HTML the server sends. The document is 74,160 bytes raw and 13,736 compressed, so a crawler that runs no JavaScript still receives the entire page.
This is the accidental advantage of having no framework. A page assembled in the browser must be prerendered before an engine that does not execute scripts can read it, and prerendering is a subsystem with its own failure modes. A static page skips that instead of solving it.
Two things were then deliberate. The robots.txt file carries explicit Allow blocks for GPTBot, ClaudeBot, PerplexityBot and Google-Extended. And the structured data is a JSON-LD @graph of five linked schema.org entities, joined by @id rather than repeated, so the page carries one description of the author, not three slightly different ones.
What shipped for accessibility and for consent
Specific criteria were targeted and specific fixes shipped, all verifiable in the code: a skip link, a global :focus-visible style, scroll-margin-top on every element with an id, FAQ aria-controls with role="region", and a mobile drawer with a real focus trap.
The drawer stores the previously focused element, cycles Tab between its first and last children, sets inert when closed, restores focus to the trigger, and closes on Escape. Four of those five traps a keyboard user rather than serving one.
The marquee got a pause button for WCAG success criterion 2.2.2, defaulting to paused under reduced motion, and type was raised to a 16 pixel floor for a stated audience of roughly 55 to 75 year olds. On consent, Meta Pixel left the inline <head> of all five pages, Google Analytics 4 was gated the same way, and Global Privacy Control suppresses the tracking and the banner itself.
Was the result any good? What the payload numbers say
The measurable result is payload discipline. The hero video went from 2.65 MB to 762 KB, a 71% reduction; the above-fold total went from roughly 3 MB to roughly 850 KB; and the first-party text payload for an 18-section page is about 63.2 KB compressed.
ffmpeg -i in.mp4 -an -c:v libx264 -crf 31 -preset veryslow -movflags +faststart out.mp4A later hero repeated that for 2.2 MB to 745 KB. Across the 11 images held in both formats, 18,654,304 bytes of originals became 601,860 bytes of WebP. Two decisions did more than any compression setting: the heaviest video is 21 MB behind preload="none" and a click, and the hero is hidden at 720 pixels and below, a 3.2 MB saving on cellular.
What was thrown away
Four things were cut and one was rejected on inspection. 21 unreferenced asset files were deleted, taking the assets directory from roughly 26 MB to 9.9 MB; an SMS opt-in page was built, then removed; a phone field was deleted from the booking form; and content-visibility: auto was refused.
content-visibility: autoon offscreen sections. The textbook fix for a 26,988 pixel page, refused because it conflicts with ScrollTrigger and causes jank.- A dedicated SMS opt-in page. A carrier proof-of-consent URL: unchecked-by-default box, frequency and rate disclosures, STOP and HELP language, audit fields on the submission. Later removed and 301 redirected.
- The phone field on the booking form. Deleted because collecting numbers before A2P and 10DLC approval is a compliance risk. It was nearly re-added as the opt-in vehicle.
- A rebuild on a mainstream CMS, quoted by an agency and deferred. The hand-built site stayed live, at a cost: one person can maintain that stylesheet.
What I would do differently
The biggest change would be adding any automated check at all. There is no test runner, no continuous integration and no package.json, and the two most expensive bugs, the !important override at line 5,531 and the form reporting success while delivering nothing, would both have been caught by the crudest smoke test.
- Check the served artefact, not the source. One assertion that a button is not clipped, and one submission asserting the CRM returned a 2xx, replaces weeks of screenshots.
- Audit the stylesheet before it reaches 6,000 lines. A periodic dump of every
!importantwould have found the dead block immediately. - Put the still image outside the
<video>from the first commit. A five-minute fix that cost several rounds of reporting, because the markup read correct. - Self-host the fonts. Three families load from Google Fonts: a third-party request in the critical path, and a visitor IP handed away.
- Ship the first state of every JavaScript-driven component in the HTML. The carousel taught this once; writing it down as a rule is why it was not learned again.
Questions people ask
How long did it take to build?
- The repository holds 100 commits between 17 May and 18 July 2026, and that is the honest answer available. It is elapsed calendar time on a project running alongside unrelated client work, not effort, and no time tracking sits behind it. A week count would be a guess.
How much does something like this cost?
- The fee for this project is a client commercial term and is not published. What drives the number can be: page count, how much motion is bespoke rather than preset, whether the media needs re-encoding, and how many compliance surfaces the forms touch. Bespoke motion costs more than a template.
Can I do this myself without knowing how to code?
- Not this specific build. Claude Code will write the CSS, but somebody has to recognise that an
<img>inside a<video>never paints, thatalign-items: stretchlooks identical towidth: 100%, and that a thank-you panel proves nothing about the CRM. The model writes code; the judgement is yours. Does Google penalise a site built with AI?
- Google penalises unhelpful content, not the tool that typed it. Its published guidance is about whether a page serves the person reading it. A hand-built static page like this one is easier for crawlers to read than most framework sites, because all 18 sections arrive in the HTML rather than after load.
What happens when the AI writes something wrong?
- It usually looks right, which is the actual problem. Here a change was confirmed live three times while an
!importantblock 5,000 lines further down the stylesheet quietly beat it, and a form was recorded as verified while failing every submission. The fix is procedural: only an executed check closes a bug.