Skip to content
Write to me
portfolio/

This site

A portfolio built as a product: a static build, a look of its own for every page, an open log of decisions and checks before every commit.

What it is
A site in two languages. Home, Flow and Projects are done, Pora na Paru is still a stub. It is plain files with no application server, served by Caddy.
My role
Design, front end, motion, infrastructure and copy.
What is open
The code, the log of decisions and the plan are on GitHub. Each section links to its files.
Period
since 30.10.2026
Address
finchaf.com
Stack
Next.js, TypeScript, Tailwind, GSAP, Caddy
  • 135commits
  • 14decisions
  • 14checks
  • 557tests
  • 4themes
  • 2languages

On 03.11.2026, branch main and a check.py run

Form

Every page has a form of its own

A portfolio usually describes the work in words. Here a page is built like the work itself: home is a stage that scrolls down and sideways, Flow looks like Obsidian because that case was a base of notes, and Projects looks like an editor because I am a designer.

The price of that form is an engine per page. To keep them from growing together, pages are split by boundaries: a feature does not import another, and what they share moves down to ui/ and lib/. It is an ESLint rule rather than an agreement: an import across a boundary fails the check.

FormForm

Themes

One component, four themes

Each page declares a theme, and the theme changes colours, typefaces and the look of controls. There are two modes, so eight combinations. A component knows nothing of themes: it reads tokens, and the values live in the theme's file. A new theme is one file.

A colour outside the contract does not compile: the Tailwind palette is off. A test measures contrast, and that is why a theme has two accents. accent is the exact brand colour for fills, accent-strong is the one for text. White on the product's blue gives 3.7:1, too little for text.

The theme is declared in the markup and read through :has(), so it is in the first HTML and the page does not flash. \Get in touch on home and [[Get in touch]] on Flow are one component, and the capsule changes its skin on the fly when a chapter of another theme arrives on home. The values below are read from the sample at every switch.

A page heading

The same paragraph, the same markup, the same components.

Get in touchSite code

Lens

Design and code on one screen

The first screen is two layers: the design and its blueprint, with sizes and real lines of code. A test finds those lines in the sources, so the blueprint cannot drift from the code.

The layers match to the pixel: every block of the blueprint stands where the real one does, because the block itself lies inside it, hidden, and sets its size. The lens only opens the second layer with clip-path. It reaches the capsule too, though that lies outside the first screen: a script writes it the same variables in its own coordinates.

The first screen never waits for animation. The name is in the HTML and drawn from the first frame, and the assembly only wipes an outline off it. The real controls lie under the code layer and take the clicks, and keyboard focus lifts them above it.

Move the lens over the frame

Motion

A page change along the slash of the logo

Each tool has its zone: CSS does hover and focus, ViewTransition the page change, GSAP the scenes on scroll, canvas the graphs and lines. Durations and curves live in tokens, so an effect's code holds no numbers.

The first page change looked bad. A ViewTransition around the page makes one layer as tall as the page, 34,678 px on home on 03.11.2026, and the browser squeezes it to the screen. Now only the screen is captured, and the new page arrives along a 20.2° line, the lean of the slash in the logo. A step back in the capsule runs the other way.

In Firefox the shape stood at its end for the whole change: it does not interpolate a registered @property variable on these pseudo-elements. So the shapes are written out in polygon keyframes. WebDriver's screenshot does not show this, so that frame comes from a system capture of a real browser window.

Only the screen is captured, and the new page arrives along the slash.

Checks

14 checks, one command

python3 scripts/check.py runs before every commit, and CI runs the same command on a PR. Below is a real run of 03.11.2026: 300 unit tests and 257 browser tests.

The browser checks run behind a real Caddy, the same image digest as the server: addresses, headers, CSP, accessibility in both modes, scenes with reduced motion and without JavaScript. A check must be able to fail: the scaffold was tested with a deliberate import across a boundary and with text in the markup.

python3 scripts/check.py

Docsok0.1 s

Publicok0.2 s

Typesok2.3 s

Lintok13.7 s

Formatok2.2 s

Contentok0.0 s

Unitok1.0 s

Buildok11.3 s

Bundleok0.3 s

Addressesok15.5 s

SEOok9.1 s

A11yok35.8 s

Motionok210.2 s

Themesok36.8 s

14 of 14, 5 min 39 s

First-load JavaScript budget, gzip, 03.11.2026
Home245.2 KB
Flow192.2 KB
Other pages178.2 KB

limit: 280 KB

What turned up along the way

  • 664494f

    On the Flow card the graph's simulation ends on different coordinates on arm64 and x64. It was green on the laptop and red in CI. Now the hash counts what the graph is made of, not where it lands.

  • da37183

    The test config differed from production, so the gate stayed green while the server's 404 would have named itself and lost HSTS. The header block now matches line for line, and the gate looks at the 404's headers.

  • 3e2a2bf

    Gecko loses a thin diagonal gradient at random, and the hatching fell apart in Firefox. It is now an SVG mask, and a test keeps such gradients out of the stylesheets.

  • bbdeab3

    Shadows took the colour of the text, which on a dark page is a glow. In the dark mode a shadow is now transparent.

Decisions

The decision first, the code next

Whatever is expensive to undo is written down before the code: the context, the options, the choice and when to revisit it. An accepted record is never rewritten, a different decision is a new record.

Static was not chosen to save money: the server had room. It is faster, more reliable and safer for the neighbour on that server, and the animation lives in the browser anyway. The repository has been open since the first commit, because what enters a history stays in old commits. The code is MIT, the texts and images are reserved, and GSAP stays a dependency that is never copied in.

Release

A release is one switch

The site is a folder of files on a shared server, beside another product. A release touches neither Caddy nor the containers: a new build lands in a folder of its own, then one operation switches the current link. There is no downtime.

The last three releases are kept, and --rollback returns the previous one in a second. GitHub holds no key to the server: CI only checks, and a release runs from the laptop. That is a price paid on purpose: there is one developer, and the site can be rebuilt from any commit.

  1. Refuses unless on main, with a clean tree, level with GitHub
  2. Every check and the build
  3. A copy in a folder of the commit under releases/, equal files as hard links
  4. An atomic switch of the current link
  5. A check of the real domain: codes, CSP, redirects
  6. If it fails, the link returns to the last release

ls portfolio/releases

664494f84cee← current

252bf73f2ffc

1a4994f45e0d

Commits to main by day
3830.106131.102601.111002.11

Stage

What is not done yet

The site is a work in progress, and the plan in the repository shows it.

  • The Pora na Paru page is still a stub.
  • The story's photographs are over budget: 2.12 MB in all against a limit of 1.5 MB, measured 01.11.2026.
  • The page change and the Flow chapter have not been tested on a real phone or in real Safari yet.

Files and decisions