Blog / Inside Forecite

[15 / 15]

Sep '257 min read

Four pixels on, six pixels off

Our dashed guide lines aren't borders. They're SVG, because CSS can't round the end of a dash, and that decision cost a week we didn't have. Here is what the rest of the drawing is made of, and the three places it stops being honest.

Marcus Hale CEO & Co-founder

There's a line in our guide component that reads strokeDasharray="4 6", with strokeLinecap="round" directly under it and a comment above explaining why it has to be there: plain CSS border-dashed cannot render a dash as a soft pill. So the dashed guides running down the sides of this page aren't borders. Each one is an inline SVG holding a single <line>, coloured text-border, positioned absolutely inside a relative parent.

Nour Zeidan wrote that component early on, back when I had more time to hold strong opinions about dashes. A border would have been one Tailwind class. The SVG version is a file, an import, and a bit of extra DOM on every page that uses it, and I am confident that no visitor has ever consciously registered the rounded ends. I'd spend the week again.

A drafting sheet is a document that shows its own construction. The guides that located the part stay on the page after the part is drawn, and the sheet states what it doesn't know as plainly as what it does. That's an unusual thing for a document to do, and it's the promise our product has to make. Open a row in the feed and the drawer gives you the five sub-scores with their weights printed beside them, so you see the arithmetic that produced the badge instead of being handed the badge. An interface that looked smooth and finished would have been arguing against the product.

Four typefaces, and two of them are used once each

The site loads Inter, Geist Mono, Finlandica and Caveat. That's more faces than a system this size should need, and I want to account for each one, because the accounting is where I lost an argument.

Inter is --font-sans, and in the @theme inline block at the top of globals.css, --font-heading is defined as var(--font-sans). We declare a heading face and then point it at the body face. Headings differ from body copy by weight, tracking and size, never by family. A display face is how a marketing page announces that it's a marketing page.

Finlandica is exposed as --font-finlandica and mapped through to the font-secondary utility, and it is used in exactly one place in the entire product: the wordmark next to the icon in the app sidebar, at text-base font-semibold tracking-tight. Caveat is --font-quote, a script face, used once, on the pull-quote in the careers team mosaic. Two typefaces, two appearances, both downloaded by every visitor. I've twice opened a branch to delete one of them and twice closed it without merging, and I don't have a principled defence for that. Call it the cost of caring about a wordmark.

Geist Mono is the one earning its keep. It sets the [01 / 15] index label in the breadcrumb band of this page, the provenance string under every figure at font-mono text-[11px] tracking-wide uppercase, and the score annotation inside every verdict badge at ten pixels with tabular-nums on it. That last class is the point of having a mono face at all. An actionability score ticking from 0.68 to 0.71 while you're looking at the column has to not move the glyphs around it. On a feed where rows arrive on their own, proportional figures make the whole column shimmer, and shimmer reads as instability in a product whose job is to look calm while something urgent happens.

One hex code in a file of oklch

Every colour token in globals.css is an oklch triple except one. --primary: #266df0. It sits there in both :root and .dark, character for character identical, which means the blue is the only value in the system that doesn't know which theme it's in. Everything around it inverts and it doesn't move.

The palette underneath it is the real statement. --chart-1 through --chart-5 are oklch(0.87 0 0), oklch(0.556 0 0), oklch(0.439 0 0), oklch(0.371 0 0) and oklch(0.269 0 0). Chroma zero, all five. We ship a charting palette with no colour in it, for a product about markets, in a category where every competitor's screenshot is a wall of red and green. Grey is not a lack of a decision here. It's the budget: if the chart is grey, then the one thing wearing colour is the thing we want read first.

4 / 6

Dash and gap, in pixels

strokeLinecap round, which is why it's SVG

#266df0

Primary, byte-identical in light and dark

the only hex in a file of oklch

0

Chroma on chart-1 through chart-5

oklch(0.87 0 0) down to oklch(0.269 0 0)

