The landing arrived as a single 545 KB HTML file. Open it and everything works: animations, dark stack plates, the whole thing spins. Look inside and you find a React wrapper, base64 resources and the markup packed into a JSON string. You cannot copy that into a project.
What was inside the bundle
The file turned out to be a self-extracting archive. The markup sat in a <script type="__bundler/template"> tag as a JSON string, next to a resource manifest where every entry had a UUID, a mime type and a compression flag. Images and fonts were base64, scripts additionally gzipped.
Unpacking that by hand makes no sense, so I wrote a short script: it reads the manifest, decompresses what is needed and writes it out. The result was 11 woff2 files, a CSS design system and a three.js web component.
Fonts: 39 rules over 11 files
The prototype pulled Nunito and Manrope from Google Fonts, and the bundle already had them split into subsets: latin, cyrillic, greek, vietnamese. There were 39 @font-face rules - one per subset-and-weight pair - for only 11 files.
Both families are variable fonts: a single file covers the whole weight range. So I collapsed the rules using a range instead of a fixed value:
@font-face {
font-family: 'Manrope';
font-weight: 400 700;
font-display: swap;
src: url('../fonts/manrope-cyrillic.woff2') format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
unicode-range matters more than it looks: the browser only downloads subsets whose characters actually appear on the page. The Russian version never fetches vietnamese, the English one never fetches cyrillic. The two most needed subsets are additionally preloaded so text does not reflow after they land.
Google Fonts is not used at all any more - all 240 KB are served from my own domain.
I kept the inline styles
The whole prototype ran on style attributes. My first instinct was to move it into classes and "do it properly". I did not, and here is why.
Sizes, grids and spacing in that kind of markup live exactly where you edit them: open the block, change the number. Moving them into classes buys you tidy CSS and costs you the link between markup and style - to fix one gap you now hunt for a rule in another file. For a landing page that changes in whole blocks, that is a bad trade.
Getting ahead of myself: I later revised this decision, and the postscript explains why.
Only what inline styles cannot express went to CSS: @font-face, palette tokens, components like buttons and cards, reveal animations and media queries. Responsive rules hook onto data attributes rather than classes:
@media (max-width: 900px) {
[data-grid] { grid-template-columns: minmax(0, 1fr) !important; }
[data-hero-art] { width: min(260px, 100%) !important; }
}
A dark theme the prototype never had
The prototype was light-only, while the site already had a theme toggle. What saved me is that the markup references variables almost everywhere: var(--color-neutral-800) instead of a literal grey. So redefining the palette is enough.
I flipped the neutral ramp: in dark mode neutral-100 became the darkest step and neutral-900 the lightest. Cards, section backgrounds and text fell into place on their own.
The accent ramp, however, must not be flipped - and that was not obvious. The contact block is a dark navy rectangle built from the accent-800 and accent-900 steps, and it should look identical in both themes. Flip the ramp and it turns pale blue in dark mode with the text disappearing into it. So in the dark palette I only redefined the two lightest accent steps, the ones used as chip backgrounds.
Five places needed more than the ramp could give; those got their own rules. Tech icons also needed a white plate behind them: they are dark SVGs and simply vanished on a dark background.
A 3D block without a bundler
In the stack section the prototype drew a pile of layers in WebGL - a custom element on three.js that pulled the library from an external CDN at runtime.
I vendored the library and pass its path as an attribute, but kept the import dynamic: those 660 KB only load once the block is about to render.
const THREE = await import(this.dataset.three || "/js/vendor/three.module.min.js");
Layer names come from a data-layers attribute so the block gets translated with the rest of the site. On narrow screens, without WebGL2 and under prefers-reduced-motion it draws a flat list of layers instead of a 3D scene.
The two traps that cost the most time
The stack drifted out of frame. The component had logic to keep labels clear: they sit on the right, and if the geometry creeps under them, slide it left. The catch is that a rotated stack is horizontally much wider than its own width - not W but W·cos + D·sin. It did not fit the 60% of the canvas it was given, the slide hit its hard limit, and the left edge of the plates ended up outside the camera frustum. On screen that reads as a clean vertical cut. I scaled the stack down and added a left-edge check that runs before the label-driven slide.
Horizontal scroll on phones. The hero has a decorative glow - a 560px circle pushed 180px past the section edge. That is exactly how much the page was stretching. You would think body { overflow-x: hidden } handles it, but it does not: per spec that value propagates to the viewport, and body itself stays visible and clips nothing.
A wrapper around the content with overflow-x: clip fixed it. I deliberately left the header outside the wrapper: an element with overflow becomes a scroll container and breaks position: sticky.
Where it landed
The landing renders server-side from a translations array, assets are served straight from public bypassing Vite, and the front end has no external dependencies left. Dark theme, two languages, a working 3D block and no horizontal scroll at 320 pixels.
The main takeaway is not about code: porting a prototype is not copying markup, it is deciding which of someone else's choices to keep and which to rewrite for your own project.
Postscript: I removed them after all
Some time after launch I moved all the markup from attributes into classes. The arguments in the section above did not stop being true, they just stopped outweighing the alternative.
What changed. Inline wins while blocks are all different: open it, change a number, done. But as the page grew, the same sets of rules started repeating. Five sections with an identical heading are five identical seventy-character lines. Changing the heading size went from "fix it in one place" to "do not forget the fifth one".
The second part of the site finished the argument. Once a blog appeared with its own pages and a shared header, the repeated chunks would have been duplicated a third time. That is exactly the point where "markup and style stay close" turns into "copy and paste across three files".
The dynamic case that had been holding me back also resolved itself. The bullet colour in an experience card varies, and substituting it into the attribute felt like the only option. A modifier with a local variable was enough:
.exp-card--accent { --job-color: var(--color-accent); }
.exp-card--accent-2 { --job-color: var(--color-accent-2-500); }
.exp-points .bullet { background: var(--job-color); }
The markup keeps a class and there are no computed styles left at all.
I did not do the move by eye. I took full page screenshots before the edits - three widths, light and dark theme - and compared them pixel by pixel afterwards. That immediately caught a loss: the project screenshots lost their rounded corners and shadow, because I removed those from the markup and forgot to add them to the class. On the difference image it showed up as a red outline exactly around the two images.
The remaining discrepancies were confined to the band with the 3D block. Testing that was simple: I took two frames of the very same code. They differed by the same amount, so it was WebGL rotating over time rather than the refactor.
The takeaway matters more to me than the decision itself: both states were right at their own time. The prototype did not know it would grow into a site with a blog, and inline was an honest choice for a prototype. The mistake would have been refusing to revisit it once the conditions changed.