lsgoulart

12 min read

What this site is made of

Five decisions that mattered, and why. The rest you can read in the source.

I'm writing this from the site I'm describing, which feels like the right place to start. lsgoulart.me is small. One person, one writing surface, one lab. Every small site is also a stack of decisions, and a few of those decisions had teeth. What follows are five of them, with the code, the dead ends, and the reasoning.

If you're a design engineer, or a frontend engineer who pays attention to the edges, this is for you. If you're a designer wondering what code-first prototyping actually looks like, also for you. The stack is Next.js 16 with the App Router, React 19, Tailwind v4, pnpm. Most of what follows would translate to other stacks with cosmetic changes.

1. Typography that earns its place

The site uses three families. Fraunces for display headings. Atkinson Hyperlegible for body. Geist Mono for code, dates, and the small all-caps labels. Three jobs, three voices. None of them is Inter.

That last sentence is the load-bearing one. Inter is a beautiful typeface, and also the one nine in ten new sites pick by reflex, which means it has stopped doing the thing fonts are supposed to do. A choice of typeface should be a choice. So I went looking for fonts that fit the brand voice (methodical, narrative, nocturnal), and landed on three that are interesting at their roles and that no other site I know of uses in this combination.

Atkinson Hyperlegible is the quiet star. It's an open-source typeface designed by the Braille Institute specifically for low-vision readers. The aperture on the lowercase letters is wide. The disambiguation between similar glyphs (capital I, lowercase l, the number 1) is decisive. It reads like a typeface that considered the reader, which for a writing-first site is the exact qualification.

Loading the three families in App Router is straightforward, with one subtle trap I want to surface for you.

app/layout.tsx
import {
  Atkinson_Hyperlegible,
  Fraunces,
  Geist_Mono,
} from "next/font/google";

const fraunces = Fraunces({
  variable: "--ff-fraunces",
  subsets: ["latin"],
  axes: ["opsz", "SOFT", "WONK"],
  display: "swap",
});

const atkinson = Atkinson_Hyperlegible({
  variable: "--ff-atkinson",
  subsets: ["latin"],
  weight: ["400", "700"],
  display: "swap",
});

const geistMono = Geist_Mono({
  variable: "--ff-geist-mono",
  subsets: ["latin"],
  display: "swap",
});

The trap is the variable name. Tailwind v4 ships its own --font-sans and --font-mono theme tokens. If you name your next/font variable --font-sans, the cascade will silently pick the wrong one, and your body type will render as the system fallback without any error. I lost an hour to this. The fix is the --ff-* namespace in the snippet above, with the Tailwind tokens referencing them one-way inside @theme:

app/globals.css
@import "tailwindcss";

@theme {
  --font-display: var(--ff-fraunces), serif;
  --font-sans: var(--ff-atkinson), system-ui, sans-serif;
  --font-mono: var(--ff-geist-mono), ui-monospace, monospace;
}

While we're on Tailwind v4 traps: do not use @theme inline. The inline mode inlines token values into utility classes, but it does not declare them at :root. Plain CSS rules that reference tokens via var() will silently break. The marginal CSS-size savings are not worth the failure mode. Use plain @theme.

2. Color in OKLCH, with an amber that lives in P3

I wanted one bright accent color, used rarely, that would sit in the pocket and only come out for hover states, focus rings, and the occasional flourish. Amber. The shade fossilised resin takes when light passes through it.

The interesting choice was the color space. Most sites still ship colors in HSL. HSL has a problem: equal steps in lightness do not look equal. Move from hsl(60, 80%, 50%) to hsl(240, 80%, 50%)and the second one looks markedly darker even though the lightness number is identical. OKLCH fixes this. It's perceptually uniform: equal steps look equal. For a design system you adjust by hand, this matters more than you think.

The other interesting choice was Display P3. Wide-gamut displays (every recent Mac, every recent iPhone, most modern Android) can render colors that simply do not exist in sRGB. The amber I wanted, the one that actually glows the way fossilised resin glows, has a chroma of 0.18 at lightness 58%. That chroma is past the sRGB ceiling. On a P3 panel, it sings. On an sRGB panel, it would clip.

