fkrzski.dev - Personal portfolio

Personal developer portfolio built with Astro and Tailwind CSS v4. Static site with GSAP animations, hash-based CSP, dark/light theme, and full CI/CD pipeline. Deployed to Cloudflare Pages.

Updated

AstroTailwind CSSGSAPTypeScript
fkrzski.dev - Personal portfolio screenshot

Overview

This site. A portfolio I own end to end — design, code, pipeline, hosting — with no template underneath and nobody else’s commits in the history.

The repo opens on 18 January 2026, and the Cloudflare Pages deploy pipeline lands three days later, before there was anything worth deploying. That order was deliberate: get the domain serving and let Google start crawling while the content is still being written. v1.0.0 shipped on 26 March 2026. Since then: 932 commits, 28 tagged releases, and a second app on a subdomain that wasn’t part of the original plan.

What it does

It is a business card, and it behaves like one — you land, you find out what I build, you get a way to reach me. Everything is statically prerendered, indexable, and readable with JavaScript off. Dark theme by default, light on request, no flash on either.

  • Portfolio — hero, about, stack, projects, contact, plus case studies like this one at /projects/<slug>.
  • Docs hub on docs.fkrzski.dev — its own Astro + MDX layout, with documentation for my open-source packages pulled in from their own repositories rather than duplicated here.
fkrzski.dev homepage hero — name in outlined type over a dark background, with the Laravel Developer tagline and call-to-action buttons

The hero, and the LCP element. Every animation above this fold is pure CSS — see below for what that cost to learn.

Why Astro

I considered Laravel with Blade first, since it is the stack I am fastest in, and I could have had an admin panel for editing content. For a static portfolio that is a combine harvester in a window box — a database, a runtime and a VPS to maintain so I can change a paragraph. React with Gatsby, and Angular with SSR, were the other candidates.

Astro won on two properties. It prerenders by default, with no configuration spent on convincing it to — everything else on the list treats static output as a mode you opt into and tune. And Islands mean choosing it now doesn’t foreclose anything later: if one component genuinely needs React, it gets React, and the other 95% of the page stays HTML.

From single app to monorepo

The trigger was Starlight. I wanted documentation on a subdomain of the same site, and Starlight turns out to be built on the assumption that it is the site — it wants its own Astro config, its own layout pipeline, its own everything. Bolting it into the portfolio app was not a fight worth having.

Two separate repositories was the obvious alternative, and I rejected it. Fonts, color tokens, spacing scale, ESLint and Prettier config, the Lighthouse thresholds — all of that would have to be maintained in duplicate, kept in sync by hand, and diverge the first week I got lazy. With more projects likely to appear later, that cost only grows.

So: npm workspaces. apps/web and apps/docs for the two sites; packages/theme for design tokens and self-hosted fonts, plus docs-schema, og and lighthouse-config for the machinery both apps need to agree on.

The migration landed on 7 July 2026 and it was the longest single stretch of work on this project — two months without a release (v1.1.1 on 17 May, v1.1.2 on 20 July) and 236 commits in July, the busiest month in the repo’s history. What kept it from turning into a rewrite was planning it as a sequence of small, individually shippable steps: convert to workspaces first, extract the shared tokens second, add the docs app last. At no point was the site broken for more than one commit.

One repository, but two deployments. Each app is its own Cloudflare Pages project — fkrzski.dev and docs.fkrzski.dev — pointed at the same repository with its own build and its own root. GitHub Actions never uploads a dist/; it POSTs to a per-app deploy hook and Cloudflare builds from there.

Shipping is tag-driven rather than push-driven. A push to master runs the full pipeline and deploys nothing. A v*.*.* tag starts the release workflow, which refuses tags that aren’t on master, bumps the version across the workspaces, fires both deploy hooks in parallel, and cuts the GitHub release. Merging is cheap; releasing is a decision. The docs deploy carries a second trigger on top of that — a repository_dispatch event, so a sibling project can refresh the docs hub from its own repository when its documentation changes, without a commit landing here.

docs.fkrzski.dev landing page with project cards for robots-txt and php-steam-api-sdk, each showing download and star counts

The docs hub — a separate Astro app on its own origin, drawing its fonts and color tokens from packages/theme. Each project’s documentation is maintained next to its code and synced in.

Engineering challenges

