Ink on paper, and nothing that pretends otherwise.
This site used to be near-black, with thin hairlines and soft glows. It is a warm sheet now, with hard rules, offset shadows and a marker. Everything below is the live system, rendered from the same stylesheet the rest of the pages use - so when one of them changes, this page has already changed with it.
Nothing is pure white or pure black. #F4F1E9 and
#14130F.
Borders are 2-3px of solid ink. No hairlines, no translucent whites.
Shadows are hard offsets in ink. Never blurred, never coloured.
Two sheets and some grain
The page is --paper. Anything laid on it - a card, the
footer, a well - is --sheet, which is the same stock a
shade darker rather than a tint of the accent. Over both, two fixed
overlays: a generated grain at 16% multiply, and four nearly-flat
gradients standing in for folds, held under 4% contrast because a
crease you can actually see reads as a dirty screen.
Both textures are feTurbulence data URIs in
base.css, so the whole surface costs no network requests
and no image bytes. They are also position: fixed: a
texture locked to the document slides as you scroll, which is the tell
that gives away a fake paper background.
Four tokens and six pigments
Every ratio below is computed from the hex beside it at build time,
against --paper unless the label says otherwise. The
palette itself is imported from src/data/palette.mjs,
the same literal tailwind.config.mjs reads - this page
holds no copy of it.
The stock
- paper
#F4F1E9The page. Warm newsprint, never #fff - pure white has no grain to catch.
- sheet
#E9E3D5A second sheet laid on the first: cards, wells, the footer, the shaded fold.
- ink
#14130FText and every hard rule. Never #000 - pure black reads as a hole in the page.
- mute
#57534ABody copy and meta. The one token tuned to a threshold rather than a look.
- void
#06070AKept for the 3D, which still clears to it, and for deep shadow. Not a page colour.
The pigments
These are pigments in the literal sense: the 3D on the home page is
composited mix-blend-mode: multiply onto the sheet, so it
can only ever subtract. No part of a scene can be brighter than the
paper it is printed on.
- Aacyan
#00D2FFThe marker. Highlights, nav hover, the current page, selection.
- Aaviolet
#883AEAThe second marker, lightened to #C3A3F7 behind text. Skill bars.
- Aaindigo
#3A47D5Deep accent. The only pigment that clears AA on paper as text.
- Aamagenta
#E834C8Post art and the particle ramp.
- Aacoral
#FF6B5AThe third marker (.hl-coral), and the warm end of the ramp.
- Aamint
#7CF5C4Post art. The lightest pigment, so it only ever sits under ink.
Why cyan is the marker.
Ink on cyan measures 10.3:1, so a stripe can sit
behind body-size text and still clear AA. Ink on violet is only 3.4:1, which is why .hl-violet
lightens to #C3A3F7 (8.8:1) rather than
using the pigment straight.
Two faces, two jobs
Inter at 900 is the voice: a grotesque heavy enough to hold a marker stripe behind it. JetBrains Mono is everything functional - nav, labels, meta, and the body copy you are reading. The split is what keeps the page from reading as one long shout.
Headings
Every h1, h2, h3 and .display. Letter-spacing
-0.03em, line-height 1.02.
Nav, labels, body
15px at 1.7. The one exception is longform: posts and lessons switch to Inter, because a monospace face at three thousand words stops being a style and starts being work.
h1 / 2.75rem
h2 / 2rem
h3 / 1.35rem
body / 0.9375rem, mono, 1.7
eyebrow / 0.6875rem, 0.18em, uppercase
Printed
Printed
A turbulence mask with scattered transparent nicks, so the letterforms read as printed rather than rendered. Display sizes only: at body size the nicks land on stems rather than between them and it stops being texture and starts being a rendering bug. It fails safe - if the mask never parses, the text is simply solid.
A highlighter, not a background
.hl paints its stripe in a pseudo-element and runs an SVG
displacement filter over it, so the edges chew but the glyphs never
warp. The inset is asymmetric on purpose: a real marker overshoots the
word at both ends and rides slightly high.
The default is cyan, the second is violet, and the warm one is coral.
Links use the underscore, which grows from a 0.3em stripe to a full fill on hover - a background gradient rather than a pseudo-element, because the reveal animation opens a stacking context and a z-index:-1 stripe drops out of sight for exactly as long as the element is animating in.
Try selecting this sentence: ::selection is the marker
too.
One idiom, four weights
A bordered block sitting slightly off its own shadow. Everything that needs to look like an object is this: cards, the nav, buttons, the footer columns, the dossier on the contact page.
The shadow ladder
Offsets are never blurred and never coloured. The distance scales with how much the object is meant to weigh.
- 3px
.skill-chipSmallest object on the site. - 4px
.block-soft, .panelWells and inner cards. - 5px
.btn-solid, .btn-outline-softAnything you press. - 6px
.block-hard, .block-cardThe default object. - 7px
.header-blockSits above the page. - 8px
.closerReversed out of ink, so it needs more.
Things that press
Nothing on this site lifts on hover. A block slides into its own
shadow instead - the same distance the old translateY(-1px) travelled, but on paper a thing that rises has nowhere to rise to.
Both are 3px rules with a 5px shadow, dropping to 1px on hover.
.btn-outline-soft also fills with the marker.
The bar behind the label is the rating, wiped in on hover with a cyan-to-violet gradient. Printed tags, not frosted pills: a blur over a light sheet just produces a lighter, blurrier sheet.
An ink header bar with the label reversed out, then rows divided by 2px rules. Used wherever a handful of facts would otherwise be a paragraph nobody reads.
Short, and mostly optional
120ms ease-out, translate(4px, 4px), shadow down to
1px. Every interactive block.
.reveal ties an 18px rise to view() -
no JavaScript, no observer. Browsers without scroll-driven
animation land on the final frame, so unsupported means already
revealed.
Under prefers-reduced-motion the 3D engine is never
imported at all, and a footer toggle appears to opt back in.
The images are code
No image model, and no design file. Every card and hero on the site is
drawn by a script in these tokens, run by hand when the art changes.
The output is committed; the tooling is not - all four need
@napi-rs/canvas installed with --no-save.
-
scripts/og-card.mjsThe share card every page falls back to.
→ og-default.png -
scripts/course-card.mjsOne cover per course. Typographic, with the lesson rail along the bottom.
→ react-for-beginners.webp -
scripts/lesson-cards.mjsTwelve covers, titles read straight out of the lesson frontmatter.
→ lessons/react-for-beginners-NN.webp -
scripts/post-heroes.mjsWordless. The alt text carries the meaning, so a reworded sentence never costs a re-render.
→ five post heroes
Two constraints the heroes keep. They are wordless, so the alt text carries the meaning and a reworded sentence never costs a re-render. And the grain baked into them is in 2x2 blocks rather than per pixel - noise is incompressible by construction, and at one pixel it put 350KB on five images.
Six things that would put it back
Every one of these is something the site did before the redesign, so none of it is hypothetical.
- Pure white or pure black #F4F1E9 and #14130F White has no grain to catch and black reads as a hole punched in the sheet.
- Hairlines and translucent borders 2px and 3px of solid ink A 1px rgba white line on paper is a smudge, not an edge.
- Blurred shadows Hard offsets in ink A blur says floating pane. An offset says block printed slightly off register.
- backdrop-filter and frosted glass A solid fill and a rule Blur exists to push a busy background behind glass. There is no glass on paper.
- Rounded corners by default Square by default daisyUI is still loaded and ships its own .card with a 1rem radius - which is why the post cards are .block-card.
- Colour-transition hovers The press-down A border going from 10% white to 40% cyan is a few pixels of tint nobody has ever noticed.