So I declared the colors twice. Hex first, OKLCH second. The cascade picks the second one when the engine can render it.

app/globals.css
:root {
  /* sRGB hex first; modern engines override with the line below. */
  --accent: #b07418;
  --accent: oklch(58% 0.18 65);
}

@media (prefers-color-scheme: dark) {
  :root {
    --accent: #e09c38;
    --accent: oklch(76% 0.19 70);
  }
}

Three things to notice. First, the dual declaration is plain CSS, no preprocessor, no framework. Second, the OKLCH version is not “translated” from the hex; it's the canonical color. The hex is the closest sRGB match, used as fallback only. Third, this pattern works in Safari, Chrome, Firefox, all current versions. Wide-gamut color is no longer experimental.

Pure black never appears in nature. Pure white either. Tint everything toward the brand hue, even by 0.005.

The second principle, which I learned from Anthony Fu and Refactoring UI both, is that pure black and pure white never appear in nature. The neutrals on this site all carry a tiny chroma of 0.005 to 0.014, oriented toward warm hues (60 to 80). It's subliminal but real: even at that chroma, the page reads warmer than a true grayscale would, and the amber accent feels like part of the family rather than a transplant.

3. The site has a sandbox inside its lab

This is the architectural decision I'm proudest of, and it took me two hours to undo my first version of it.

The site has a Lab, which is the place I want to test interface ideas without committing them to the rest of the site: working sketches, accordions built four ways, a hand-rolled scroll-driven thing if I get the bug to make one. v1 of the Lab put every experiment inside the same site shell. Same header, same fonts, same color tokens, same fade-up choreography on page load. It looked nice. It was wrong.

It was wrong because the shell was silently the experiment's first design constraint. If I wanted to try a brutalist takeover with a font I'd never use anywhere else, the site header sat above it and editorialised it. The artifact was a citation of the site, not a thing of its own.

So I split the routes. The site half (home, lab index, post pages) lives inside a (site) route group with its own layout that wraps everything in <SiteHeader />. The sandbox half (the experiment detail pages at /lab/[slug]) lives outside the group. The root layout strips down to html, body, the smooth-scroll wrapper, and that's it.

folder structure
app/
├── layout.tsx           ← html, body, SmoothScroll only
├── (site)/
│   ├── layout.tsx       ← <SiteHeader />{children}
│   ├── page.tsx         ← /
│   ├── lab/page.tsx     ← /lab (the editorial index)
│   └── posts/<slug>/
└── lab/
    └── [slug]/page.tsx  ← /lab/<slug>, bare canvas

The trick is that route group parens are URL-invisible. /lab resolves from app/(site)/lab/page.tsx and gets the header. /lab/collapsible-menu resolves from app/lab/[slug]/page.tsx and renders bare. The reader sees the same URL space, the browser renders two completely different layout chains.

app/lab/[slug]/page.tsx
import { notFound } from "next/navigation";
import {
  experiments,
  getExperiment,
} from "../_experiments/registry";
import BackToLab from "../_experiments/BackToLab";

export function generateStaticParams() {
  return experiments
    .filter((e) => e.Component)
    .map((e) => ({ slug: e.slug }));
}

export default async function ExperimentPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const experiment = getExperiment(slug);
  if (!experiment?.Component) notFound();

  const Component = experiment.Component;
  return (
    <>
      <BackToLab />
      <Component />
    </>
  );
}

For experiments that want to fully take over (custom font, custom palette, no chrome at all), they author their own page at app/lab/<slug>/page.tsx. Next.js prefers static segments over dynamic, so the static file silently overrides the bare shell. No flag, no plumbing, no central registry of overrides. The escape hatch is the file system.

The bare shell carries one piece of chrome: a small floating ← Labpill in the top-left corner with a subtle backdrop blur, so it stays legible against any background the experiment paints. That's the only thing the sandbox enforces. Everything else is the experiment's call.

