+
+
+
+
diff --git a/README.md b/README.md
index a276b59088..0d3ebcd86c 100644
--- a/README.md
+++ b/README.md
@@ -8,12 +8,12 @@ Static personal site for [grimgrimberg.github.io](https://grimgrimberg.github.io
- Primary maintained pages:
- `index.html`
- `photo.html`
- - `thank-you.html`
- `cv.html`
-- Legacy compatibility pages:
- - `about.html`
- - `projects.html`
- - `vision.html`
+ - `fly101.html` (project notes; the Fly101 repo is private, so never link to it or copy private paths)
+ - `404.html` (served by GitHub Pages for any missing path, so it uses root-absolute URLs)
+- Legacy URLs kept as `noindex` redirects into homepage sections:
+ - `about.html`, `projects.html`, `vision.html`, `thank-you.html`
+- Crawl and sharing files: `robots.txt`, `sitemap.xml`, `apple-touch-icon.png`, `assets/images/og-card.png`
This repo is not React and is not driven by Jekyll templates in its current maintained flow.
@@ -30,12 +30,18 @@ This repo is not React and is not driven by Jekyll templates in its current main
- Page-specific behavior lives in:
- `assets/js/index-page.js`
- `assets/js/photo-page.js`
-- Legacy standalone pages still use `assets/js/scripts.js`.
+- The interactive labs live in:
+ - `assets/js/portfolio-labs.js`: lab switching, plus a lazy loader that imports the scenes when a lab nears the viewport or the page goes idle
+ - `assets/js/path-playground.js` and `assets/js/lab-experiments.js`: the three.js scenes
+ - `assets/js/lab-models.mjs`: the pure simulation models, tested by `npm run test:models`
+ - `assets/js/fly-brain.js`: the connectome point cloud, shared by the `Connectome` lab and `fly101.html`
+- Clippy (jQuery + clippy.js, about 1.5 MB with the sprite sheet) is injected on demand: after page load on desktop, on the first Guide click on phones.
## CSS Story
- `assets/css/styles.css` is the Tailwind v4 source-of-truth stylesheet.
-- `npm run build` produces the committed `assets/css/output.css` used by the maintained pages and legacy compatibility pages.
+- `npm run build` produces the committed `assets/css/output.css` used by every page. CI fails if it is stale.
+- `assets/css/original-plus-3d.css` holds hand-written overrides for the hero, nav, labs and goose, loaded after `output.css` on the homepage.
- Primary pages no longer depend on the Tailwind CDN runtime.
## Local Development
@@ -56,12 +62,13 @@ npm run serve
- [http://127.0.0.1:8000/](http://127.0.0.1:8000/)
- [http://127.0.0.1:8000/photo.html](http://127.0.0.1:8000/photo.html)
-- [http://127.0.0.1:8000/thank-you.html](http://127.0.0.1:8000/thank-you.html)
+- [http://127.0.0.1:8000/cv.html](http://127.0.0.1:8000/cv.html)
## Commands
```bash
npm run lint
+npm run test:models
npm test
npm run test:headed
npm run build
@@ -70,7 +77,9 @@ npm run serve
```
- `npm run lint` runs the repo-specific static and runtime hygiene checks.
+- `npm run test:models` checks the lab simulation models in plain Node.
- `npm test` runs the maintained Playwright smoke suite.
+- `.github/workflows/site-checks.yml` runs the build check, lint, model tests and smoke suite on pushes to `dev` and on pull requests.
- Use `npm run lint` and `npm test -- --reporter=line` as the default acceptance gates before pushing portfolio changes.
- `npm run build` rebuilds the committed Tailwind CSS used by the site pages.
- `npm run serve` launches the same local static server shape the smoke suite uses.
@@ -82,11 +91,25 @@ assets/
css/
styles.css
output.css
+ original-plus-3d.css
+ data/
+ github-repos.json
+ images/
+ fly101/ # renders of the connectome, fly and arm
+ projects/ # line-art schematics for the project cards
+ icons/ # inline-ready skill icons
js/
index-page.js
photo-page.js
site-shell.js
- scripts.js
+ portfolio-labs.js
+ path-playground.js
+ lab-experiments.js
+ lab-models.mjs
+ fly-brain.js
+ vendor/
+ clippyjs/
+ three/
scripts/
lint-site.mjs
optimize-images.ps1
@@ -108,8 +131,11 @@ docs/
REFACTORING_SUMMARY.md
index.html
photo.html
-thank-you.html
cv.html
+fly101.html
+404.html
+robots.txt
+sitemap.xml
```
## Notes
diff --git a/about.html b/about.html
index af68daa40e..b6dedece20 100644
--- a/about.html
+++ b/about.html
@@ -1,62 +1,22 @@
+
- About | Yuval Grimberg
-
+ About moved | Yuval Grimberg
+
+
+
+
+
-
-
-
-
-
-
- Legacy page: this standalone route is kept for compatibility. Start from
- the homepage
- for the maintained experience.
-
-
-
-
-
-
About Me
-
As a Mechanical Engineer specializing in control theory and autonomous vehicles, I bring together technical expertise and innovative thinking to solve complex engineering challenges.
-
-
-
Education
-
Mechanical Engineering with focus on Control Theory and Autonomous Systems
-
-
-
Skills
-
Control Systems, Robotics, Machine Learning, System Design
-
-
-
Interests
-
Autonomous Vehicles, AI in Engineering, Advanced Control Systems
If your mail app did not open, use the link below or copy the draft. No backend, no pretend inbox, no nonsense.
-
+ ✉Try Opening Email Again
@@ -916,46 +845,77 @@
clippyElement.dataset.hideTimer = String(timerId);
}
+ function loadScript(src) {
+ return new Promise((resolve, reject) => {
+ const script = document.createElement('script');
+ script.src = src;
+ script.onload = resolve;
+ script.onerror = reject;
+ document.head.appendChild(script);
+ });
+ }
+
function initRealClippy() {
if (realClippyLoadStarted) {
- return;
+ return realClippyLoadStarted;
}
- realClippyLoadStarted = true;
updateRealClippyGlobals();
+ realClippyLoadStarted = CLIPPY_SCRIPTS
+ .reduce((chain, src) => chain.then(() => loadScript(src)), Promise.resolve())
+ .then(() => new Promise((resolve) => {
+ if (!window.clippy || typeof window.clippy.load !== 'function' || !window.jQuery) {
+ resolve(false);
+ return;
+ }
+
+ window.clippy.BASE_PATH = CLIPPY_AGENT_BASE_PATH;
+ window.clippy.load(
+ 'Clippy',
+ (agent) => {
+ realClippyAgent = agent;
+ realClippyLoaded = true;
+ updateRealClippyGlobals();
+ allowRealClippySoundAfterGesture(realClippyAgent);
+ realClippyAgent.hide(true);
+ resetRealClippyQueue(realClippyAgent);
+ const close = document.createElement('button');
+ close.id = 'real-clippy-dismiss'; close.className = 'clippy-visit-close'; close.type = 'button'; close.textContent = '×';
+ close.setAttribute('aria-label', 'Dismiss Clippy for this visit');
+ close.addEventListener('mousedown', event => event.stopPropagation());
+ close.addEventListener('click', event => {event.stopPropagation();hideClippy(true);});
+ document.querySelector('.clippy')?.appendChild(close);
+ window.dispatchEvent(new CustomEvent('portfolio:clippy-ready'));
+ resolve(true);
+ },
+ () => {
+ realClippyLoaded = false;
+ updateRealClippyGlobals();
+ resolve(false);
+ }
+ );
+ }))
+ .catch(() => {
+ realClippyLoaded = false;
+ updateRealClippyGlobals();
+ return false;
+ });
+ return realClippyLoadStarted;
+ }
- if (!window.clippy || typeof window.clippy.load !== 'function' || !window.jQuery) {
+ function scheduleRealClippy() {
+ // The animated agent is ~1.5 MB. Phones and Save-Data get the text helper until someone asks for Clippy.
+ if (navigator.connection?.saveData || window.matchMedia('(max-width: 640px)').matches) {
return;
}
- window.clippy.BASE_PATH = CLIPPY_AGENT_BASE_PATH;
-
- try {
- window.clippy.load(
- 'Clippy',
- (agent) => {
- realClippyAgent = agent;
- realClippyLoaded = true;
- updateRealClippyGlobals();
- allowRealClippySoundAfterGesture(realClippyAgent);
- realClippyAgent.hide(true);
- resetRealClippyQueue(realClippyAgent);
- const close = document.createElement('button');
- close.id = 'real-clippy-dismiss'; close.className = 'clippy-visit-close'; close.type = 'button'; close.textContent = '×';
- close.setAttribute('aria-label', 'Dismiss Clippy for this visit');
- close.addEventListener('mousedown', event => event.stopPropagation());
- close.addEventListener('click', event => {event.stopPropagation();hideClippy(true);});
- document.querySelector('.clippy')?.appendChild(close);
- window.dispatchEvent(new CustomEvent('portfolio:clippy-ready'));
- },
- () => {
- realClippyLoaded = false;
- updateRealClippyGlobals();
- }
- );
- } catch {
- realClippyLoaded = false;
- updateRealClippyGlobals();
+ const start = () => ('requestIdleCallback' in window
+ ? window.requestIdleCallback(() => initRealClippy(), { timeout: 3000 })
+ : window.setTimeout(initRealClippy, 1500));
+ if (document.readyState === 'complete') {
+ start();
+ } else {
+ window.addEventListener('load', start, { once: true });
}
}
@@ -1213,7 +1173,7 @@
if (event.altKey && event.shiftKey && event.code === 'KeyC') {
event.preventDefault();
- showClippy('Old-school Clippy activated. No third-party script, no clipboard drama, just one paperclip doing its job.', 18000);
+ showClippyGuide('Old-school Clippy activated. No third-party script, no clipboard drama, just one paperclip doing its job.');
}
konamiSequence.push(event.code);
@@ -1231,10 +1191,14 @@
showClippy('Konami code activated. Ultimate easter egg unlocked.', 18000);
- document.body.style.animation = 'rainbow 2s infinite';
- window.setTimeout(() => {
- document.body.style.animation = '';
- }, 5000);
+ // Animate main, not body: a filter on body would re-anchor every fixed element for five seconds.
+ const stage = document.getElementById('main-content');
+ if (stage && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
+ stage.style.animation = 'rainbow 2s infinite';
+ window.setTimeout(() => {
+ stage.style.animation = '';
+ }, 5000);
+ }
konamiSequence = [];
});
}
@@ -1402,6 +1366,7 @@ ${role.bullets.map((bullet) => `- ${escapeHtml(bullet)}`).join('\n')}`;
const isActive = button.getAttribute('data-hire-role') === roleKey;
button.classList.toggle('is-active', isActive);
button.setAttribute('aria-selected', isActive ? 'true' : 'false');
+ button.tabIndex = isActive ? 0 : -1;
});
document.querySelectorAll('[data-command-run^="hire "]').forEach((button) => {
@@ -1412,10 +1377,29 @@ ${role.bullets.map((bullet) => `- ${escapeHtml(bullet)}`).join('\n')}`;
}
function initHireCommandCenter() {
- document.querySelectorAll('[data-hire-role]').forEach((button) => {
+ const tabs = [...document.querySelectorAll('[data-hire-role]')];
+ tabs.forEach((button, index) => {
+ button.tabIndex = button.getAttribute('aria-selected') === 'true' ? 0 : -1;
button.addEventListener('click', () => {
setHireRole(button.getAttribute('data-hire-role') || 'robotics');
});
+ // Standard tablist keys: arrows move between roles, Home/End jump to the ends.
+ button.addEventListener('keydown', (event) => {
+ const offsets = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 };
+ let next = null;
+ if (event.key in offsets) {
+ next = tabs[(index + offsets[event.key] + tabs.length) % tabs.length];
+ } else if (event.key === 'Home') {
+ next = tabs[0];
+ } else if (event.key === 'End') {
+ next = tabs[tabs.length - 1];
+ }
+ if (next) {
+ event.preventDefault();
+ next.focus();
+ next.click();
+ }
+ });
});
}
@@ -1424,7 +1408,21 @@ ${role.bullets.map((bullet) => `- ${escapeHtml(bullet)}`).join('\n')}`;
message ||
'Old Clippy reporting for duty. I can route you to hire fit, projects, repos, CV, contact, reviews, or the goose.';
- pinClippyGuide(guideMessage);
+ if (realClippyLoaded || useTextHelper()) {
+ pinClippyGuide(guideMessage);
+ return;
+ }
+
+ // First explicit request fetches the agent, but nobody waits more than a beat for a paperclip.
+ let shown = false;
+ const show = () => {
+ if (!shown) {
+ shown = true;
+ pinClippyGuide(guideMessage);
+ }
+ };
+ initRealClippy().then(show);
+ window.setTimeout(show, 2500);
}
function getCommandResponse(rawCommand) {
@@ -1466,7 +1464,7 @@ clear clear the terminal`;
if (verb === 'projects') {
scrollToSection('projects');
- return 'Jumping to featured projects. The short version: BGR, orbital LQI, repo summarizer, daily movers agent.';
+ return 'Jumping to featured projects. The short version: UGV diagnostics, FPV visual geometry, BGR, orbital LQI, repo summarizer, daily movers agent, Fly101.';
}
if (verb === 'hire' && args.length > 0) {
@@ -1582,13 +1580,17 @@ This is opt-in. Public surface remains hireable. The terminal is where the eyebr
}
if (verb === 'goose' && ['terror', 'release', 'attack', 'chaos', 'loose'].includes(args[0])) {
+ const alreadyLoose = gooseTerrorState.active;
const started = startGooseTerror();
- return started
- ? `Goose terror mode released.
+ if (started) {
+ return `Goose terror mode released.
It waddles, brings dumb notes, and leaves footprints like a tiny workplace liability. Tap the goose for another note.
-Stop it with \`goose stop\`, Esc, or the on-screen banish button.`
- : 'Goose terror mode is already active. This is not a second goose economy.';
+Stop it with \`goose stop\`, Esc, or the on-screen banish button.`;
+ }
+ return alreadyLoose
+ ? 'Goose terror mode is already active. This is not a second goose economy.'
+ : 'The goose looked for a clear patch of screen and found none. Give it a bigger window and try again.';
}
if (verb === 'goose' && ['stop', 'banish', 'calm', 'enough', 'off'].includes(args[0])) {
@@ -1681,12 +1683,17 @@ The flex is not perfection. The flex is range, follow-through, and enough taste
return;
}
+ if (palette.classList.contains('hidden')) {
+ commandPaletteOpener = document.activeElement;
+ }
+
palette.classList.remove('hidden');
document.querySelectorAll('[data-command-open]').forEach((button) => {
button.setAttribute('aria-expanded', 'true');
});
- if (!commandHistory.length) {
+ if (!commandPaletteWelcomed) {
+ commandPaletteWelcomed = true;
appendCommandOutput('welcome', 'Type `help`, `repos`, `projects`, `cv`, `contact`, or `goose`. This is a static GitHub Pages site, so the repo list comes from a baked-in snapshot.');
}
@@ -1707,11 +1714,41 @@ The flex is not perfection. The flex is range, follow-through, and enough taste
document.querySelectorAll('[data-command-open]').forEach((button) => {
button.setAttribute('aria-expanded', 'false');
});
+
+ if (commandPaletteOpener?.isConnected && typeof commandPaletteOpener.focus === 'function') {
+ commandPaletteOpener.focus({ preventScroll: true });
+ }
+ commandPaletteOpener = null;
+ }
+
+ function trapFocus(container, event) {
+ if (event.key !== 'Tab') {
+ return;
+ }
+
+ const focusable = [...container.querySelectorAll('a[href], button, input')]
+ .filter((element) => !element.disabled && element.getClientRects().length);
+ const first = focusable[0];
+ const last = focusable[focusable.length - 1];
+ if (!first) {
+ return;
+ }
+
+ if (!container.contains(document.activeElement) || (event.shiftKey && document.activeElement === first)) {
+ event.preventDefault();
+ (event.shiftKey ? last : first).focus();
+ } else if (!event.shiftKey && document.activeElement === last) {
+ event.preventDefault();
+ first.focus();
+ }
}
function initCommandPalette() {
const form = document.getElementById('command-form');
const input = document.getElementById('command-input');
+ const palette = document.getElementById('command-palette');
+
+ palette?.addEventListener('keydown', (event) => trapFocus(palette, event));
document.querySelectorAll('[data-command-open]').forEach((button) => {
button.addEventListener('click', () => openCommandPalette());
@@ -1745,7 +1782,7 @@ The flex is not perfection. The flex is range, follow-through, and enough taste
const clippyCloseButton = document.getElementById('clippy-close-button');
const emailButton = document.getElementById('open-email-client-btn');
const gameRequestButton = document.querySelector('[data-game-request]');
- const copyButtons = [document.getElementById('copy-email-address'), document.getElementById('quick-copy-email')];
+ const copyButtons = [document.getElementById('copy-email-address')];
clippyCloseButton?.addEventListener('click', () => hideClippy(true));
emailButton?.addEventListener('click', openEmailClient);
@@ -1811,28 +1848,55 @@ The flex is not perfection. The flex is range, follow-through, and enough taste
}
function buildModalShell(icon, title, bodyHtml) {
+ const opener = document.activeElement;
+ const titleId = `portfolio-modal-title-${++modalCount}`;
const modal = document.createElement('div');
modal.className = 'fixed inset-0 bg-black/50 flex items-center justify-center z-50';
+ modal.setAttribute('role', 'dialog');
+ modal.setAttribute('aria-modal', 'true');
+ modal.setAttribute('aria-labelledby', titleId);
modal.innerHTML = `
${icon}
-
${title}
+
${title}
${bodyHtml}
`;
+ const onKeydown = (event) => {
+ if (event.key === 'Escape') {
+ modal.closeModal();
+ } else {
+ trapFocus(modal, event);
+ }
+ };
+
+ modal.closeModal = () => {
+ modal.remove();
+ document.removeEventListener('keydown', onKeydown);
+ if (opener?.isConnected && typeof opener.focus === 'function') {
+ opener.focus({ preventScroll: true });
+ }
+ };
+
modal.addEventListener('click', (event) => {
if (event.target === modal) {
- modal.remove();
+ modal.closeModal();
}
});
+ document.addEventListener('keydown', onKeydown);
return modal;
}
+ function showModal(modal) {
+ document.body.appendChild(modal);
+ modal.querySelector('[data-modal-actions] a, [data-modal-actions] button')?.focus({ preventScroll: true });
+ }
+
function appendModalButton(container, label, className, onClick, href) {
if (href) {
const link = document.createElement('a');
@@ -1862,8 +1926,8 @@ The flex is not perfection. The flex is range, follow-through, and enough taste
appendModalButton(
actions,
'Got It',
- 'w-full bg-gradient-to-r from-tech-cyan to-tech-green text-white font-bold py-3 px-6 rounded-lg',
- () => modal.remove()
+ 'w-full bg-gradient-to-r from-tech-cyan to-tech-green text-tech-dark font-bold py-3 px-6 rounded-lg',
+ () => modal.closeModal()
);
const note = document.createElement('div');
@@ -1871,7 +1935,7 @@ The flex is not perfection. The flex is range, follow-through, and enough taste
note.textContent = 'Use external game pages responsibly. Verify availability and licensing before downloading anything.';
actions.appendChild(note);
- document.body.appendChild(modal);
+ showModal(modal);
}
function showRetroGameRequest() {
@@ -1885,7 +1949,7 @@ The flex is not perfection. The flex is range, follow-through, and enough taste
appendModalButton(
actions,
'Send Request',
- 'block w-full bg-gradient-to-r from-tech-purple to-tech-pink text-white font-bold py-3 px-6 rounded-lg text-center',
+ 'block w-full bg-gradient-to-r from-tech-purple to-tech-pink text-tech-dark font-bold py-3 px-6 rounded-lg text-center',
null,
'mailto:yuval.grimberg@gmail.com?subject=Retro Classics Nomination&body=Hi Yuval! I would nominate this retro game for the classics shelf:%0D%0A%0D%0ATitle:%0D%0ASource page:%0D%0AWhy it belongs:'
);
@@ -1894,10 +1958,10 @@ The flex is not perfection. The flex is range, follow-through, and enough taste
actions,
'Maybe Later',
'w-full bg-gray-600 hover:bg-gray-500 text-white font-bold py-2 px-6 rounded-lg',
- () => modal.remove()
+ () => modal.closeModal()
);
- document.body.appendChild(modal);
+ showModal(modal);
}
function openGameSource(gameId) {
@@ -1958,14 +2022,12 @@ The flex is not perfection. The flex is range, follow-through, and enough taste
}
function initPage() {
- initAos();
- initRealClippy();
+ scheduleRealClippy();
loadRepoSnapshot();
initProjectCardObserver();
initFunZone();
initGooseAnimation();
initHireCommandCenter();
- initCvGate();
initCommandPalette();
initGuideButtons();
initKeyboardEasterEggs();
diff --git a/assets/js/lab-experiments.js b/assets/js/lab-experiments.js
index 9843487e77..87f8d8e019 100644
--- a/assets/js/lab-experiments.js
+++ b/assets/js/lab-experiments.js
@@ -52,7 +52,7 @@ function replaceLine(object, positions) {
}
function label(text,position,color='#9eafab') {
const c=document.createElement('canvas'); c.width=512;c.height=64;
- const ctx=c.getContext('2d');ctx.font='28px Segoe UI';ctx.fillStyle=color;ctx.fillText(text,8,42);
+ const ctx=c.getContext('2d');ctx.font='28px "Segoe UI", system-ui, sans-serif';ctx.fillStyle=color;ctx.fillText(text,8,42);
const sprite=new THREE.Sprite(new THREE.SpriteMaterial({map:new THREE.CanvasTexture(c),depthTest:false,transparent:true}));
sprite.position.copy(position);sprite.scale.set(5,.625,1);scene.add(sprite);
}
diff --git a/assets/js/portfolio-labs.js b/assets/js/portfolio-labs.js
index 993c4f3431..8bd35551e1 100644
--- a/assets/js/portfolio-labs.js
+++ b/assets/js/portfolio-labs.js
@@ -2,6 +2,7 @@
const driving = document.getElementById('path-playground');
const experiments = document.getElementById('experiment-playground');
const reconstruction = document.getElementById('reconstruction-lab');
+const brainLab = document.getElementById('brain-lab');
const frameSlot = document.getElementById('reconstruction-frame-slot');
const loadViewer = document.getElementById('reconstruction-load');
const closeViewer = document.getElementById('reconstruction-close');
@@ -38,24 +39,59 @@ closeViewer?.addEventListener('click', () => {
document.addEventListener('portfolio:lab-visibility', () => {
document.body.classList.toggle('path-scene-near', labReading.size > 0 || [driving, experiments].some(panel => panel && !panel.hidden && panel.dataset.nearViewport === 'true'));
});
+// The connectome data and three.js load only when someone opens this lab.
+let brainRequested = false;
+function openBrainLab() {
+ if (brainRequested) return;
+ brainRequested = true;
+ const status = document.getElementById('brain-lab-status');
+ if (!('WebGL2RenderingContext' in window)) {
+ status.textContent = 'This view needs WebGL. The picture shows the same neurons.';
+ return;
+ }
+ import('./fly-brain.js?v=20261106b')
+ .then(({ mountBrain }) => mountBrain({
+ stage: document.getElementById('brain-lab-stage'),
+ canvas: document.getElementById('brain-lab-canvas'),
+ framing: 'panel'
+ }))
+ .then((brain) => {
+ status.textContent = 'Drag to rotate. Switch a region off to look inside.';
+ document.querySelectorAll('[data-brain-region]').forEach((button) => {
+ button.disabled = false;
+ button.addEventListener('click', () => {
+ const visible = button.getAttribute('aria-pressed') !== 'true';
+ button.setAttribute('aria-pressed', String(visible));
+ button.classList.toggle('is-active', visible);
+ brain.setRegion(Number(button.dataset.brainRegion), visible);
+ });
+ });
+ })
+ .catch(() => {
+ brainRequested = false;
+ status.textContent = 'The neuron data did not load. Reopen this lab to try again.';
+ });
+}
function selectLab(mode, updateHash = false) {
- if (!['drive','space','perception','reconstruction'].includes(mode)) return;
+ if (!['drive','space','perception','reconstruction','brain'].includes(mode)) return;
if (mode !== 'reconstruction') unloadReconstruction();
if (driving) driving.hidden = mode !== 'drive';
if (experiments) experiments.hidden = !['space','perception'].includes(mode);
if (reconstruction) reconstruction.hidden = mode !== 'reconstruction';
+ if (brainLab) brainLab.hidden = mode !== 'brain';
+ if (mode === 'brain') openBrainLab();
document.querySelectorAll('[data-lab-mode]').forEach(button => {
button.setAttribute('aria-pressed', String(button.dataset.labMode === mode));
});
document.querySelectorAll('[data-experiment-copy]').forEach(el => el.hidden = el.dataset.experimentCopy !== mode);
document.body.dataset.activeLab = mode;
document.dispatchEvent(new CustomEvent('portfolio:lab-change', { detail: mode }));
- if (updateHash) history.replaceState(null, '', '#' + ({drive:'path-playground',space:'space-lab',perception:'perception-lab',reconstruction:'reconstruction-lab'}[mode]));
+ if (updateHash) history.replaceState(null, '', '#' + ({drive:'path-playground',space:'space-lab',perception:'perception-lab',reconstruction:'reconstruction-lab',brain:'brain-lab'}[mode]));
}
document.querySelectorAll('[data-lab-mode]').forEach(button => button.addEventListener('click', () => selectLab(button.dataset.labMode, true)));
document.querySelectorAll('[data-lab-target]').forEach(link => link.addEventListener('click', () => selectLab(link.dataset.labTarget, true), {capture:true}));
function readHash() {
- const mode = {'#path-playground':'drive','#space-lab':'space','#perception-lab':'perception','#reconstruction-lab':'reconstruction'}[location.hash];
+ const mode = {'#path-playground':'drive','#space-lab':'space','#perception-lab':'perception','#reconstruction-lab':'reconstruction','#brain-lab':'brain'}[location.hash];
if (mode) { selectLab(mode); requestAnimationFrame(() => document.getElementById('lab-picker')?.scrollIntoView({block:'start',behavior:'instant'})); }
}
window.addEventListener('hashchange', readHash);
@@ -83,6 +119,23 @@ const readingObserver = new IntersectionObserver(entries => {
document.dispatchEvent(new Event('portfolio:lab-visibility'));
}, {rootMargin:'-80px 0px -35% 0px'});
document.querySelectorAll('#hero, #about, #contact, #lab-picker, .path-playground').forEach(el => readingObserver.observe(el));
+
+// three.js is ~750 KB, so the scenes load when a lab gets close or the page goes idle, never before first paint.
+let labScenesRequested = false;
+function loadLabScenes() {
+ if (labScenesRequested) return;
+ labScenesRequested = true;
+ labLoadObserver.disconnect();
+ Promise.all([import('./path-playground.js?v=20261102'), import('./lab-experiments.js?v=20261102')])
+ .catch(() => document.querySelectorAll('#path-status, #experiment-status').forEach(el => { el.textContent = 'Lab failed to load. Refresh to try again.'; }));
+}
+const labLoadObserver = new IntersectionObserver(entries => {
+ if (entries.some(entry => entry.isIntersecting)) loadLabScenes();
+}, {rootMargin:'1200px 0px'});
+document.querySelectorAll('#lab-picker, .path-playground').forEach(el => labLoadObserver.observe(el));
+const scheduleIdleLoad = () => ('requestIdleCallback' in window ? requestIdleCallback(loadLabScenes, {timeout:4000}) : setTimeout(loadLabScenes, 2500));
+if (document.readyState === 'complete') scheduleIdleLoad();
+else window.addEventListener('load', scheduleIdleLoad, {once:true});
document.addEventListener('visibilitychange', () => {
if (document.hidden) unloadReconstruction('Viewer closed while this tab was hidden. Load it again to continue.');
});
diff --git a/assets/js/scripts.js b/assets/js/scripts.js
deleted file mode 100644
index bf135cc215..0000000000
--- a/assets/js/scripts.js
+++ /dev/null
@@ -1,92 +0,0 @@
-document.addEventListener('DOMContentLoaded', function () {
- const LEGACY_GLOBAL_TIMEOUT_MS = 3000;
- const LEGACY_GLOBAL_INTERVAL_MS = 100;
-
- function initSmoothAnchors() {
- document.querySelectorAll('a[href^="#"]:not([href="#"])').forEach((anchor) => {
- if (anchor.dataset.anchorReady === 'true') {
- return;
- }
-
- anchor.addEventListener('click', function (event) {
- const target = document.querySelector(this.getAttribute('href'));
- if (!target) {
- return;
- }
-
- event.preventDefault();
- target.scrollIntoView({ behavior: 'smooth', block: 'start' });
- });
-
- anchor.dataset.anchorReady = 'true';
- });
- }
-
- function initBackToTopButton() {
- if (window.backToTopButton) {
- return;
- }
-
- const backToTopButton = document.createElement('button');
- backToTopButton.type = 'button';
- backToTopButton.textContent = '\u2191';
- backToTopButton.className = 'fixed bottom-4 right-4 bg-blue-500 text-white p-2 rounded-full hidden';
- backToTopButton.setAttribute('aria-label', 'Back to top');
- document.body.appendChild(backToTopButton);
-
- function syncVisibility() {
- if (window.scrollY > 300) {
- backToTopButton.classList.remove('hidden');
- } else {
- backToTopButton.classList.add('hidden');
- }
- }
-
- window.addEventListener('scroll', syncVisibility, { passive: true });
- backToTopButton.addEventListener('click', () => {
- window.scrollTo({ top: 0, behavior: 'smooth' });
- });
-
- syncVisibility();
- window.backToTopButton = backToTopButton;
- }
-
- function waitForGlobal(name, onReady) {
- const deadline = Date.now() + LEGACY_GLOBAL_TIMEOUT_MS;
-
- function check() {
- if (window[name]) {
- onReady(window[name]);
- return;
- }
-
- if (Date.now() >= deadline) {
- console.info(`Legacy helper timed out waiting for ${name}.`);
- return;
- }
-
- window.setTimeout(check, LEGACY_GLOBAL_INTERVAL_MS);
- }
-
- check();
- }
-
- initSmoothAnchors();
- initBackToTopButton();
-
- if (document.querySelector('[data-legacy-clippy-shortcut]')) {
- waitForGlobal('clippy', function (clippy) {
- clippy.load('Clippy', function (agent) {
- agent.hide();
-
- document.addEventListener('keydown', function (event) {
- if (event.altKey && event.shiftKey && event.code === 'KeyC') {
- agent.show();
- agent.moveTo(100, 100);
- agent.speak('Legacy helper ready.');
- }
- });
- });
- });
- }
-});
diff --git a/assets/js/site-shell.js b/assets/js/site-shell.js
index 49d74f0964..3dc51830ca 100644
--- a/assets/js/site-shell.js
+++ b/assets/js/site-shell.js
@@ -1,5 +1,6 @@
(function () {
- const NAV_ACTIVE_BACKGROUND = 'rgba(10, 15, 28, 0.8)';
+ const NAV_ACTIVE_BACKGROUND = 'rgba(7, 10, 13, 0.94)';
+ const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
function initNavbarBackground() {
const navbar = document.getElementById('navbar');
@@ -36,15 +37,30 @@
event.preventDefault();
target.scrollIntoView({
- behavior: 'smooth',
+ behavior: reducedMotion.matches ? 'auto' : 'smooth',
block: 'start'
});
+ // Keep the URL shareable and move keyboard focus with the scroll, so skip links actually skip.
+ if (!anchor.hasAttribute('data-lab-target')) {
+ history.pushState(null, '', anchor.getAttribute('href'));
+ }
+ if (!target.matches('a[href], button, input, select, textarea, [tabindex]')) {
+ target.setAttribute('tabindex', '-1');
+ }
+ target.focus({ preventScroll: true });
});
anchor.dataset.anchorReady = 'true';
});
}
+ function initCurrentYear() {
+ const year = String(new Date().getFullYear());
+ document.querySelectorAll('[data-current-year]').forEach((element) => {
+ element.textContent = year;
+ });
+ }
+
function cloneDesktopLinks(mobileNavList, desktopNav) {
if (!mobileNavList || !desktopNav || mobileNavList.children.length > 0) {
return;
@@ -186,5 +202,6 @@
initNavbarBackground();
initMobileMenu();
initSmoothAnchors();
+ initCurrentYear();
});
})();
diff --git a/cv.html b/cv.html
index c7d0e2a813..fc1b06abcf 100644
--- a/cv.html
+++ b/cv.html
@@ -13,6 +13,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/site/SETUP_INSTRUCTIONS.md b/docs/site/SETUP_INSTRUCTIONS.md
index c6d38673e3..1c9a5cc170 100644
--- a/docs/site/SETUP_INSTRUCTIONS.md
+++ b/docs/site/SETUP_INSTRUCTIONS.md
@@ -2,7 +2,7 @@
## Purpose
-This repo is a root-static GitHub Pages site. The maintained user-facing flow lives in `index.html`, `photo.html`, `thank-you.html`, and `cv.html`.
+This repo is a root-static GitHub Pages site. The maintained user-facing flow lives in `index.html`, `photo.html`, and `cv.html`, plus `404.html` for missing routes.
## Quick Start
@@ -22,7 +22,7 @@ npm run serve
- [http://127.0.0.1:8000/](http://127.0.0.1:8000/)
- [http://127.0.0.1:8000/photo.html](http://127.0.0.1:8000/photo.html)
-- [http://127.0.0.1:8000/thank-you.html](http://127.0.0.1:8000/thank-you.html)
+- [http://127.0.0.1:8000/cv.html](http://127.0.0.1:8000/cv.html)
## Optional CSS Build
@@ -36,11 +36,14 @@ Use this when you change Tailwind classes, theme tokens, or shared site styles.
```bash
npm run lint
+npm run test:models
npm test
```
- `npm run lint` runs the repo-specific static and runtime hygiene checks.
+- `npm run test:models` checks the lab simulation models in plain Node.
- `npm test` runs the maintained Playwright smoke matrix aimed at the current live pages, not a broad cross-device lab.
+- `.github/workflows/site-checks.yml` runs all of the above, plus a check that `output.css` matches `styles.css`, on pushes to `dev` and on pull requests.
## Branching
@@ -49,4 +52,4 @@ npm test
## Legacy Pages
-`about.html`, `projects.html`, and `vision.html` remain in the repo for compatibility and reference, but they are not the primary maintained surfaces. Local harness pages live under `tests/fixtures/`.
+`about.html`, `projects.html`, `vision.html`, and `thank-you.html` are tiny `noindex` redirect pages into the matching homepage section, so old links still land somewhere useful. Local harness pages live under `tests/fixtures/`.
diff --git a/favicon.svg b/favicon.svg
index 091e5424bf..1b256f6930 100644
--- a/favicon.svg
+++ b/favicon.svg
@@ -1,5 +1,5 @@
diff --git a/fly101.html b/fly101.html
new file mode 100644
index 0000000000..893909b5a6
--- /dev/null
+++ b/fly101.html
@@ -0,0 +1,196 @@
+
+
+
+
+
+
+ Fly101: a fruit-fly brain wired to a robot arm | Yuval Grimberg
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Skip to content
+
+
+
+
+
+
+
+
+
Fly101 · research in simulation
+
A fruit-fly brain, wired to a robot arm.
+
The complete MaleCNS connectome, simulated neuron by neuron, follows a live human hand through a simulated SO-101 arm.
+
+
Real neuron positions from the connectome. Drag to rotate.
+
+
+
+
+
neurons, wired as mapped
165,122
+
closed-loop step
30 ms
+
median live tracking error
17.8 mm
+
synapses changed
0
+
+
+
+
+
The idea
+
MaleCNS is a complete electron-microscopy map of a male fruit fly’s central nervous system: 165,122 neurons and 25.6 million connections. Fly101 runs every neuron as a spiking model on one laptop GPU, in a live loop, wired exactly as in the data.
+
Nothing inside the brain is trained. Only a small linear readout learns, turning the activity of 250 neurons into commands for the arm.
+
+
+
+ The whole nervous system, drawn only from neuron positions. Optic lobes in brass, central brain in teal, nerve cord in grey.
+
+
+
+
+
The loop
+
+
A webcam tracks your hand and sets a target pose for the gripper.
+
The error reaches the fly’s eye as firing across 5,307 first-stage visual neurons.
+
The whole brain responds for 30 ms of simulated time.
+
A learned readout turns the activity into a motion command.
+
The arm moves in MuJoCo, under speed limits, and the loop repeats.
+
+
+
+
+ The fly: the flybody model in MuJoCo.
+
+
+
+ The arm: a simulated SO-101.
+
+
+
+
+
+
Results
+
+
Live human hand, 104 s
17.8 mm median
+
Held-out recorded hand motion
20.05 mm median
+
Gripper accuracy
100 %
+
Live rate
32 updates/s
+
Joint-speed or acceleration violations
0
+
+
Measured in simulation. The real SO-101 arms are calibrated and teleoperated, and hardware work is under way.
+
+
+
+ Technical notes: full results, baselines and open questions
+
+
Status on 4 November 2026. No brain has driven the real arm yet, and these experiments are ongoing.
+
+
Full test results
+
+
+
Test
Result
Bar
+
+
Held-out hand motion
20.05 mm
≤ 25 mm
+
Same motion at 1.8× speed
43.2 mm
≤ 45 mm
+
Brain replaced by its average activity
177 mm
> 50 mm
+
Live, 104 s (teacher on the same motion: 19.0 mm)
17.8 mm
None
+
+
+
+
These use an ideal simulated servo. With a 12 V motor model, median tracking rises to about 22–30 mm, 37–49 % worse. The readout is trained with ridge regression and DAgger, with a hand-written controller as the teacher.
+
+
+
Where the signal lives
+
I measured how well groups of 250 neurons predict the teacher’s commands (held-out R²), by their synaptic distance from the input. The whole brain sits within five synapses of the input, but the signal doesn’t get that far:
+
+
+
Group
Most informative 250
Random 250
+
+
1 synapse from input
0.776
0.546
+
2 synapses
0.644
0.260
+
3 synapses
0.052
0.017
+
4 synapses
≈ 0
≈ 0
+
Medulla (visual)
0.787
0.523
+
Mushroom body, protocerebrum, GNG, VNC
≈ 0
≈ 0
+
+
+
+
With this input, the fly’s early visual system is the signal path: 240 of the best readout’s 250 neurons are in the medulla. The central brain and the descending neurons, which normally drive the fly’s body, carry almost none of it.
+
+
+
The wire test
+
To steer an arm, a controller has to combine the error with the arm’s current posture: an error × posture product. So I gave the brain both signals and asked whether its descending neurons could produce the required joint rates. Then I compared them with a “wire”, a readout that skips the brain entirely.
+
+
+
Readout
Joint-rate R² (held out)
+
+
Brain, descending neurons
0.34
+
Linear wire, raw inputs, no brain
0.65
+
Bilinear wire, inputs plus error × angle products
0.92
+
+
+
+
The descending neurons do carry the errors (R² 0.67) and the joint angles (0.79), but not their product. A follow-up read from all 12,847 recorded neurons reached 0.54, and still added nothing beyond the linear wire. By the rule I had set before running it, that stage stopped there.
+
The later experiments point the same way:
+
+
Passing the bars isn’t proof. A linear wire with the motor model also tracks reasonably (32.8 mm), so tracking alone can’t show the brain does the inverse kinematics.
+
A harder test separates controllers. On pan-heavy poses, linear controllers fail 59–72 % of the cases the teacher holds, and nonlinear ones about 13 %. The fixed brain with a linear readout fails like the linear wires.
+
Real wiring versus rewired brains. The real graph first beat rewired controls on one joint, but on fresh targets the effect shrank and the winning joint changed. Silencing the candidate neurons did not remove what was left, so the preregistered reading is “not causal”.
+
Neuron model. A conductance-synapse variant showed no useful gain (the test was inconclusive), and voltage floors ruled out one artefact explanation for the remaining pan interaction.
+
+
Each stage has its success bar, stop rule and compute budget fixed before the run, which is what makes a “no” worth trusting.
+
+
+
What comes next
+
+
Path to the real arm. The brain drives a task-space readout, fixed analytical inverse kinematics maps it to joints, and LeRobot drives the hardware. The loop already runs through LeRobot’s simulated SO-101 interface; fitting the motor model to the real arm comes next.
+
Can the brain form the product itself? The open research question is whether any input encoding gets a fixed connectome to compute error × posture, and whether the real wiring is what does it.
+
+
+
+
+
+
+
Sources and credits
+
+
MaleCNS v1.0 ↗ connectome (CC-BY), with regions from neuPrint ↗. The brain images are my renders of its neuron positions.
+
flybody ↗ fly model by Google DeepMind and HHMI Janelia (Apache-2.0).
+
SO-ARM100 ↗ robot model (Apache-2.0), with kinematics from LeRobot (Apache-2.0).
+
flycoinrh ↗ neural reference (MIT). GeNN, MuJoCo and MediaPipe run the simulation, physics and hand tracking.
+
+
The repository is private for now. Email me for a walkthrough.
- I build Python software for autonomous systems, computer vision, and applied AI.
+ I build Python software for autonomous systems: planning and control, diagnostics, computer vision and applied AI.
@@ -341,14 +385,14 @@
- Open to software development and R&D roles. Based in Israel, open to remote or relocation.
+ Open to R&D and software roles in autonomy, robotics and applied AI. Tel Aviv, open to remote or relocation.
- Pick the mission. I will show the proof.
+ Pick the mission. I will show the proof.
- Recruiters should not need a treasure map. Choose the role shape and the site will line up
- projects, skills, and the honest reason I fit.
+ Recruiters shouldn’t need a treasure map. Pick the role you’re hiring for and I’ll line up the projects, the tools and the honest reason I fit.
-
Led path planning and control work for a real Formula Student driverless effort.
-
Comfortable translating messy system behavior into debuggable software loops.
+
Built shared Python backend and operator interfaces for a UGV: command handling, telemetry, diagnostics, watchdogs and heartbeats (full-time, July–September 2026).
+
Led five student developers on BGR Formula Student planning and control, 2023 to late 2025; our car drove about 50 metres autonomously.
Useful when the job needs range, not a one-trick framework certificate.
@@ -444,8 +487,8 @@
R&D Sof
About me
I’m Yuval, a software developer with a mechanical engineering degree. I work on algorithms, Python systems and applied AI.
-
From July to September 2026, I worked full-time on a UGV defense project. I developed shared Python backend and operator interfaces for command handling, telemetry and diagnostics, with logging across four components, thread-timing diagnostics, watchdogs and heartbeat handling.
-
At BGR Formula Student, I led planning and control from 2023 to late 2025, sharing implementation with the team. We achieved approximately 50 metres of physical autonomous driving using ego-centric navigation without a functioning IMU.
+
From July to September 2026 I worked full-time on a UGV defense project. I built a shared Python backend and operator interfaces for command handling, telemetry and diagnostics, then made failures traceable: logging across four components, thread-timing diagnostics, watchdogs and heartbeat handling.
+
At BGR Formula Student I led the five-person planning and control group from 2023 to late 2025 and wrote code alongside them. Our car drove about 50 metres autonomously, navigating ego-centrically without a working IMU.
I like turning an unclear problem into a model I can inspect, a first version I can run, and an experiment that tells me what to change.
- Projects that show the useful part: math, systems, code, and enough polish to inspect without a
- treasure map.
+ Each one shows the useful part: the math, the system, the code, and enough polish that you can inspect it yourself.
-
-
software development
🛠️
+
+
software development
+
UGV Software & Diagnostics
Make commands, state and failures traceable across a robot’s software.
-
Full-time, July–September 2026. Developed a shared Python backend and operator interfaces for command handling, telemetry, and diagnostics. Added logging across four components, thread-timing diagnostics, watchdogs, and heartbeat handling.
+
Full-time, July–September 2026. I built a shared Python backend and operator interfaces for command handling, telemetry and diagnostics, then added logging across four components, thread-timing diagnostics, watchdogs and heartbeat handling.
A driverless race car needs a path it can actually follow.
-
I led the student planning and control team from 2023 to late 2025, sharing implementation of an adapted planner, Pure Pursuit, Stanley steering and curvature-based PID speed control. Our physical car drove approximately 50 metres autonomously; testing also included simulation-driven physical actuators.
+
I led five student developers from 2023 to late 2025 and co-wrote the stack: an adapted cone planner, Pure Pursuit and Stanley steering, and curvature-based PID speed control. We tested in simulation and on physical steering and wheel actuators; the car drove about 50 metres autonomously.
I connected all 165,122 neurons of the MaleCNS connectome to a simulated SO-101 arm, with every synapse left as mapped and only a linear readout trained. In simulation, the arm follows a live human hand with a 17.8 mm median error.
Every dot is a real neuron position from the MaleCNS fruit-fly connectome. Drag to turn it, then switch regions off to see how a nervous system is built.
+
+
+
+
+
+
+
Loading neuron positions…
+
Showing a 42,000-neuron sample of the 165,122 in the connectome. Anatomy only: this view does not simulate activity.
- Six old-school games that still explain timing, constraints, flow, and why children in the 90s
- developed suspiciously strong patience. No hosted binaries; only source pages.
+ Games I would actually defend in a meeting. Six old-school classics that still explain timing,
+ constraints, flow, and why children in the 90s developed suspiciously strong patience.
-
-
- 6
- Curated Classics
-
-
- 3
- Protected Favorites
-
-
- 0
- Source Opens
-
-
- 0
- Hosted Game Files
-
-
-
-
-
-
one shelf, no landfill
-
Games I would actually defend in a meeting
-
-
Fast to understand, mechanically sharp, and still useful as tiny design lessons. Also: legally safer than hosting mystery ZIPs.