Three numbers that decide most of how the site looks.design tokens

The blue gets spent in one animation and it's the one I care most about. A row arriving live mounts with animate-feed-row-highlight, which is color-mix(in oklab, var(--primary) 14%, transparent) fading to transparent over five seconds, ease-out, running once per element. Fourteen percent. We started at forty in November 2024 and it made a busy minute look like an alarm going off, which is precisely the feeling a trader is already having and does not need furniture for. Because the animation runs per element rather than per list, a burst of eleven items fades out raggedly instead of all at once, and that raggedness is the closest thing the interface has to a sense of time passing.

The rest of the surface is shadcn with "style": "radix-nova" and "baseColor": "neutral" in components.json, Tabler for icons, and a radius ladder derived by arithmetic from a single token: --radius: 0.45rem, with sm through 4xl computed as multiples of 0.6, 0.8, 1, 1.4, 1.8, 2.2 and 2.6. Nobody argued about 0.45. It was 0.5 for a fortnight and then it wasn't, and neither Martin nor I can now reconstruct who changed it.

Here's where the discipline breaks, and it breaks in the most visible component we own. The verdict badges are emerald, amber and rose. Actionable is emerald at 60 and above, Watch is amber down to 35, Stale is muted grey below that, and the sentiment badge runs emerald and rose off the sign of short_direction. Every rule above says those should have been the blue and two neutrals. They aren't, because we watched people scan a ninety-row table and a hue boundary is read pre-attentively while a lightness boundary is read by looking. Nine grey badges are nine things to inspect. I don't like that it's inconsistent. I like it more than the version that was consistent and slower to read, and I'm not going to pretend the system is purer than it is.

Where the drawing stops being honest

Three costs, all of them ones I'd rather write down than have someone find.

Dark mode is the big one. --border in .dark is oklch(1 0 0 / 10%), ten percent white, and the guide lines are drawn in text-border. A one-pixel dashed stroke at ten percent opacity on a near-black background is, on a laptop at half brightness in an office at four in the afternoon, essentially not there. The blueprint is a light-mode design that survives in dark mode as a rumour. Roughly two thirds of our app sessions are dark. So the aesthetic I've spent this article defending is invisible to most of the people using the product most of the time, and the fix isn't to raise the opacity, because at fifteen percent the borders around cards start reading as boxes and the calm goes.

The second is that the apparatus is desktop-only by construction. The dotted texture strip on this article's hero is hidden ... lg:block, so below 1024 pixels it doesn't exist, and the hatched gutters on the careers page shrink from lg:w-28 to w-6. Right call for a 375-pixel viewport. It also means a reader on a phone gets a clean, ordinary, slightly anonymous page.

The third one is a small lie I want to correct. That [01 / 15] in the corner looks like a plate number on a drawing set, and it isn't. It's the article's index in a newest-first list, String(index + 1).padStart(2, "0") over posts.length. Publish anything and every older article's number moves. The label whose entire job is to imply a fixed position in a catalogue is the one element on the page that can't hold still. There's also a cover key wired through three files that no article has ever set, so the code path that renders a hero image has been dead since the day it was written, and the blog index quietly draws a collage instead.

None of that is what I'd have written a year ago, when the first marketing site had a serif headline face and a second amber accent for anything mid-confidence. Both went in March 2025, in the same afternoon, and the page got quieter and better. What I keep relearning is that the restraint has to be enforced by something structural, a token you have to go and change, or it leaks back in one component at a time.

The guide component still lives where it was born, under the careers pages, which is an absurd home for something the blog and the platform pages both lean on. --primary is still the one token nobody can reason about, because it isn't expressed in the same colour space as everything around it. And that index label renumbers itself when a new post lands, which is exactly what a drawing sheet must never do. None of the three is hard. All three have been true for a year, which tells you something about how a system stays honest: not by being designed well once, but by someone being annoyed enough, often enough, to go back.