4. Smooth scroll as a client island

Lenis is a smooth-scroll library by darkroom.engineering. It catches wheel events and animates the scroll position with inertia, which makes the wheel-and-keyboard scroll feel less like a staircase and more like a slide. I wanted that for this site. The room is editorial. Long columns. The native wheel is too abrupt for the rhythm.

The interesting part wasn't the library, it was where to put it.

Lenis touches window, document, and the requestAnimationFrame loop. It cannot live in a server component. The naive move is to make the whole root layout a client component, which would force the entire page tree into the client bundle. Wrong move.

The right move is the smallest possible client island. A wrapper component, marked "use client", that mounts Lenis and renders its children. The children stay server-rendered.

app/_components/SmoothScroll.tsx
"use client";

import { ReactLenis } from "lenis/react";
import { useEffect, useState, type ReactNode } from "react";

export default function SmoothScroll({
  children,
}: {
  children: ReactNode;
}) {
  const [enabled, setEnabled] = useState(true);

  useEffect(() => {
    const mq = window.matchMedia(
      "(prefers-reduced-motion: reduce)",
    );
    const sync = () => setEnabled(!mq.matches);
    sync();
    mq.addEventListener("change", sync);
    return () => mq.removeEventListener("change", sync);
  }, []);

  if (!enabled) return <>{children}</>;

  return (
    <ReactLenis
      root
      options={{ lerp: 0.1, duration: 1.1, smoothWheel: true }}
    >
      {children}
    </ReactLenis>
  );
}

Two things I want to flag. First, the wrapper checks matchMedia("(prefers-reduced-motion: reduce)") and returns plain children if true. Lenis does not read the media query on its own. If you skip this check, you ship motion to users who explicitly asked for less of it. That's not a polish concern, it's a default-correctness concern.

Second, the wrapper subscribes to changes in the media query, so a user who flips the OS toggle mid-session gets the right behaviour without a reload. Tiny thing. Worth doing.

5. A notebook for the build

This is the practice I'd most recommend stealing.

Every notable creative or technical decision in the build of this site is captured in a markdown entry under .notebook/entries/YYYY-MM-DD-slug.md. Decisions, dead ends, experiments, aesthetic moves, ethical defaults refused, tooling-friction stories. The notebook is committed alongside the code.

The reason is that reasons rot fastest. Six months from now, I'll look at the --ff-*font variable namespace and wonder why it exists. The notebook entry from the day I added it tells me, with the failed attempt, the diagnosis, and the fix. Six months from now I'll look at the route group split and wonder why the lab detail page has no header. The entry tells me, with the v1 mistake and the migration plan.

The notebook also seeds the blog. This post, the one you're reading, was assembled from a dozen notebook entries. Most of them won't survive the cut into a published post (most of them shouldn't), but they're the receipt that proves the work happened, and they're the source the post can quote.

Three rules for the notebook practice, if you want to try it:

  1. Write the entry at the moment of the decision, not later. The reason will already be slipping by the next morning.
  2. Show failed attempts before the fix. The failure is what makes the fix legible.
  3. Be specific. “I tried X, it broke because Y, I switched to Z” beats “explored options, picked the best one” every time.

A small reflection

A phrase I keep coming back to is that a website is a place. Not a product, not a content management problem, not an SEO surface. A place. The five decisions above are about what kind of place this is. Quiet. Restrained. Designed for someone to read and stay a minute. With one bright object kept in the pocket for when a moment warrants it.

It's also the start of a walk. The frontend half of design engineering I've practised for a decade; the design half I'm still finding the edges of, slowly and on purpose. This site is where that finding gets to happen out loud. Every decision above is one small cut, and the notebook is the receipt — proof that the taste was made, not downloaded.

The repository is public. The notebook entries are committed. If you're building something similar, take what's useful and leave the rest. The decisions are not prescriptive, they're an inventory.

Thanks for reading. The next post will be narrower, probably the typography story in more detail. I'm still figuring out the rhythm.