A portfolio that looks like my desktop

How I built dgudovic.com — Omarchy themes, kinetic type, zero-JS page transitions and a perfect Lighthouse score from a small VPS.

I spend most of my day inside Omarchy: Arch Linux, Hyprland, tiled windows with a two-pixel border that lights up when they’re focused. So when I finally sat down to build a proper home on the web, the brief wrote itself: it should feel like my desktop — and it should be fast enough that nobody waits for it.

Hard constraints, decided before the first line:

  • 100 on every Lighthouse category, on mobile. Not “green”. One hundred.
  • Writing a post is a Markdown file and one command, nothing more.
  • Static files on my own server. No platform, no cookies, no trackers.

Here’s how it came together.

The desktop metaphor

The top bar is modelled on Omarchy’s: workspaces on the left, a clock set to Podgorica in the middle, a tray on the right. The workspaces are the navigation — and they follow you as you scroll the home page, the way Hyprland highlights the active workspace. Press 1–5 to jump between them.

Projects are windows. They tile like Hyprland’s master layout — one big window, the rest stacked beside it — and when you hover one, its border turns into a slowly rotating gradient, the same trick as Omarchy’s active border. That one is pure CSS: an animatable custom property drives a linear-gradient angle, masked down to a two-pixel frame.

@property --angle {
  syntax: '<angle>';
  inherits: false;
  initial-value: 45deg;
}
.win:hover::after {
  background: linear-gradient(var(--angle), var(--accent), var(--accent-2));
  animation: border-spin 4s linear infinite;
}
@keyframes border-spin {
  to { --angle: 405deg; }
}

Sixteen themes, all readable

Press t. The palette cycles through sixteen Omarchy themes — Matte Black, Tokyo Night, Catppuccin, Gruvbox, Kanagawa, Rosé Pine and the rest — taken straight from each theme’s colors.toml. My desktop runs Tokyo Night, but the site defaults to Matte Black: orange on near-black is the loudest thing in the set, and it’s the same orange as Pinhole’s Generate button.

Theme palettes are designed for terminals, not for body text, and plenty of them have a “dim foreground” that fails contrast. So the colours go through a small build-time step: every text token is nudged toward the foreground until it passes WCAG AA against the background it sits on.

/** Push `color` toward `toward` until it reaches `ratio` against every background. */
const ensure = (color: string, bgs: string[], toward: string, ratio = 4.6) => {
  for (let t = 0; t <= 1.0001; t += 0.02) {
    const out = mix(color, toward, t);
    if (bgs.every((bg) => contrast(out, bg) >= ratio)) return out;
  }
  return toward;
};

The result is a few CSS custom properties per theme, inlined in the page. A tiny inline script restores your last theme before the first paint, so there’s never a flash of the wrong colours. Switching uses the View Transitions API to wipe the new theme in as a circle from the button you pressed — and code blocks like the ones on this page follow along, because Shiki’s css-variables theme maps onto the same tokens.

Kinetic type that doesn’t cost a point

The hero is three words — BUILDER, ENGINEER, MAKER — set in Mona Sans at full width and black weight. Each word “decodes” out of terminal glyphs when it becomes active, and a spotlight fills the dimmed words under your cursor.

The sizing is one line of CSS. The stack is a size container, and the font size is the container width divided by the measured width of the longest word, in ems:

.stack { container-type: inline-size; }
.word  { font-size: calc(100cqi / 5.8); } /* "ENGINEER" is 5.74em wide */

Two things went wrong on the way, and both are worth knowing:

  1. -webkit-text-stroke on a variable font shows its overlapping contours. The outlined words had stray lines inside every E. The fix was to drop the stroke and fill the dimmed words with a flat colour through background-clip: text — which also gave the cursor spotlight for free, as a second gradient layer.
  2. The decode effect caused layout shift. The first version swapped each letter for a random monospace glyph, which is narrower than the letter it replaced, so its neighbours moved. Lighthouse scored it as a CLS of 0.028. Now the glitch glyphs are drawn in an absolutely positioned ::after over a ghost of the real letter. The letters never change size, and CLS is zero.

Fonts: 171 KB down to 75 KB per page

Typography carries this design, so fonts were the biggest line item: Mona Sans with both its weight and width axes, plus JetBrains Mono for everything that should feel like a terminal. Out of the box, the home page downloaded 171 KB of WOFF2.

Two passes with fonttools fixed it:

  • Subset to the characters the site actually uses, with Montenegrin letters (č, ć, đ, š, ž) split into their own 4 KB file that only loads where they appear.
  • Instance the variable font down to the part of the design space the CSS touches — weights 400–900 and widths 100–125%. That alone took the main Mona Sans file from 65 KB to 46 KB.

Only the main Latin file is preloaded, and every face ships with metric-matched fallbacks, so text paints immediately and barely moves when the real font arrives.

JetBrains Mono gets one more trick. It’s decoration — the bar, labels, code — so on a first visit it loads after the page has, with the system monospace standing in for a moment (both are 0.6em wide, so nothing reflows). A flag in localStorage tells every later page that it’s cached, and those pages start with it straight away. That leaves 50 KB of fonts in front of the first paint.

Page transitions with zero JavaScript

Navigating between pages animates, but there’s no router. One line of CSS opts the whole site into cross-document view transitions, and blog titles carry a matching view-transition-name so they morph from the list into the post header:

@view-transition { navigation: auto; }

On top of that, a speculation-rules block asks Chromium to prerender a page as soon as you hover a link, so most navigations are effectively instant.

The JavaScript budget

The entire client runtime — themes, keyboard shortcuts, the clock, scroll reveals, the hero animation — is about 5 KB gzipped. The command palette (Ctrl K) is another 1.4 KB that only downloads the first time you open it, along with its list of entries. Fourteen of the sixteen themes live in a 6 KB stylesheet that’s only fetched once you pick one of them. Everything else is HTML and CSS: each page inlines its own styles, so there are no render-blocking requests at all.

Shipping it

npm run deploy builds the site, pre-compresses every text file with gzip so nginx can serve it with gzip_static, and rsyncs it into a fresh release directory on the server. Unchanged files are hard-linked from the previous release, so uploads are tiny. Then a symlink flips, atomically. Fingerprinted assets are cached forever; HTML always revalidates.

Writing a post is npm run new -- "Title", a Markdown file, and the same deploy.

How it was built

Like most of what I ship these days, this site was built with an AI agent at the keyboard — Claude Code, working from my brief, my GitHub, and my opinions. I set the constraints and the taste; it did the legwork and kept the receipts. Every number in this post was measured — Lighthouse, fonttools, gzip — not guessed.

If you want something like this for your own work, get in touch.

esc
↑↓ move ↵ open t next theme 1–5 workspaces jk windows