Static single page. React 19, Vite, Tailwind v4, Motion. Prerendered at build time and deployed to GitHub Pages as static files.
The stack matches the AI twin (ai-professional-twin), and the page is anchored
on the same #0b0f19 so the two properties read as one identity.
DESIGN.md is the token file. Every colour, size, space, radius, duration and curve on the page is defined there, along with the reference analysis and the citation apparatus rules. Read it before changing anything, and add a token there before using one.
npm install
npm run devnpm run build && npm run previewnpm run lint runs tsc -b, which typechecks the app and node projects. npm run fonts re-subsets the three faces and
needs Python with fontTools.
npm run build does four things:
tsc -b, type check.vite build, the client bundle.vite build --ssr src/entry-server.tsx, a server bundle used once.node scripts/prerender.mjs, which renders the app to HTML, injects it intodist/index.html, inlines the stylesheet, and deletes the server bundle.
The page ships as real markup rather than an empty #root. That takes first
paint off the JavaScript critical path, which is the one thing a client-rendered
SPA cannot do, and it means crawlers see the content. The client hydrates.
Every material claim is wrapped in <Claim source="...">
(src/components/Claim.tsx): the claim text gets a
dotted rule and a mono superscript marker. Hover or keyboard focus turns both to
the accent and surfaces the source name in an absolutely positioned label, so
nothing reflows. Under 768px the label is suppressed and the marker is a tap
target, because hover does not exist there.
Sources live in one registry, src/data/sources.ts, keyed by marker number. A source either has a URL or it does not:
- With a URL, the marker links straight to the repository, deployment or PDF.
- Without one, the marker links to that source's row in
Sourcesat the foot of the page, where the reason it has no URL is written down.
So a marker never resolves to a dead page, and the two unpublished dissertations are visibly unpublished rather than quietly linkless. To add a claim, add the source to the registry and wrap the text; marker numbers, accessible names and the reference list all follow.
Twelve claims on the page: four in the twin case study, four in the trajectory, two in research, two in other work, none in the hero.
The head of Trajectory: a graph of the actual story, in CareerGraph.tsx from career.ts. Time runs 2018 to 2027 across four ordinal bands, Filings to Reporting to Pipelines to Models, so a job change reads as a rise. One path steps through the roles, study runs as a lighter second track underneath, and the path draws with scroll in both directions. The pointer picks the nearest move and surfaces its readout.
The bands are a reading of the resume rather than a figure from it, so the graph carries no numeric y axis that would imply otherwise. Dates and titles are exact. It replaced a generated soroban, which replaced a downloaded wristwatch: the graph wins because it is the story rather than an object standing in for it. See DESIGN.md section 14.
Seven surfaces respond to the pointer, all through motion values in pointer.ts so a mouse move never re-renders the tree: the sentinel in the hero, the career graph, both line charts, the bar chart, both calls to action, the contact rows, and the 3D chess set.
The endoskeleton bust (EndoStage.tsx) fills the hero's right column, which was empty at any width below 1280. About 75 meshes from primitives sharing 5 materials: cranium plates, brow, hinged jaw with teeth, neck vertebrae with hydraulic rods, ribs, clavicles and shoulder joints, with two emissive red optics. The head turns toward the pointer independently of the body, and the optics blink once on any click.
That bust is no longer on the page. The hero is a supplied model, credited
below, and src/components/three/EndoStage.tsx is left on disk imported by
nothing as the record of the attempt.
Primitives cannot reach film quality. A photoreal endoskeleton is a sculpted
asset, and boxes and spheres will not get there however well they are tuned. If
you obtain a real one, drop the .glb in public/models/ and set model in
HERO_MODEL in content.ts. It then gets the same scroll
rotation and cursor lean as the chess set, with the fit measured from its own
bounding box so any export scale frames correctly, and Draco and KTX2 both work
because the decoders are already self hosted. Keep it under about 8 MB, since it
sits above the fold. The click blink stays exclusive to the authored bust, because
it drives named materials.
Because it is 3D above the fold, HeroFigure.tsx reserves the frame in CSS and only mounts the canvas after first paint, so the hero text is the largest paint and the 250 kB library never lands on the critical path.
The retrieval path panel moved out of the hero and into the twin's own case study, where it sits beside the project it documents, and now runs on first view rather than on page load. Numbers and rules in DESIGN.md section 13. Every effect pins to neutral under reduced motion, and none of them is the only route to any information.
One object, bound to scroll position rather than to a timeline, so it moves whenever the reader moves and stops when the reader stops. It does not loop. Full rationale in DESIGN.md section 12.
| Object | Where | Source |
|---|---|---|
| Wired head | Hero | robot.glb. Robot girl with hair wires by GR-video, CC BY 4.0. Compressed 29 MB to 0.93 MB, retinted onto the palette |
| Brain tractography | Ask it something | brain.glb. Neural Networks of the Brain by Universal Design for Learning, CC BY 4.0. Compressed 1.79 MB to 76 kB |
| Chess set | Other work, beside the chess analytics entry | chess.glb. A Beautiful Game by Moeen Sayed and Mujtaba Sayed, glTF conversion by Ed Mackey, CC BY 4.0 |
| Hero poster | Hero, until the canvas paints | hero-figure.webp, a render of robot.glb, so the same credit applies |
The pipeline that matters is one command:
npx @gltf-transform/cli optimize in.glb out.glb --compress draco --texture-compress webp --simplify trueMeasured on the two models supplied: 29.06 MB to 1.51 MB, and 1.79 MB to 77 kB.
Inspect first with gltf-transform inspect, because the lever depends on where
the bytes are; the robot was 98 percent geometry, so texture compression would
have saved nothing. Use webp rather than ktx2 unless you have the external
ktx binary installed, which is not a default.
Size is not the constraint, position is. A 1.5 MB Draco model in the hero measured Performance 59 desktop and 49 mobile with a 12.5 s mobile LCP, because unpacking 345,000 vertices is main-thread work that lands while the reader is waiting. The same file below the fold, lazy-mounted, measured 100 desktop. Put models below the fold.
Models arrive in their author's colours. grade on ModelStage remaps each
material's albedo along the page's own ramp by luminance, preserving metalness,
roughness and every texture map, so a bought asset belongs here instead of
importing two foreign hues.
None of this is on the critical path. Both scenes are React.lazy imports and a
scene mounts only when its frame is within one screen of the reader, so initial
load is 207 KiB while the 3D layer and the chess model are 12.6 MB that
most readers never request. Draco and KTX2 decoders are self hosted under
/decoders, because drei points Draco at a Google CDN by default. On a metered
connection or a 2g effective type the scene never mounts at all.
Every graph is bound to scroll position, not triggered by it: both line
charts, the bar chart, the career graph and the retrieval path derive their drawn
state from useDrawProgress in scroll.ts, a transform of
the element's own crossing of the viewport. They draw as you arrive and undraw if
you scroll back up, and are always fully drawn by the time the element is centred
and readable.
One orchestrated page-load sequence in the hero. Timings and curves are in src/motion/tokens.ts, mirroring DESIGN.md section 8.
The hero artifact deliberately does not animate in. It is the largest paint
on every viewport, so starting it at opacity: 0 would put first paint behind
the animation clock. The evidence is simply there and the argument assembles
around it.
LazyMotion loads only the domAnimation feature set, and strict mode makes
the build fail if a full motion.* component reappears.
Facts come from public/vishal-khan-resume.pdf and the public repositories it
cites. Where the resume hedges with a tilde, the page keeps the hedge ("about
20%", not "20%").
Two deliberate departures:
- 370 tests, not 178. The twin's README reports 370 (257 backend, 113 frontend); the resume PDF still says 178. The page cites the README section that shows the current count. Update the resume and the two will agree.
- No "four years in finance". The resume supports Jul 2018 to Sep 2020 at PJRJ plus Jan to Aug 2021 at Aryan Care, which is under three years. The page uses date ranges instead of a year count.
| File | What it is |
|---|---|
public/models/chess.glb |
11.5 MB, Draco plus KTX2 ETC1S. CC BY 4.0, credited on the page. Lazy. |
public/decoders/ |
Self hosted Draco and Basis decoders, 1.3 MB. Lazy. |
public/portrait.webp |
GitHub avatar, 460x460, duotoned into the palette. |
public/og-image.jpg |
Generated share card, same spine, palette and width-axis emphasis as the page. |
public/vishal-khan-resume.pdf |
Cited as source 4. |
public/fonts/*.woff2 |
Subset to ASCII by scripts/subset_fonts.py. |
The hero is not an image. It is the twin's real retrieval path drawn as an ordered list, in HeroPipeline.tsx, with a brass signal running its spine once on load. Every label and number on it is a setting in that repository.
The three charts in Other work are redrawn from real measurements held in src/data/charts.ts: the CIFAR-10 training log, the churn cutoff table, and the UK higher education test scores. Nothing there is illustrative, and if a number cannot be pointed at in a public file it does not go in that file.
- Set the site origin in
index.html, near the top, and make the canonical andog:imageURLs absolute. - Replace
public/portrait.webpwith a headshot 1200px or wider. It is the only photographic asset on the page. - Decide whether the resume PDF should be served from this origin. It carries a
phone number, and it is already public from the twin's
/resume/download. - Add UK-HE-Completion-Risk-Classifier to the resume. The page carries it and cites the repository, and source 7 says plainly that it postdates the resume.
Lighthouse against the production build, Chrome 149 headless. Mobile is the throttled 4G profile.
| Performance | Accessibility | Best practices | SEO | LCP | FCP | |
|---|---|---|---|---|---|---|
| Desktop | 75 to 79 | 100 | 100 | 100 | 0.7 s | 0.4 s |
| Mobile, throttled | 80 | 100 | 100 | 100 | 3.8 s | 1.7 s |
CLS is 0 on both. TBT is 330 to 620 ms and initial transfer is 474 KiB.
Performance dropped from 100 and 97 to the high seventies when the endoskeleton
moved into the hero, and that is not recoverable while it stays there. A WebGL
scene above the fold has to parse a 260 kB library and initialise a scene inside
exactly the window Lighthouse measures. Deferring the mount to requestIdleCallback
with a 2.4 s timeout, dropping the environment cube from 192 to 64 and capping
dpr at 1.5 were all tried and none of them moved the number.
The remedy, if the score matters more than the placement, is to move the figure
below the fold so it lazy-mounts on approach like the chess set. That configuration
measured 97 and 100. It is a one-line change of where HeroFigure is rendered.
axe-core reports zero violations across wcag2a, wcag2aa, wcag21a, wcag21aa and
best-practice rules at 375, 768, 1024 and 1440, with the 3D scene mounted, the
graph drawn and every chart drawn: 51 passes at each width. scrollWidth equals
the viewport at all four widths.
Best practices reached 100 on mobile when the hero screenshot was replaced by the
drawn pipeline: the image-size-responsive flag it was costing went with it,
because the page no longer ships a raster hero at all.