Case Study 22 min read

Shipping v2.13: Lighthouse, Turnstile, and Five Months of Not Being Done

Vector v2.13 is live: I tore out WakaTime, built DevActivity from Orion data, launched a blog, and fought with Cloudflare Turnstile. Here's the journey.

Lorenzo Lopez Villalobos profile picture
Lorenzo Lopez Villalobos
Owner & SR. Developer
March 5, 2026 22 min read
title:Shipping v2.13: Lighthouse, Turnstile, and Five Months of Not Being Done

Reading Mode

Font Size

Line Spacing

When I wrote [Road to Vector 2.0](/blog/road-to-vector-2-0) back in February, the site had just gone through its first serious overhaul. New framework, new design system, new deployment pipeline. I called it a progress report, not a victory lap. Turns out that was more accurate than I expected. Since that post, the site has gone through **nineteen more releases**. Not because the foundation was broken — it held up fine — but because the moment you start treating your portfolio like a real product instead of a side project, the backlog becomes infinite. Every improvement reveals three more things worth improving. This is the story of everything that happened between v2.0 and v2.16.1. --- ## Dismantling WakaTime ![](https://vectorcrcdn.sfo3.cdn.digitaloceanspaces.com/orion-vectorcr-com/images/orion-vectorcr-com-images-lorenzo-orion-stats-3476-2560.avif) I had a WakaTime integration on the site for a while. The pitch was simple: real-time coding stats, proof that I actually write code every day. Languages, hours, streaks, the whole dashboard. It worked. But it never felt like mine. WakaTime is someone else's API, someone else's rate limits, someone else's data schema. When their endpoint hiccupped, my portfolio hiccupped. And "12 hours of TypeScript this week" doesn't tell a visitor anything meaningful about what I'm building or how I work. So I killed it. Not just the widget — the entire integration. API client, types, environment variables, caching layer, chart.js dependency, CSS, i18n keys, data snapshots, documentation. **All of it.** That was v2.1.0, and it set the tone for every release that followed: if a dependency isn't earning its place, it goes. --- ## Building DevActivity from Orion ![](https://vectorcrcdn.sfo3.cdn.digitaloceanspaces.com/orion-vectorcr-com/images/orion-vectorcr-com-images-lorenzo-dev-activity-909f-2560.avif) The WakaTime replacement came from our own stack. We'd been building **Orion** — Vector's project management platform — and it already had all my development data. Every commit, every task opened and closed, every code review. Why pipe stats through a third-party service when the real story was in our own database? The DevActivity widget pulls data from Orion's public API. A GitHub-style contribution heatmap covering 52 weeks, commit patterns broken down by hour and day, task type and priority distributions via donut charts, a productivity card with streaks and velocity. The difference from WakaTime: this is **real project data**, not just "time spent in an editor." I went through several iterations on the charts. The first version used a charting library and rendered SVGs. The final version uses **pure HTML and CSS** — divs with calculated widths and background colors. Zero external dependencies. The entire component is roughly 3KB gzipped. Sometimes the simplest approach really is the right one. By v2.7.0, DevActivity had grown into a full dashboard: help modals explaining what burndown and velocity mean, an auto-refresh timer synced to Orion's cache TTL, changelog pills showing what changed between refreshes, and a server-side proxy so the API key never touches the browser. By v2.10.0, the navbar had a live countdown badge linking to the stats section, and the widget showed diff badges on refresh so you could watch your numbers move in real time. It's the section of the site I'm most proud of. It tells you something real about how I work, and every line is ours. --- ## The Blog That Almost Wasn't ![](https://vectorcrcdn.sfo3.cdn.digitaloceanspaces.com/orion-vectorcr-com/images/orion-vectorcr-com-images-lorenzo-the-blog-that-almost-wasnt-ff52-2560.avif) I'd wanted a blog since the v2.0 rebuild. It kept getting deprioritized — there was always a client project or an Orion feature or some infrastructure task that felt more urgent. The blog lived permanently in "next sprint." What tipped the scale was Orion developing a real CMS. Once it had content management with rich text editing, image handling through **Lyra** (our CDN optimization pipeline), and a public API, building the blog became a few hours of work instead of a project. The integration went live in v2.4.0: listing pages at `/blog` and `/es/blog`, detail pages with full HTML rendering, JSON-LD structured data for SEO, responsive card layouts, and 10 Playwright tests covering the entire surface. Content lives in Orion, gets served through an API endpoint, and the portfolio just renders it. No markdown in the repo. No static generation for posts. Just an API call and some Astro pages. Then came v2.4.1 — a patch release for **one byte**. The blog worked perfectly in development. In production, every API call returned a 401. I spent more time than I'd like to admit debugging the auth middleware before discovering that the API key in the encrypted env file had an extra trailing character. One character, 401 on every request. The kind of bug that keeps you humble. Responsive images landed in v2.5.0. Orion's Lyra pipeline generates responsive WebP variants at different breakpoints for every uploaded image, and the blog needed to actually use them. Proper `srcset` and `sizes` attributes so the browser picks the right variant for the viewport. Getting `sizes` to work correctly when you have a max-width container with padding that collapses to full width on mobile took more tweaking than you'd expect from what sounds like a simple feature. By v2.10.0, the blog had a full sidebar: tag cloud with counts, clickable tag filtering, monthly archive timeline, and a mobile layout that stacks everything cleanly. It went from "we should have a blog" to a proper publishing platform in roughly a week of focused work. --- ## Contact Form: A Story in Three Parts ![](https://vectorcrcdn.sfo3.cdn.digitaloceanspaces.com/orion-vectorcr-com/images/orion-vectorcr-com-images-lorenzo-profile-contact-form-3b46-2560.avif) The contact form shipped in v2.8.0 as a full modal experience: first and last name, company, email, phone with country code and auto-formatting for 26 countries, reason and method dropdowns (including a "Schedule a Google Meet" option), honeypot spam trap, and email delivery through **Resend** with a branded HTML template. The second part was reCAPTCHA. Google reCAPTCHA v2 invisible, added to prevent bots from burning my Resend quota. It worked. It also required sixty lines of CSS to restyle Google's floating badge into something that didn't look terrible in both light and dark mode, loaded 178KB of JavaScript, blocked the main thread for 860 milliseconds, and set **forty-nine third-party cookies** on every visitor's browser. The third part — v2.13.0 — was ripping out reCAPTCHA and replacing it with **Cloudflare Turnstile**. But that's part of the Lighthouse story. --- ## Infrastructure: The Invisible Releases Some of the most important work between v2.0 and now is stuff no visitor will ever see. Releases where the changelog reads like a sysadmin's diary, and they matter more than any UI change. **v2.1.0** was the infrastructure release. Health endpoint at `/api/health` returning `{"status":"ok"}` so Coolify knows the container is alive. Multi-stage Docker builds with a non-root user. Structured VCR logger using `process.stdout.write` (which survives production console stripping — more on that in a second). Pre-push quality gate. Git remote migrated to Forgejo at git.vectorcr.com. All docs consolidated into a `/documentation/` directory instead of loose files cluttering the root. **v2.3.0** was the dotenvx release. We standardized on [dotenvx](https://dotenvx.com) for encrypted secrets across all Vector projects. The `.env.production` file gets committed to git as ciphertext, and the decryption key lives only in Coolify's runtime environment. Overkill for a portfolio site? Maybe. But the point isn't the portfolio — it's the pattern. Every Vector project, from this site to Orion to client apps, uses the same secrets workflow. One mental model across every repo. That release also fixed the logger. We'd configured esbuild to `drop: ["console"]` in production, which strips all `console.log` calls from the browser bundle. Great for security — no debug output in DevTools. Terrible for the server, where those same `console.*` calls were our only logging output. The fix: the VCR logger uses `process.stdout.write` directly, so it survives the stripping. Structured emoji-prefixed output that looks clean in Coolify's log dashboard and doesn't accidentally leak to end users. **v2.9.0** was analytics. GA4 via GTM, with 16 custom dataLayer events across 7 components: CTA clicks, social links, contact modal interactions, project filters, dark mode toggles, language switches, scroll depth. Granular tracking that tells you how people actually use your site, not just that they showed up. **v2.11.0** brought the **Lyra CDN SDK** to the portfolio. My profile image in the hero section now comes through Lyra with responsive WebP srcset and a blurhash placeholder while it loads. Server-side fetching at render time with a hardcoded CDN fallback for resilience. It's one image, but it exercises the same pipeline our client sites use — I catch SDK issues here before they hit production elsewhere. **v2.12.0** was pure cleanup. 1,477 unused Phosphor SVG icons removed (5.8MB). Dead Three.js background component removed (1.8MB in dependencies we weren't importing). Faker.js, orphaned profile photos, duplicate favicons, IDE artifacts, stale WakaTime documentation — all gone. The repo's `.git` folder went from 45MB to 3.1MB after we ran git-filter-repo to purge the large blobs from history. Components got reorganized into VCR's three-tier structure: `base/` for atoms, `shared/` for workhorses, `layouts/` for page structure. Sixteen files updated for import paths. Not glamorous, but the codebase breathes now. **v2.14.1** was the Dockerfile gold standard. We migrated to the Bun build / Node runtime pattern: Bun handles the fast install and build steps, then the final image runs on Node for production stability. Multi-stage build, non-root user, `HEALTHCHECK` directive, optimized layer caching. This same Dockerfile pattern now runs across every Vector project. Write it once, use it everywhere. --- ## The Lighthouse Sprint ![](https://vectorcrcdn.sfo3.cdn.digitaloceanspaces.com/orion-vectorcr-com/images/orion-vectorcr-com-images-lorenzo-profile-light-desktop-6f83-2560.avif) This was v2.13.0, and it was a wake-up call. I ran Lighthouse on desktop and mobile. Desktop came back 99 / 91 / 77 / 100 (Performance / Accessibility / Best Practices / SEO). Mobile was worse: 66 / 91 / 77 / 100. SEO was already perfect, which felt good. Everything else needed work. The numbers alone don't tell the story. What matters is **why** each score was where it was, and what that revealed about the decisions I'd been making on autopilot. ### Best Practices: The reCAPTCHA Reckoning **77 out of 100**, and the reason was almost entirely one thing: forty-nine third-party cookies, all from Google's reCAPTCHA infrastructure. I didn't set those cookies. I didn't ask for them. I loaded a script to verify that contact form submissions came from humans, and that script brought Google's entire tracking apparatus along for the ride. That 77 wasn't telling me "your practices are mediocre." It was telling me "you loaded someone else's surveillance toolkit onto your personal website and didn't think about what that means for your visitors." **Cloudflare Turnstile** was the fix. Same concept — verify the user is human — fundamentally different implementation. No cookies. No tracking scripts. No phoning home to ad infrastructure. The widget is lightweight, renders inline, supports automatic dark mode, and the `size: 'flexible'` option means it adapts to whatever container you put it in. The migration touched three files: the contact form component (swap widget initialization), the API endpoint (change verification URL), and the environment config (rename keys). The most satisfying diff was the CSS: **sixty lines of reCAPTCHA badge styling, deleted.** Turnstile just looks good out of the box. After the switch: **Best Practices 100.** ### Performance: Death by a Thousand Scripts Desktop performance was already at 99. Mobile was at 66 — a very different story. The biggest culprit was Total Blocking Time — the amount of time the main thread is blocked running JavaScript when a user might be trying to interact. On a fast desktop, negligible. On a mobile processor, it's the difference between a responsive page and one that feels like it's fighting you. **reCAPTCHA** was part of this. 860ms of main thread blocking and 178KB of JavaScript, gone with the Turnstile switch. **Google Tag Manager** was another hit. The default GTM install loads synchronously in the document head, blocking everything below it. I rewrote the loader to defer until the first user interaction — scroll, click, keypress, or touch — or a 3-second timeout, whichever comes first. Analytics still capture everything meaningful (if someone interacts with the page, GTM loads before they do anything trackable), but the initial render doesn't pay the tax. **Google Fonts** were also render-blocking. Loaded via CSS `@import`, which means the browser can't paint any text until it's downloaded and parsed the font file. I switched to a `` tag with an `onload` handler that swaps the rel to `stylesheet`. The font loads asynchronously now. Brief flash of fallback font on very slow connections, but the page renders immediately instead of staring at you with a blank screen. **React islands** were the final piece. The site uses Astro's island architecture — interactive React components embedded in a static page. I had three components using `client:load`, meaning they hydrate immediately on page load: the DevActivity widget, the Forgejo heatmap, and the project list. All three sit well below the fold. Nobody sees them until they scroll. Switching to `client:visible` — which uses IntersectionObserver to defer hydration until the element enters the viewport — significantly reduced initial JavaScript execution. Roughly 113KB of JS that was being parsed and executed on load now waits until you actually scroll to those sections. On mobile, where every millisecond of main thread time counts, that difference is real. ### Accessibility: Semantic HTML Actually Matters **91 out of 100.** The issues were all structural, and they were all mine. The heading hierarchy was wrong. The hero section had an H1 (my name) followed by an H3 (the subtitle), skipping H2 entirely. Screen readers announce heading levels, and a jump from 1 to 3 signals something is missing. The DevActivity charts used H4 tags for labels that weren't document headings at all — just styled text. The project list used H5 for project names. Same problem. The fix was being honest about what things are. The hero subtitle became an H2 with CSS overrides to keep the visual size I wanted. Chart labels and project names became `` elements, because that's what they are — **styled text, not document structure**. Visual hierarchy and document hierarchy are different things, and I'd been conflating them. The contact modal had `aria-labelledby="modal-title"`, but nothing in the modal had `id="modal-title"`. It was telling the screen reader "this dialog is labeled by something" and there was nothing to find. I added the `id` to the H3 heading inside the modal. One attribute. The language selector had an aria-label that said "Switch language" — tells you what the button does but not what language you're currently viewing. I changed it to include the current language: "EN, switch language." Now a screen reader user knows both the current state and the available action. --- ## The Vela Migration This is the big one. The work that happened in v2.15.0 and v2.16.0, and the reason the site looks and feels fundamentally different now. **Vela** is Vector's design system. We'd been building it in parallel with client projects — a shared component library, token system, and motion framework that every VCR site can pull from. The portfolio was still running on one-off custom components: `ButtonMain`, `ButtonSecondary`, `H1`, `H2`, `H3`, `P`, `TagPill`. Each written specifically for this site, each slightly different from what we were building for clients. The inconsistency was becoming a problem. Every time I refined a pattern in Vela for a client project, the portfolio drifted further from the standard. The portfolio was supposed to be Vector's showcase, and it wasn't even using Vector's own design system. ![](https://vectorcrcdn.sfo3.cdn.digitaloceanspaces.com/orion-vectorcr-com/images/orion-vectorcr-com-images-screenshot-2026-03-11-at-1-7432-2560.webp) ### v2.15.0: The Full Swap This was a surgical migration. Every custom base component was replaced with its Vela equivalent or with raw HTML + Tailwind utilities using Vela's CSS custom properties. `ButtonMain` and `ButtonSecondary` became `Button` with variant props. `H1` through `H3` and `P` became semantic HTML with Tailwind classes. `TagPill` became `Badge`. The ContactForm alone had a 113-line scoped `