design
How I design this site
Every decision here was made at least twice: once quickly, and once after the first version felt wrong. This is the record of what I settled on and why. It is not a style guide. It is closer to a set of running notes from someone who keeps rebuilding the same site until it stops bothering him.
Valyrian Steel and the one accent
Most of the site runs on nine shades of grey. Not because grey is safe, but because limiting colour to a single role gives it weight. Five semantic tones live alongside the neutral ramp for callouts, presence states, and the command palette. Each semantic colour only appears in context: paired with copy, position, shape, or an icon. Colour is never the only signal.
The nine greys are OKLCH at hue 240. Plain sRGB neutrals warm as they approach white, leaving an unintentional yellow cast on light surfaces. OKLCH at hue 240 stays perceptually neutral across the full ramp: the same temperature of light at every step. Light mode uses step 1 as canvas and step 9 as ink. Dark mode reverses them exactly, and the same hierarchy holds.
I call these greys Valyrian Steel. I like Game of Thrones, and the name stuck.
Five semantic tones carry intent across the palette: Green for success and availability, Amber for warnings and winding down, Blue for information, Violet for highlights, and Red for caution, error, and off-hours. Each appears only in context, always paired with copy, position, shape, or an icon.
available, success, live endpoints
warning, winding down
information, links
important, highlight
caution, error, asleep (off-hours)
Each swatch shows the light-mode value on the left and the dark-mode value on the right. The hue stays constant; only the lightness shifts.
Three typefaces, one job each
Inter carries navigation, prose, and UI. Newsreader Italic gets one serif moment: the naming note for the grey scale, literary asides, and quote attribution. JetBrains Mono is for code, tokens, hashes, timestamps, and measurements only. Each font stays inside its lane, which means each one can lean into what it does well.
Emphasis here comes from value, not weight. A word in ink against a sentence in ghost does what italic or bold usually does, without adding typographic clutter. When weight does change, it changes in deliberate steps. Click through them below.
I work from home; on a countryside region wrapped in coconut groves, close enough to the Western Ghats that the weather has opinions.
The exact values are in design.md. This page is the part I rewrite when the site teaches me something.
Newsreader Italic · 400 · 1.55 leading. One serif moment, used sparingly.oklch(0.58 0.2 25)oklch(0.58 0.2 25)
Left shifts 1 px down to align optically with Inter. Right sits on the natural baseline. Without the correction, monospace sits slightly high and reads as floating.
One rail, every page
Header, content, and footer share one fixed rail: clamp(680px, 52vw, 800px) wide, with 20 px gutters on each side. At 800 px and around 64 characters per line, a paragraph sits at the comfortable top of the reading range. Wider feels like a newspaper column that requires head movement; narrower starts to feel cramped on a desktop screen.
The same rail for every route. No "wide" library view, no "narrow" notes layout. Consistency means the reader's eye always starts from the same place.
A comfortable line length lets the eye glide effortlessly across ten to twelve words before sweeping back to the start of the next line. When a measure is too wide, the return sweep becomes a conscious search; when too narrow, the rhythm fractures into constant stops and starts. The 64-character rail keeps reading continuous and effortless.
Typography is ergonomics for the eyes: wider is not better, and narrower is not tidier.
Below 608 px the layout shifts: the top navigation disappears and five fixed tabs appear above the safe area at the bottom. The body reserves space beneath the bar via --mobile-chrome-clearance, so the last line of any page is never hidden behind it. Section spacing is larger above a heading than below it, which gives the eye a clear signal that a new topic is starting.
Seven durations, three springs
Every animation on the site draws from seven durations, each tied to how much attention the movement should ask for. 70 ms for the press itself, faster than the eye expects, which makes a control feel physically responsive. 110 ms when only colour changes and nothing moves. 120 ms for a departure, always shorter than the matching arrival. 150 ms for movement under 8 px, 200 ms for a component moving, 240 ms for something arriving that was not there, 280 ms for an overlay that deserves a breath.
Releasing a press is not a duration, it is a spring. A small capsule overshoots a little and settles; a wide row or card comes back without a wobble, because on a wide target a wobble reads as a mistake. Click a circle to see how a duration feels. The same dot, seven different paces.
--dur-press70mspress feedback--dur-tint110mscolour only--dur-exit120msdeparture--dur-nudge150msmovement under 8px--dur-shift200mscomponent movement--dur-enter240msarrival--dur-overlay280msfull-surface overlaysRoute transitions use blur only: never opacity, never position. Any opacity below 1 during the route change causes a visible canvas flash, even for a single 16 ms frame. Blur keeps the element fully opaque while masking the content change. The outgoing page blurs in 90 ms; the incoming page clears in 200 ms. The header stays fixed throughout.
With reduced motion enabled, all travel is removed. What remains: colour transitions, opacity on show and hide, and focus feedback. The experience is complete without animation.
A tiny Web Audio engine
The site has fourteen interaction sounds. None of them are audio files. Each one is a few lines of Web Audio API: create an oscillator, set a frequency, ramp the gain down to near zero. The complete engine is a single TypeScript module with no runtime dependency, gentle organic micro-pitch variation to prevent auditory fatigue, and a master gain of 0.09 - quiet enough that it reads as a texture rather than a notification.
Sound is on by default, controlled by the toggle in the footer, and persisted in localStorage. Open tabs stay in sync via the storage event. The AudioContext is only created from a real user gesture - before that, any call to playAccent() is a no-op.
Sound carries no meaning alone. Every accent is redundant with a visual change: the theme toggle shows the sun shift before any tone plays. Someone with sound off misses nothing. The mute confirm plays before the gate flips, so the last sound you hear when muting is the descending toggle-off chord.
The details that compound
Individually, none of these are remarkable. Together, they account for most of the time I spend on this site.
Pills, at five heights
Every single-line control is a pill, at one of five heights: 48 for the one primary action, 40 for segments and the footer, 32 for inline actions like copy, 28 for tags, and 20 for a badge, which only states a fact and is never pressable. Anything with more than one line is a rounded rectangle whose radius nests inside its padding. Glass is a recipe, not a blur. In the light theme it is the header nav's surface and nothing else: the canvas, one solid hairline and one soft shadow, with no sheen, no gradient rim and no specular edge, and hover is a tint and a firmer hairline. Dark keeps a sheen, an opaque fill, a gradient rim, a specular edge and a shadow, painted once. Capsules, docks and cards carry no pointer spotlight; only tiles, pager cards and rows do. Real blur goes only where content actually moves behind the surface.
Every press goes down in 70 ms and springs back up, and none of it moves layout: a press or a state change never animates width, so the copy circle is the same size at rest, copied and failed. The one capsule that grows is an arrow capsule on hover, by the glyph's width. State is never colour alone. Copy is a symbol and never a word: the glyph becomes a check that draws itself, or an alert. A tag's hash and a brand are marks, never letters; a soon tile is simply dim, with no badge.
Arrow direction
One glyph per meaning. On a labelled capsule the arrow is not there until you reach for it: hover or tab to the capsule and it opens by the glyph's width, the label stays put, and the glyph travels its last few pixels along its own direction. Touch never shows it. Rows, tiles and icon-only controls keep theirs visible. The arrow on an inline external link is the same idea inside a sentence: it takes no room at rest, so a justified paragraph holds still, and opens with its space when the paragraph or the link is under a fine pointer or has keyboard focus. Hover or tab through these; this is the complete set.
- The libraryArrowRight: another page on this site
- Back homeArrowLeft: back, or the page before. The icon sits on the left.
- SourceArrowUpRight: leaves the site, opens a new tab. Nothing else gets it.
ChevronDown: opens in place
Links without underlines
Inline links are ink against the paragraph's grey and take the paragraph's own weight at rest, with no underline and no wash behind them: this /design.md is one. Hover the paragraph, not just the link, and every link in it lifts to 550 together; a fine pointer only, so touch rests as it is. In long-form notes the body text is ink already, so colour cannot find a link: there they rest at 500 and lift to the same 550. Because weight moves, a hover can reflow a line; that is accepted.
Focus
A 2 px ink ring that lands from 8 px out and closes to 3 px, shown only for keyboard navigation via :focus-visible. Mouse clicks suppress it. Controls that sit in a group, like rows and segments, keep the ring flush so it never collides with a neighbour, and it hugs an inline link's own box. The 32 and 28 px capsules carry an invisible 44 px hit area, so their visible size is not their target size.
:focus-visible.Hover paragraphs
The homepage intro and the colophon use a hover effect: hovering a paragraph brightens the whole block from ghost toward ink. The keywords inside already sit in ink, so they do not change weight and nothing reflows. It rewards reading slowly. It is not applied globally, only in blocks where the density of information makes the interaction useful.
The typographic stream
List items across the site sit directly on the canvas, without boxes or dividing lines. Hovering a row lays a wash behind it that arrives in 110 ms and leaves in 220 ms, so moving down a list leaves a short afterglow while the other rows soften. A row that goes nowhere gets no wash and no arrow.
Selection
Regular selection is ink on canvas. Quote text uses the same ink-on-canvas treatment, keeping selection consistent across the page.
A live clock in the footer
The footer shows the current time in Pollachi, Tamil Nadu (Asia/Kolkata, UTC+5:30). Clicking it reveals your own local time and zone for comparison. The dot inside the clock capsule is the presence indicator: a small coloured circle that changes based on the current hour in IST.
Three states, driven by the hour. Available from 08:00 to 20:00. Winding down from 20:00 to midnight and from 06:00 to 08:00. Asleep from midnight to 06:00. Each state gets a different colour token and a label drawn at random from a small pool, so the wording stays fresh across visits.
Reading current state...
Every part of the page that shows presence subscribes to one presence:change custom event. The footer clock dispatches it once per second; the event only fires when the state actually changes, not every tick. The dot, the map pin tooltip, and the contact CTA line all read from the same source with no duplicated logic.
When I am asleep, a data-accent="off-hours" attribute is applied to the root element. This desaturates the red accent slightly via a CSS override, making the whole site feel quieter. It is a small thing that is easy to miss.
The things you might not find
These are here because they should be, not because anyone asked. None of them affect the site's function.
The console API
On any page, open the browser console and type ashwin.help(). The object is read-only, frozen, and not enumerable on window.
» ashwin.help()» ashwin.status()» ashwin.source()» ashwin.secrets()ashwin.status() returns the current route, build commit hash, active theme, reduced motion state, and the live initialisation state of the Spotify embed and the MapLibre map. It is useful for debugging integrations without opening React DevTools, because there is no React.
The command palette
Open it with K or /, then try one of these.
› whoami› vanakkam› 42› sudo hire ashwinThe quote block
The homepage quote cycles daily based on a seed derived from the current date. Pressing J shuffles it immediately. There are enough of them that you might land on one you did not expect.
The clock
The footer clock shows my time by default. Pressing it switches to your local time and zone. Pressing it again returns to mine. It used to be a secret; now the tooltip says so. The dot inside it is the presence indicator, described in the chapter above.