Skip to content
MaXiMo000Public

About

Instruments of resolution — an interactive WebGL portfolio. React Three Fiber, one machined instrument that reconfigures per project.

Topics

Resources

Security policy

Stars

1 star

Watchers

0 watching

Forks

Latest commit

 

History

48 Commits

Folders and files

Repository files navigation

portfolio

An interactive WebGL portfolio for Ritish Saini — backend engineer, Postgres, security. Live at ritishsaini.tech.

Instruments of resolution. Every project here takes something messy and untrusted and returns something you can rely on. So the site is one machined instrument, under one lighting rig, that reconfigures itself per project.

The usual failure of a 3D portfolio is a different toy per section and no world. This does the inverse — continuity of world, variety of event. The object never cuts between sections; it transforms.

§ Project Mechanism, and what scroll drives
00 — sealed housing; eight wedges hinge open as 01 arrives inside them
01 carabiner ratchet — one tooth per step, hot sparks. Scroll back and the pawl lifts: a ratchet is released, never reversed
02 firedrill vessel — the struck teeth become rivets sealing a drum; a gauge needle searches, then only settles and lights once a restore has genuinely been read back
03 recur sorting rotor — recurring charges lock into rings, noise is flung outward
04 LabLedger spectrometer — bands snap to LOINC rows; one holds amber and escalates
05 QuizNest tumbler — five notched rings; the beam passes only when all align
06 AI-Recipe-Maker dosing manifold — five pistons fire in sequence into one output
07 Wizcommerce core sample — five barrels converge and lie down into one column; a sonde runs its length and each band takes the beam as it is read
08 what I reach for feeler gauge — the bands come off the column and swing out around one rivet. Seven leaves: one shape, ground seven ways
09 — the housing seals; ends where it started

Running it

npm install --prefix web
npm run dev --prefix web
Script Does
npm run build typecheck, bundle, SSR-prerender the markup into index.html
npm run shots screenshot every section headlessly, report clipped text
npm run fonts re-download the self-hosted woff2, regenerate src/fonts.css
npm run og re-render og.html → public/og.png

CI is gated by carabiner — this repository is scanned by the scanner in it, SARIF is uploaded to code scanning, and the drill re-runs weekly. Actions are pinned to commit SHAs with a read-only default token. See SECURITY.md.

shots exists because dev preview surfaces blank out on scroll, and headless Chrome without a GL backend silently falls back to the no-WebGL path — so you end up auditing the wrong page. Pass a viewport: npm run shots -- 1440 900. shots and og find Chrome per platform (web/scripts/chrome.mjs); CHROME_PATH overrides.

Design

Palette. Six tokens. The signal colour is light inside the scene, not decoration on the UI — which is what keeps this off the acid-accent-on-black pile.

Token Rule
--void #08090C ground
--chalk #EDECE8 primary type
--alloy #98A3B1 secondary type; the instrument's own value
--beam #86E9DE measurement light and live values only — never a border or a fill
--escalate #E8873B a human decides. Appears twice on the whole site
--hair rgba(255,255,255,.07) every rule and panel edge. Never brighter

Type. Archivo variable carries the identity through its width axis (62–125) — statements set wide, labels condensed. That is a typographic idea, not a font pairing. Instrument Serif italic is one gesture, reserved for human-judgment moments. JetBrains Mono for every number and identifier.

Contrast is bought with separation, not colour. White type is only 2.39:1 over bright metal, so no palette fixes copy sitting on the instrument. Desktop crops the object to one side; narrow screens lift it into the top quarter and put a ground scrim under the text. Every token clears WCAG AA against the ground on its own — nothing under 4.5:1 ships, however good it looks.

Materials. One alloy; a procedural studio of lightformers rather than an HDRI. On machined metal the environment is the material, and shaped softboxes give sharper, art-directable specular than a photographic probe, at zero bytes and with no third-party origin. All geometry is generated in code — no .glb.

Pacing. Each section owns ~150vh; cramped scroll is the biggest amateur tell. Camera moves on eased curves only, and roughly 60% of every frame stays empty.

