Kape Tools

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.

The two-domain split: a credibility site owning the long-form content and the canonical tag, a separate funnel, an arrow between them.
Two domains about one practice are two candidates for one search result, until one of them is declared the original.Open full size

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.

Every rejected alternative below was a real candidate.
LayerChoiceRejectedReason
MarkupHand-written HTML5, 8 pagesA CMS or templating engineA CMS puts a database behind a site that changes monthly.
StylingOne stylesheet, 6,190 lines, 32 custom propertiesTailwind CSS or a preprocessorNo build step was the constraint, and the properties centralise the tokens.
BehaviourVanilla JavaScript, 3 files, 933 linesA component frameworkThe behaviour is a drawer, a carousel, a banner and two forms.
MotionGSAP 3.12.5 with ScrollTrigger, plus Lenis 1.1.13CSS-only animation, or the paid GSAP SplitText pluginCSS could not sequence the reveals, and SplitText was behind the paid tier.
ImagesWebP rendered; PNG and JPG as metadata targetsOne format for both jobsShare crawlers do not reliably render WebP, so originals stay unrequested.
Video13 MP4 files, H.264, +faststartAnimated GIF, or autoplaying everythingIt moves the index forward, so playback starts before the download ends.
HostingCloudflare Pages, no build commandThe previous host on its free tierThat 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.

The first two lines of the animation layer
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.

Three stacked layers: static HTML, an IntersectionObserver reveal, then GSAP and Lenis, with a guard cutting off the topmost only.
Learned twice: the carousel also shipped its slides at 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 size

How 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 awk or ffmpeg invocation 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.

A hand-drawn comparison: an img nested inside a video, marked as never painting, beside the same img as a sibling.
The broken version is valid HTML and passes every validator. No CSS on either element reaches the bug.Open full size

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.

Dump a selector with its enclosing media query, in cascade order
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} }'
A hand-drawn stylesheet as a tall column: three correct fixes near the top, one !important block far below overriding them.
Verifying that a change reached production proves it is there. It does not prove it is winning.Open full size

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.

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.

A budget diagram of the hero video re-encode, the WebP conversion, and the tiny block of first-party HTML, CSS and JavaScript.
The first-party code is the smallest block on the chart. Arguing about script size while one video sits at 2.65 MB optimises the wrong thing by two orders of magnitude.Open full size
The re-encode, applied to every autoplaying video on the site
ffmpeg -i in.mp4 -an -c:v libx264 -crf 31 -preset veryslow -movflags +faststart out.mp4

A 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: auto on 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.

  1. 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.
  2. Audit the stylesheet before it reaches 6,000 lines. A periodic dump of every !important would have found the dead block immediately.
  3. 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.
  4. Self-host the fonts. Three families load from Google Fonts: a third-party request in the critical path, and a visitor IP handed away.
  5. 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, that align-items: stretch looks identical to width: 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 !important block 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.