Google found the site almost immediately and then did nothing with it. Pages sat in discovered — currently not indexed for weeks. Sitemap submitted, structured data in place, a link from my GitHub profile — none of it moved the needle.

What broke the deadlock was one link, from Dead by Stats to here. That domain has years of history and real traffic behind it. Two days later roughly 90% of the pages were indexed, with no other change on my side.

A sitemap gets you crawled. A link from a domain the crawler already trusts gets you indexed. Those are not the same problem, and I spent weeks solving the one I didn’t have.

One theme, two origins

fkrzski.dev and docs.fkrzski.dev are separate origins, so localStorage — where the theme preference lives — does not cross between them. Pick light on the portfolio, click through to the docs, get dark. Small thing, but it makes two sites out of what should read as one.

The fix is a theme cookie scoped to the apex domain, which a subdomain can see. Both sites run the same toggle and the same inline script from packages/theme. The toggle saves every pick to localStorage and mirrors it into the cookie. The script runs before first paint and takes the cookie first, then the domain’s own localStorage, then dark — so there is no flash on either origin.

The system preference plays no part. The toggle has two states, light and dark, so a visitor on a light-mode OS can’t knock the portfolio off its deliberate dark default just by opening the docs.

That inline script is hashed into each site’s CSP at build time — there is no unsafe-inline anywhere on either origin.

The 1250 ms the hero spent invisible

The hero was animated with GSAP, like everything else on the page. Which meant the heading sat at opacity: 0 until a ~48 KB bundle had downloaded, parsed and run — and the LCP element is charged for every millisecond it spends transparent. That was ~1250 ms of pure element render delay on the single metric the page is judged by.

The fix wasn’t dropping GSAP, it was drawing a line through the page. Anything needed for the site to be readable or usable is CSS or eager JavaScript: the hero entrance is now pure CSS keyframes, and the navbar background moved to a passive scroll listener — functional chrome shouldn’t wait on an animation library either.

Everything below the fold is still GSAP, and still should be. The scroll reveals, the staggered stack grid, the project cards — animations.ts does all of it, lazy-loaded on idle or first scroll, and the image lightbox pulls GSAP in on demand only once you actually click an image. It is decoration, it now loads like decoration, and it can no longer hold the fold hostage.

The rule is written down in the repo so I don’t quietly undo it in six months.

Quality gates

Nothing merges without: astro check, ESLint, Prettier, Vitest across four workspaces, and 16 Playwright specs covering navigation, theming, view transitions, SEO, sitemap, CSP headers, and axe-core accessibility.

A monorepo makes it easy to run everything on every change, which is the same as running nothing carefully. So each run is routed by a path filter: a docs-only pull request never starts Playwright or the web Lighthouse runners, and a web-only one never syncs and rebuilds the docs. Anything shared — packages/**, the lockfile, the root config — counts as a change to both apps, because it is one.

On top of that, Lighthouse CI runs both apps on both form factors, two runs each. The thresholds that fail the pipeline are: performance ≥90 on mobile and ≥95 on desktop, and accessibility, best practices and SEO at exactly 100. The performance numbers have slack because mobile throttling on shared CI runners is noisy. The other three do not, deliberately — a regression there is a mistake, not weather.

Results

  • Lighthouse: ≥95 performance, and 100 in accessibility, best practices and SEO, in every app/form-factor combination.
  • 932 commits, 28 releases since January 2026, on a project with one contributor.
  • Traffic: close to nothing, and I’m not going to dress that up. It’s a business card. Its job is to be there and be good when someone follows a link to it — not to acquire anybody.

What I’d do differently

  • Wouldn’t have made GSAP the default. It is still on the site and it still earns its keep on the scroll-triggered reveals. But reaching for it on everything is what put a 48 KB bundle in front of the LCP element, and most of what it did here — fades, slide-ups, staggers — CSS does natively and faster. The library should have been the exception I reached for, not the baseline I started from.

  • Would have started as a monorepo. The migration wasn’t difficult, it was just long, and it cost two months of releases at exactly the point where I had momentum. Workspaces on day one are nearly free; workspaces retrofitted onto a live site are not.

Status today

Actively maintained. Dependencies come in through Dependabot, small fixes ship continuously, and a release gets tagged when something is actually worth tagging. The design is where I want it, so most new work is content — this case study among it.

← All projects