Rules the build keeps

  • Content is readable with the canvas dead. prerender.js bakes the markup into index.html and the client hydrates it. The experience layer loads after idle and is skipped on save-data, prefers-reduced-motion, or no WebGL.
  • A lost WebGL context can never take the page down — lib/ExperienceBoundary.tsx.
  • The render loop stops when the tab is hidden — measured at 414 draw calls per 1.5s visible, 0 hidden. It never pauses before the first frame, or a page opened in a background tab would show an empty canvas when focused.
  • No preloader, no autoplay audio, no third-party requests. The loading state is a technical drawing of the instrument, in the space it will occupy; it gates nothing and reports no fake progress.
  • Everything is same-origin, so the CSP is default-src 'none' with the rest 'self'. Fonts are self-hosted, unmodified and deliberately un-subsetted — a dropped glyph is a worse failure than a few kB.
  • The canvas is aria-hidden. It is purely decorative — the prerendered DOM above already carries every word — so a screen reader must not announce an unlabelled graphic instead of skipping straight to the content.

The header's fast path

GitHub, LinkedIn, email and CV sit in the header, not only in section 09. The rail already lets a keyboard or screen-reader user jump to "Contact" in one step, but a visitor scanning the page visually has no reason to know that — the four destinations most worth reaching (an evaluator's first move, usually) were otherwise a nine-section scroll away. Same four links, same order, as the closing section: landing at the top is not shown a different set than the one waiting at the end. Icons only, each with its own aria-label distinct from section 09's plain-text versions of the same links — check-build.mjs already asserts every link's accessible name is unique sitewide, which is what caught the first attempt reusing "GitHub" and "LinkedIn" verbatim. 44×44px targets under 820px, matching the floor the rest of the mobile layout already holds itself to.

Stack and deploying

React · React Three Fiber · drei · three.js · GSAP ScrollTrigger · Lenis · @react-three/postprocessing, built with Vite.

Netlify, not GitHub Pages: Pages cannot set response headers, which rules out a real CSP, HSTS and frame-ancestors. netlify.toml sets base web/; public/_headers carries the policy. Base directory must be web.

Measured on the live site — desktop 94 / 94 / 100 / 100, mobile 97 / 100 / 100 / 100, CLS 0, TBT 0 ms.

CI also runs Lighthouse against every build (.github/workflows/ci.yml, lighthouse job) and keeps the report as a build artifact — informational, not a gate. A GitHub-hosted runner has no GPU, so its score on this page's WebGL-heavy hero is a different measurement from a real browser against the live CDN; asserting the numbers above as a CI threshold would fail on the runner's own limits, not a real regression. What the job does check is that a report exists for every commit, rather than the numbers above being the only measurement anyone can point to.

Three modes, not two

live is the full experience. off (save-data, or no WebGL) drops the canvas entirely and the prerendered page stands alone. prefers-reduced-motion gets still — the same instrument, lighting and composition, held in a chosen pose per section and rendered once when the section changes. Reduced motion should not mean a stripped page. Smooth scrolling is handed back to the OS, the entrance and reveals are off, and sparks never fire. Verified: 0 pixels change over 2.5s of idle.

The rail

The right-hand rail is a readout, not a menu — so it has to do the two things a readout does.

It says where you are. The active link takes --beam, the colour reserved for live values, and holds the extended hairline that hover only borrows. It also carries aria-current, which is the half that is not decoration: ten links reading 01 carabiner … 09 Contact are, to a screen reader, ten identical destinations with nothing saying which one you are standing in. The marker is written straight to the DOM from the scroll handler for the same reason S is not React state — re-rendering the tree to move one hairline would put the whole page on the scroll path.

Clicking it travels. A bare #s4 is a native hash jump, and the scroll position is the only input the scene has, so moving it in one step is a cut — the one control on the site that broke the rule the rest of it is built around. Rail and skip-link clicks go through lenis.scrollTo instead, eased on the same power3.out used everywhere, over a duration that grows with the distance and caps at 1.6s. Continuity is the point, not holding somebody hostage to their own navigation.

Focus follows the jump, which preventDefault would otherwise take away — and which the native jump never did either: it left focus on <body>, so the skip link scrolled the page and then sent the next Tab back to the top. Under prefers-reduced-motion none of this is wired at all; there is no Lenis to route through, and the instant jump is the correct behaviour there rather than a fallback.

Handovers

Every boundary is a transformation, not a cross-fade. Each mechanism begins in the shape of the one before it:

  • ratchet → vessel — the struck teeth fly inward and become the rivets sealing a drum shut
  • vessel → rotor — the rivets let go again, back onto the exact ring they came from; the teeth become the transactions from there, same as always — the vessel borrows the ring for the length of its own section, and neither neighbour has to know it was there
  • rotor → spectrometer — the cloud collapses onto one axis; that line is the beam the prism splits
  • spectrometer → tumbler — the flat spectral rows curl into concentric rings
  • tumbler → manifold — the rings extrude into the piston barrels
  • manifold → core — the barrels converge on the axis and lie down; the collector's single output becomes the sixth band
  • core → gauge — the bands come off the column and swing out around one rivet, drawn long and ground thin
  • gauge → rack — the leaves fold shut; a dozen small modules grow out of that same origin point and fan into a shelf
  • rack → gyroscope — the modules gather back onto the vertical axis as one line, and three gimbal rings unfold out of it, outermost first
  • housing → ratchet — eight lathe wedges hinge open around it

One primitive carries three of those: the cylinder that is a manifold barrel is the same cylinder that is a core band, a gauge leaf, and then one of the rack's own modules at a fifth the scale — tall and narrow, short and wide, long and thin, small and plain.

Look inside

Every project section has a second, quieter control under its main link: "Look inside →" opens one real excerpt from that repository, unedited except for dedenting a class method to its own margin -- never rewritten for the panel, never trimmed without saying so (a comment # ... where something real was cut) -- next to a small schematic of that system's actual shape. The pitch is not enough to take on faith; this is the receipt for it.

The schematic is one primitive reused six times, the same discipline the 3D side already holds itself to: a labelled box, a hairline connector, the last box in --beam -- the palette's one colour for a live value, here standing for the thing that comes out the other end. Six different systems read as one visual grammar instead of six unrelated diagrams.

Pure <dialog>-shaped behaviour with none of the plumbing a library would add: inert on <main> while it's open (native, no hand-rolled focus trap), Escape and a backdrop click both close it, focus lands on the close control and returns to whichever project's link opened it. No new dependency, no animation library -- the panel appears and disappears in one frame, which needed nothing to reconcile with prefers-reduced-motion because there was never anything to reduce.

Six get a mechanism. Twelve get a rack.

The six full project sections used to be a curated subset with nothing on the page saying so -- an audit correctly flagged that as the single biggest reason the site hadn't kept pace with a 17-repo portfolio. Inventing twelve more bespoke instruments would have been months of the same hand-built work every other mechanism here took (scene/geometry.ts per shape, scene/Instrument.tsx's morphs hand-tuned per handover -- see Handovers, above), and would very likely have diluted the six that actually earn a close look rather than added to the site's case.

The shape that fits the honest version of that tradeoff: one more real instrument -- Rack, section 09 -- built once for all twelve, not twelve built separately. A dozen small modules, the same cylinder every other mechanism already reuses at a fifth the scale, arrive on a shelf and are read left to right exactly once, each linking straight to its own repo. It costs one geometry and one handover, the same as any other section, instead of twelve.

The closing section changed too: it used to reopen the exact housing the page started in, the same shape playing twice. It's a real, different final shape now -- Gyroscope, three nested gimbals and a flywheel. The rings tumble loosely while the section is read, then settle into orthogonal rest as the page bottom is reached, the flywheel spins up and the core lights: "something that holds up", shown as the one mechanism whose whole job is holding its orientation.

Knock it — a fast flick across its side of the screen, or a tap anywhere on the contact section that isn't a control — and it wobbles off true, the core dims, and it comes back to rest on its own.

Under it, "How this page works" lists every handover above; each one replays by jumping to just before that boundary and travelling through it slowly.

Visit counts

Off by default. Set UMAMI_WEBSITE_ID in Netlify (a free Umami Cloud site) and the build adds the tracker. Both the script and its beacon are proxied through /u/ (netlify.toml), so the browser still only talks to this origin and the same-origin CSP is unchanged: no cookies, no third party.

Domain

ritishsaini.tech (DNS at Hostinger: A @ 75.2.60.5, CNAME www -> ritishsaini.netlify.app). SITE_URL in Netlify sets og:url, og:image, robots.txt and sitemap.xml; web/site.config.js falls back to the same origin. www and the old netlify.app address both 301 to it, the latter via netlify.toml, so old links (the CV included) keep working.

About

Instruments of resolution — an interactive WebGL portfolio. React Three Fiber, one machined instrument that reconfigures per project.

Topics

Resources

Security policy

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages