Personal site for Saidev Dhal — one page and a writing section over an animated Paper Shaders backdrop.
Built with Next.js 16 (App Router), React 19, shadcn/ui on Tailwind v4,
@paper-design/shaders, and Fumadocs for the writing.
Type is Inter Tight with JetBrains Mono for code, with letter-spacing tightened across the scale for a denser setting.
bun install
bun dev # http://localhost:3000
bun run build # typechecks, then builds
bun startlib/site-config.ts holds identity, every URL, the "Now" prose and the
footer. Long-form content is MDX: posts in content/writings/, projects in
content/projects/. A case study is prose and does not belong in a TypeScript
file, so the project list is no longer in the config.
Prose that contains inline links is written as arrays of segments so it can stay in the config rather than moving back into JSX:
[
"I build ",
{ text: "Sapient", href: links.sapient },
". It has ",
{ stars: true }, // live star count for flagship.repo
" stars.",
];components/segments.tsx renders those.
-
app/page.tsx— the home page, a server component. It fetches the flagship repo's star count from the GitHub API (revalidated hourly, falling back to the last known value) so the number in the copy stays current on its own. -
app/layout.tsx— metadata plus an inline script that resolves the theme before first paint, so a dark-mode visitor never sees a flash of light paper. -
components/backdrop.tsx— lazy-loads the shader withssr: false. Without a WebGL context it falls back to a static CSS dot grid. -
components/backdrop-shader.tsx— the live backdrop.activeShaderat the top chooses which one;Dithering(simplex, 8×8 Bayer) today, withGrainGradientandNeuroNoisepresets kept beside it so switching is one line.colorBackis transparent so only the ink draws and the paper shows through; the radial falloff is a CSS mask on.backdrop__field.Two dials control how present it is:
--backdrop-opacityinglobals.css(0.30 light / 0.26 dark, and 0.16 behind long reads) and the shader's ownintensity. Both are deliberately low — the stock GrainGradient presets sit atintensity: 0.5, which reads as a poster gradient rather than a texture. -
app/globals.css— the layout is hand-written CSS with custom properties. shadcn's design tokens are remapped onto that palette rather than kept on their own neutral ramp, and its dark variant is repointed from.darkto[data-theme="dark"], so both systems read one switch.
A warm neutral (Tailwind's stone family): #fafaf9 paper on #292524 ink,
with the dark theme built from the same family's dark end (#0c0a09 /
#fafaf9) so the two read as one design.
--paper is duplicated once outside the stylesheet, in lib/theme.ts, because
<meta name="theme-color"> has to be set from JS. Keep the two in step.
One MDX file per project in content/projects/, rendered at /projects/[slug]:
---
title: Sapient
description: One line, shown in every listing.
url: https://sapient.openhorizon.so
repo: https://github.com/SkidGod4444/sapient # optional
stack: [Rust]
year: "2026"
order: 1 # lower sorts first
draft: false
---The body is optional and currently empty on all four. Frontmatter alone renders a complete page — title, lede and a facts table of year, stack, live link and source — so a project without a write-up still looks finished. The case studies are yours to write; nothing here invents one.
Home and /projects rows link to the project's own page, not straight out to
the live site; the outbound link lives on the page.
Posts are MDX in content/writings/, served at /writings. Frontmatter is
validated at build time by source.config.ts:
---
title: A post
description: One line, shown in the listing.
date: 2026-07-28
draft: false # optional; true keeps it out of the listing and the build
---Only the Fumadocs content layer is installed — fumadocs-mdx for parsing and
fumadocs-core for the page loader and Shiki. fumadocs-ui is deliberately
absent: it ships a docs shell with a sidebar and its own palette, and posts here
render in the site's own layout. <Mdx /> gets no components prop, so you get
plain h2/p/pre that .prose in globals.css styles.
Newest post first, by frontmatter date. The three most recent also appear on the
home page. Each post gets a contents list above the article on small screens
only. .source/ is generated at dev/build time and is gitignored.
Code blocks: Shiki runs with defaultColor: false, emitting --shiki-light and
--shiki-dark as CSS variables, and globals.css selects between them on
[data-theme] — not .dark or prefers-color-scheme. Only Shiki's syntax
colours are used; its theme backgrounds are dropped, because github-dark's
blue-grey surface clashes with the warm paper. The surface comes from
--code-surface instead.
app/sitemap.ts,app/robots.ts— generated; robots excludes/shader-lab.app/feed.xml/route.ts— RSS,force-static, advertised viaalternates.typesso readers autodiscover it.app/opengraph-image.tsxand the two underapp/writings/— social cards generated withnext/og, one per post, prerendered at build time.lib/og.tsxholds the shared template; it reads TTFs fromassets/because satori cannot parse woff2 and a build should not need the network. Note that satori rendersradial-gradientwith a hard edge, so the card wash is linear.components/structured-data.tsx— JSON-LD.Person+WebSiteon the home page,Blogon the index,BlogPosting+BreadcrumbListon each post. The person carries an@idthe other schemas reference rather than repeat.- Root metadata sets a title
template, so child pages supply only their own title.robots.googleBot['max-image-preview'] = 'large'is what lets Google show the full card instead of a thumbnail.
Traffic is measured with @vercel/analytics, mounted once in the root layout.
It no-ops off Vercel, so local runs and other hosts stay clean.
siteConfig.sameAs currently points at link shorteners for X and LinkedIn.
Search engines follow them, but canonical profile URLs are stronger signals —
worth replacing when convenient.
Working tools, indexed at /lab. All are noindex, listed in robots.ts
disallow, and linked from nowhere on the public site.
/design-lab— every palette token as a swatch over a checkerboard (so translucent ones read honestly), the type scale, a prose specimen and every button variant. This is how you catch a token that only misbehaves in one theme, or a warm grey that turns brown at full fill./shader-lab— every Paper shader candidate in the site's own ink and paper. Tiles mount their WebGL context only while near the viewport, because browsers cap live contexts at around sixteen and the grid has more tiles than that.
When adding a lab route, add it to the disallow list in app/robots.ts too.
.profile__shell draws a vertical hairline at each column edge via
::before/::after, so every horizontal border on the page terminates on the
same two lines. They are pseudo-elements of the column rather than a fixed
overlay, which means they inherit its exact width — alignment holds without any
measurement being repeated. --grid-line is set per theme; a hairline tuned for
the dark paper is invisible on the light one.
- The theme storage key lives in
lib/theme.ts, deliberately outside any"use client"module — a server component importing a const from a client module gets a client reference, not the string. - Paper Shaders parses
rgba()and 8-digit hex but not the keywordtransparent, which it rejects at runtime. - The shader ink in
lib/theme.tsis near-neutral on purpose. The stone steps the text palette uses (#78716c/#a8a29e) are a visible taupe: the backdrop hides that at 26–30% opacity, but any shader that fills large areas — as most of the/shader-labtiles do — renders it as outright brown. - NeuroNoise adds light, so it needs different
brightness/contrastper theme, not just a different ink — on near-white paper added light flattens the filaments into the background. - Inter Tight is loaded with real italics on purpose:
font-synthesis: nonemeans a missing italic face renders<em>upright and emphasis silently disappears. ::selectionsets background only, and deliberately nevercolor. A selection that setscolorflattens every token in a code block to one shade, because::selectioninherits from the parent's selection rather than the element's own text colour — so evencolor: inheritthere resolves to the selection ink, not the syntax colour.- Tailwind's preflight strips list markers and sets
cursor: defaulton buttons;globals.cssputs both back. blogPostsfrom.source/serveris an array, solib/source.tsuses the standalonetoFumadocsSource(pages, metas). The.toFumadocsSource()method in the install guide only exists on thedefineDocswrapper.- ESLint ignores
.source/; the generated files carry their own@ts-nocheck.