Denis Stritar

Design tokens

The single source of truth is app/globals.css. This is the human-readable mirror. The Figma “Portfolio” library mirrors these too — keep all three in sync (see .claude/skills/sync-tokens-to-figma).

Theming model

The site has four time-of-day moods, selected by [data-theme] on <html>: morning, noon, evening, night. There is no separate light/dark — the moods are the theme. Every colour utility resolves through CSS variables that [data-theme] re-points, so one attribute flip re-themes everything.

“Auto” (the system preference) resolves the mood from the visitor’s local sunrise/sunset (geolocation) and falls back to clock buckets (6–11 morning · 11–16 noon · 16–20 evening · else night).

Colour palettes

Token (--color-*) morning noon evening night
fg (text) #2F2F2F #2F2F2F #F7F5F4 #F7F5F4
base (page bg) #F2EFEA #EDECEB #373330 #292623
btn / btn-fg dark rgba(47,47,47,.85) / #F7F5F4 same light rgba(247,245,244,.85) / #2F2F2F same
glass rgba(255,255,255,.75) same rgba(41,38,35,.75) same
glass-hover rgba(255,255,255,.9) same rgba(41,38,35,.9) same
tile-media #F7F6F7 same #5B5855 same
hairline rgba(47,47,47,.12) same rgba(247,245,244,.14) same

Glass surfaces sit at 75% opacity; clickable glass goes to 90% on hover (bg-glass-hover) — mirrors the Figma tile behaviour.

Stale: the table above still uses the four time-of-day moods. globals.css has been plain light/dark for a while — read light from the noon column and dark from the night column. Needs a proper rewrite.

surface-hover (bg-surface-hover) — hover fill for transparent icon chrome, i.e. the header buttons. Figma component 81:277 state=hover shows a flat #f7f6f7, an unbound raw hex in the file; bind it when syncing back.

  light dark
--surface-hover rgba(255,255,255,.5) rgba(247,245,244,.09)
over flat --bg-base #f6f6f5 — within 2/255 of the comp — (no comp)
over the real gradient behind the header ~#ebeaee, +20/255 ~#3b3835, +19/255

Deliberately translucent rather than the literal hex: the header floats over the WebGL model, and an opaque chip would punch a rectangle out of it on hover. Light is set for fidelity to the comp; dark has no comp, so it is matched on delta instead — both lift ~+19/255 off what is actually behind them, which is the gradient backdrop, not flat --bg-base. --glass-hover cannot stand in: at 0.42 over a dark ground it is the background, i.e. invisible.

tile-media (bg-tile-media) — the empty-tile fill in the bottom rail (Figma node 79:253), and later the well behind each tile’s image or video. The comp measures a flat #f7f6f7 — the same raw hex as --surface-hover above, but here it is used literally. This is the one card surface on the site, and it is opaque.

  light dark
--tile-media #F7F6F7 — the comp’s hex, exactly #5B5855
the ground actually behind the rail ~#d6d5db ~#3d3a37
lift off that ground +33/255 +30/255

Light is the comp value with nothing composited on top. Dark has no comp, so it is matched on delta: what is behind the tiles is the bottom of the gradient backdrop, not flat --bg-base, and #5B5855 steps off it by the same amount #F7F6F7 steps off the light ground. #5B5855 also holds the palette’s warm ramp — R/G/B stepping +3/+3, the same as --bg-base (#292623) and the gradient’s bottom stop.

Opacity was traded for card presence, deliberately: the rail floats over the WebGL model, so the three tiles now occlude it. Note this is exactly the argument --surface-hover still runs the other way — the header chip stays translucent so hover doesn’t punch a rectangle out of the model. The two tokens used to share one rationale and no longer do; that divergence is intended, not drift.

This token was previously rgba(235,235,235,.25) / rgba(61,58,55,.25) and unused by any component — it resolved to roughly #ecebeb, a wash faint enough that the tiles were nearly invisible against the backdrop. It then ran as rgba(255,255,255,.5) / rgba(247,245,244,.09), which approximated the comp to within 2/255 in light (#f6f6f5) but still read as a wash rather than a surface.

panel (bg-panel) — the case-study reading surface (Figma node 97:313, a wireframe: the values below are the system’s, not measured). The .article-panel chrome in globals.css §4c reads it directly; bg-panel exists for anything else that needs the surface.

  light dark
--panel #F7F6F7 — the same hex as --tile-media #33302D

Light reuses the site’s one card hex on purpose — the panel is a card surface at page scale — but stays its own token because the two jobs can legitimately diverge (a media well vs. a page of prose). Dark does not reuse --tile-media’s #5B5855: that value is matched on delta for a small well and a full reading surface that light would glow. Instead it is --bg-base (#292623) lifted +10/255 on the palette’s warm R−G=3 / G−B=3 ramp — one card step off the page.

There is deliberately no --panel-fg: --text reads on this surface in both themes (#2f2f2f on near-white, #f7f5f4 on the warm dark). Muted ink inside the panel is color-mix(in srgb, var(--text) 60%, transparent), so it tracks the theme by construction.

The panel is opaque and must stay free of backdrop-filter descendants: it carries .cloud-hide, and an ancestor below opacity 1 forms a backdrop root (see the glass gotchas below).

tile-scrim (tile-scrim utility, bg-tile-scrim) — the wash that frosts a tile’s media on hover and focus, Figma node 88:307 state2. Paired with --tile-scrim-glow, the halo behind the label (text-shadow-tile-label).

  light dark
--tile-scrim rgba(255,255,255,.65) — the comp’s value, exactly rgba(41,38,35,.65)
--tile-scrim-glow #FFF rgba(0,0,0,.55)

The alpha carries over from light to dark unchanged, which is the one place this token departs from how --tile-media and --surface-hover are derived. Those two are matched on delta because they sit over a gradient whose value differs per theme; the scrim sits over photography, which has no theme, and 0.65 is what makes an image legible-but-recessive either way. Only the base hue follows the palette (the same warm dark --glass uses).

The glow inverts rather than carrying over: it exists because 0.65 white over an image is not a uniform ground, and a glyph landing on a light patch of photo needs its own separation. Light text on the dark scrim needs the same help from the opposite direction, so the sign flips with the fill.

Unlike --surface-hover, the scrim can afford to be this heavy — what it obscures is the tile’s own image, and obscuring it is the point.

Gradient backdrops (radial over linear, non-interpolable → crossfaded by two layers):

mood radial linear
morning #ffffff → #F4E9D8 #F0D8B8 → #D7BFA6 → #9A8C7A
noon #ffffff → #D6D5DB #E8E8EA → #D9D7D5 → #B8B5B2
evening #FFD8C2 → #B28B7C #4A3F38 → #3C3530 → #2F2A26
night #4A4A52 → #2E2C2A #222324 → #292623 → #3D3A37

Typography

Families — Outfit (Google, variable 100–900) for everything: font-heading, font-body, font-sans all point at it. Roboto Mono (font-mono) for code and eyebrow labels.

Fluid scale

One scale, named semantically. Every size is a clamp() interpolating between the viewport bounds 390px → 1440px — the grid-page mobile boundary and --container-max, so type stops growing exactly where the container does. Outside that range it pins to the endpoints. There are no type breakpoints.

Utility 390px 1440px line-height Used by
text-caption 11 12 1.4 eyebrows (404 404 label)
text-label 13 14 1.2 h6, Button, .skip-link
text-body 14 16 1.55 p, li, h5, Button size="lg"
text-lead 16 18 1.5 h4
text-heading 18 20 1.3 h3
text-title 20 24 1.25 h2
text-display 24 32 1.25 h1, the hero tagline
text-display-lg 32 48 1.1 404 headline

The endpoints are the values the old stepped scale already used, so 390px and 1440px render identically to the previous mobile/desktop steps — only the middle is new. text-display’s 1.25 is the comp’s 30/24 exactly, and being unitless it holds at every step of the clamp.

Three rules behind the implementation:

  1. --text-*: initial clears Tailwind’s built-in ramp. text-xs … text-9xl no longer exist — every size must come from the table above. The reset is a startsWith("--text") prefix match against an allowlist, so text-shadow-*, text-indent, text-decoration-* and text-underline-offset survive, and --color-* is untouched (text-fg is a colour utility and still works). It must be the first declaration in its block — clearing runs in declaration order. The palette’s own --text is safe only because it sits in a plain :root rule; never move it into a @theme block, the prefix would eat it.
  2. The scale lives in a plain @theme, not the @theme inline block. inline freezes the value into the utility, so no var() is emitted and the :root declaration becomes contingent on a reference surviving elsewhere. @layer base reads these with var(), so they need a guaranteed :root.
  3. Slope terms are rem + vw, never bare vw. A bare-vw size is completely inert to the reader’s browser font-size setting — a hard WCAG 1.4.4 failure. With the rem term the whole scale tracks it: at root 20px the hero reads 30 → 37px instead of 24 → 32px. Be accurate about the limit, though — a 200% text-only resize grows a token by ~2x at narrow viewports (where the clamp sits on its rem min) but only ~1.66x at 1440px, because the vw component does not scale. Every token still lands at or above twice its own minimum. That gap is inherent to fluid type; the alternative is all-rem bounds with no fluidity at all.

@layer base maps h1h6, p and li onto the scale (h4h6 previously had no size at all and fell through to the UA defaults). It restates both font-size and line-height per element: Tailwind’s paired --text-x--line-height modifier is applied by the text-x utility only, so a bare var(--text-x) in plain CSS pulls the size alone.

Weight is deliberately not paired into the tokens. The same size legitimately appears at two weights — base h1 is 700, the hero tagline is 400 — so a --text-display--font-weight would contradict one of them. Weight stays orthogonal, via font-normal / font-bold.

Trackingtracking-eyebrow (0.3em) for mono uppercase labels. Tailwind’s widest default is 0.1em, nowhere near enough.

Measures scale with the type. The hero uses max-w-[15.833em] — at 24px that is 380px exactly (the comp), and being em it grows with the clamp, so the tagline holds its line count instead of reflowing from three lines to four.

Layout & surfaces

Motion

Everything on the site is a hand-rolled CSS transition except one entrance, because a transition has no “before” state to leave from on mount.

.reveal — opacity + an 8px lift, 600ms cubic-bezier(.22,1,.36,1). 600ms matches .bg-layer’s crossfade and the hero canvas fade, so the page settles on one clock. Stagger with [--reveal-delay:80ms], travel further with [--reveal-from:12px]. Header uses 0 / 80 / 140ms; the <h1> 220ms.

Three rules, all load-bearing:

  1. animation-fill-mode: backwards, never both. both retains the final keyframe indefinitely, and animation declarations outrank inline styles — so a retained transform: none would permanently override any transform set later, from JS or otherwise. backwards covers the delay window and then lets go.
  2. Never declare opacity: 0 statically. Reduced motion, no animation support, or a typo’d delay then degrade to already visible rather than to a blank header.
  3. Never declare --reveal-delay / --reveal-from in .reveal. That rule is unlayered and would beat the layered Tailwind utility meant to set them. Read them through var() fallbacks only.

Reduced motion is handled in the plain-CSS @media (prefers-reduced-motion) block, not via motion-reduce: utilities — a utility ties with .reveal on specificity and would win or lose on source order.

The curve is ease-out-quint (--ease-out-quint), the utility form of the cubic-bezier(.22,1,.36,1) written out above. Rules inside globals.css still spell it literally — they are animation / transition shorthands, where a var() is more fragile than it is worth. Use the utility in components.

The 600ms clock covers entrances and mode changes, not hover. Interaction feedback runs at 300ms — the tile scrim and label, matching Button. Hover held for 600ms reads as lag rather than as one clock.

A hover state that only crossfades opacity gets no prefers-reduced-motion branch. That block exists to remove motion; a fade with no travel is feedback, and taking it away leaves the state change unannounced.

Article mode moves on the View Transitions API (globals.css §4c) — the one place a layout change is animated, because no transition can tween between two grid templates. Same clock and curve: every ::view-transition-* group runs 600ms ease-out-quint. The rules that make it behave:

.article-hide is .cloud-hide keyed to [data-article] — same 600ms opacity + discretely-interpolated visibility pair, same inert-in-the-same-tick handling in the provider. The selected tile’s 50% dim is interaction feedback, so it runs on the 300ms clock with no reduced-motion branch.

Glass

The glass utility is a port of the Figma GLASS effect on node 68:528 (“intro”). Figma exposes only the frost radius over MCP, so the rest was recovered by decoding the 1:1 render and sampling it — the node sits on a flat canvas, which isolates the light and bevel component exactly.

Figma param Value Measured on the render CSS
Frost 43 — (flat backdrop) blur(var(--glass-blur, 43px))
Light −45° @ 80% 1px rim, white-over-fill α 0.69 → 0.88 top-left → bottom-right (mean 0.78) two 1px inset shadows, --glass-rim-dim top-left + --glass-rim-lit bottom-right
Depth 20 inner band ~15px deep --glass-depth: 11px (offset == −spread ⇒ the band runs to ~15px)
Splay 100 fully soft falloff, no step blur == 2 × depth
Refraction 80 no CSS equivalent. The bevel stands in for it
Dispersion 50 ±1 LSB, inside 8-bit noise dropped
top+left −4/255, bottom+right +4/255 the two inset bevel shadows
Fill rgba(255,255,255,.4) interior dead flat, no tint --glass--color-glass
Drop shadow none none none — do not add one

Tokens: --glass / --glass-hover (fill) · --glass-rim-dim / --glass-rim-lit (rim) · --glass-bevel-shadow / --glass-bevel-light (bevel) · --glass-blur (frost radius). Only the fills are aliased into @theme; the rest are read inside the utility, never applied as bg-*.

Deliberate departures from the measurement

The surface no longer matches node 68:528 in three respects. These are decided, not drift — do not “correct” them back toward Figma:

Dark-theme rim and bevel values are tuned by eye — there is no dark Figma node to measure.

Gotchas

tile-scrim — frost inside a card

A second, smaller frosted surface: blur(--tile-scrim-blur) over --tile-scrim, with no rim and no bevel. glass is a slab floating over the page; this is a wash inside a card, and an inner bevel on something already hard-clipped to 8px reads as a second border. Used only by the tile rail’s hover state.

--tile-scrim-blur is 15px and deliberately not --glass-blur. That token is registered and inheriting so a cluster can be ramped from an ancestor; declaring it here would shadow the ramp for everything inside a tile — the gotcha above, from the other side. 15px is sized to the media it frosts and has no relationship to glass’s 43.

Always pair it with tile-media-fill on the media underneath. A blur has to gather pixels from beyond each output pixel, and where the backdrop simply ends backdrop-filter resolves back toward the unfiltered source. With the media flush to the tile the result is a sharp, readable band right around the perimeter, roughly 2× the blur radius wide, framing a properly frosted middle. tile-media-fill bleeds the media out by that same 2× so there are real pixels under the scrim’s edges, and the tile’s overflow-hidden clips the excess. The cost is a permanent crop — the media covers a box ~18% larger than the tile — so frame accordingly, and if you raise --tile-scrim-blur the bleed follows it automatically.

This is also the site’s one backdrop-filter under an opacity fade. Both .reveal and .cloud-hide fade ancestors of the scrim, which is exactly the gotcha above. It survives, for a specific reason: the scrim samples its own sibling <img> — content inside the fading subtree, which is still there at any opacity. The trap catches surfaces sampling the page outside the fading element, and that content really is gone.

So it does not generalise: glass proper still fails under a fade, and the gotcha above applies verbatim everywhere else on the site.

Measured, not reasoned. Hovering a tile with the rail’s <section> pinned at opacity 0.99 and at 0.6 renders the frost in full, and the render is byte-identical with isolation: isolate removed from the tile — so the isolate on the <a> is not what saves it and should not be credited with the job. It is there for stacking, nothing more. Chromium only; re-check if Safari ever matters.

Tailwind utility cheatsheet

text-fg · bg-base · bg-btn / text-btn-fg / bg-btn-hover · bg-glass / bg-glass-hover · bg-surface-hover · bg-tile-media · bg-panel · border-hairline · font-heading / font-body / font-mono · text-caption / text-label / text-body / text-lead / text-heading / text-title / text-display / text-display-lg · tracking-eyebrow · text-shadow-tile-label · ease-out-quint · glass / glass-sm · tile-scrim / tile-media-fill · grid-page / page-inset / page-measure / tile-rail · reveal. Opacity modifiers work as usual (text-fg/70).

Plain classes from §4c–d (not Tailwind utilities): article-rail / article-hide / article-panel / article-close-row / article-item · article-prose and its slots article-lead / article-meta / article-quote / article-grid-2 / article-stats.

Tailwind’s default font-size ramp (text-xstext-9xl) has been removed — see Typography above. text-sm, text-2xl etc. are no longer valid and will silently emit nothing.