diff --git a/README.md b/README.md
index a5b47ca..46e378c 100644
--- a/README.md
+++ b/README.md
@@ -1,16 +1,44 @@
# Emil Shanayev — resume site
-Live at **https://shanaty.ru/resume/**. A static Next.js site whose content is loaded live from Gitea:
+Live at **https://shanaty.ru/resume/**. A static Next.js site whose content is read live from Gitea, so it is updated by editing repositories, not by redeploying.
-- **Text** (name, bio, skills, experience, education, contacts, …) comes from [`data/resume.json`](data/resume.json) in this repo.
-- **Projects** are the public repos on Gitea (`git.shanaty.ru/Emil_Shanaty`) that carry the topic **`resume`**. Tag a repo and it appears within about a minute; remove the topic and it disappears.
-- Optional `projectNotes` in `data/resume.json` (keyed by repo name) enrich a project with a title, kind, longer description, highlights, tech tags, extra links and a `rank` (lower ranks first; the rest are newest first). Without a note a project shows its Gitea description, language and topics.
+## What comes from where
-## How it works
+| On the page | Source |
+|---|---|
+| Name, role, bio, skills, experience, education, languages, contacts | [`data/resume.json`](data/resume.json) in this repo (`main` branch) |
+| Projects | Public repos of `Emil_Shanaty` on Gitea that carry the topic **`resume`** |
+| Project picture | Optional `demo.png`, `demo.jpg`, `demo.gif` or `demo.webp` in the root of that repo |
+| Project title, kind, description, highlights, tech tags, links, order | Optional `resume-card.json` in the root of that repo |
-A systemd timer on the server (`resume_sync.py`, in the `auto` repo) rebuilds `https://shanaty.ru/data/resume.json` every minute from the `main` branch of this repo plus the topic search. The page fetches that file on load and falls back to the copy bundled at build time if it is unreachable. A broken `data/resume.json` (invalid JSON, missing fields, non-http links) is rejected and the previous published data stays.
+A timer on the server (`resume_sync.py` in the `auto` repo) rebuilds `https://shanaty.ru/data/resume.json` every minute from these sources, so a change shows up within about a minute. If a source is broken (invalid JSON, missing fields, unsafe links) the previous published data stays and the problem is logged (`journalctl -u gitea-resume`).
-To update content: edit `data/resume.json`, commit, and **push to Gitea** (`git push gitea main`). No rebuild or deploy is needed.
+## Adding a project
+
+1. On Gitea, add the topic `resume` to the repository (it must be public).
+2. Optional: put a `demo.png` (or `.jpg`, `.gif`, `.webp`) in the repo root. Up to 6 MB, any proportions; animated GIFs and WebP keep animating. Without one the project gets a generated picture.
+3. Optional: put a `resume-card.json` in the repo root to control how the project reads:
+
+```json
+{
+ "title": "Cortex Engine",
+ "kind": "AI-native 3D game engine",
+ "description": "One or two sentences on what it is and what makes it interesting.",
+ "highlights": ["Pure Vulkan 1.3 backend", "Playable FPS demo"],
+ "tech": ["C#", "Vulkan", "MCP"],
+ "links": [{ "label": "Docs", "href": "https://example.com/docs" }],
+ "rank": 1,
+ "hidden": false
+}
+```
+
+Every field is optional. Without a card the page uses the Gitea description, the repo language and its topics (the topics `resume`, `portfolio` and `featured` are not shown as tags). `rank` puts a project first (lower first, unranked ones follow, newest first); `"hidden": true` removes it from the page. The same values can also live centrally in `projectNotes` (keyed by repo name) in `data/resume.json`; a `resume-card.json` wins over it.
+
+The layout adapts to any number of projects: rows of two or three plates with a wider feature plate first, no lone project left in the last row, and a single project gets the whole width.
+
+## Editing the text
+
+Edit `data/resume.json`, commit and **push to Gitea** (`git push gitea main`). No rebuild or deploy is needed.
## Development
@@ -20,12 +48,16 @@ npm run dev # http://localhost:3000, data from the public https://shanaty
npm run build # static export in out/
```
-`BASE_PATH` sets the URL prefix (default `/Emil_Shanayev_Resume`, for GitHub Pages) and `NEXT_PUBLIC_DATA_URL` the data URL.
+`BASE_PATH` sets the URL prefix (default `/Emil_Shanayev_Resume`, for GitHub Pages) and `NEXT_PUBLIC_DATA_URL` the data URL. Fonts (Bricolage Grotesque, Instrument Sans; SIL Open Font License) are bundled in `app/fonts`, so building needs no network.
## Deploy
`./deploy-shanaty.sh` builds and publishes the site to shanaty.ru (needs the ssh key `~/.ssh/MyGitea`). Only needed after changing code or styles.
+## Design
+
+The page is laid out like a 3D-editor viewport, a nod to the game-engine work it presents: a studio backdrop with a perspective floor whose red X and blue Z axes match the axis colours of Blender and Unity, and an orientation gizmo in the corner that orbits as the page scrolls (press it to go back to the top). Projects without a demo image get a deterministic isometric voxel sculpture generated from the repo name.
+
## Not published on purpose
The official resume on hh.ru holds more personal details (phone, date of birth, salary expectations). This site only carries the professional content.
diff --git a/app/fonts/BricolageGrotesque-Variable.woff2 b/app/fonts/BricolageGrotesque-Variable.woff2
new file mode 100644
index 0000000..fcc4eb1
Binary files /dev/null and b/app/fonts/BricolageGrotesque-Variable.woff2 differ
diff --git a/app/fonts/InstrumentSans-Variable.woff2 b/app/fonts/InstrumentSans-Variable.woff2
new file mode 100644
index 0000000..8611e41
Binary files /dev/null and b/app/fonts/InstrumentSans-Variable.woff2 differ
diff --git a/app/globals.css b/app/globals.css
index a51f572..a8872ec 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -1,144 +1,42 @@
-@import url('https://fonts.googleapis.com/css2?family=DM+Mono:wght@300;400;500&family=Manrope:wght@400;500;600;700&display=swap');
+/* Design tokens: a studio-lit 3D viewport. Axis colours (X red, Y green, Z blue) are marks and lines, not text. */
+:root {
+ --studio: #e6e9ed;
+ --studio-deep: #cdd3db;
+ --paper: #f4f6f8;
+ --ink: #15181d;
+ --muted: #5b6571;
+ --line: rgba(21, 24, 29, 0.14);
+ --x: #e0384b;
+ --y: #5aa300;
+ --z: #2c63e0;
+ --z-text: #2455c9;
- :root { --ink:#f3f6f0; --paper:#0b0e0d; --surface:#121715; --surface-raised:#171d1a; --acid:#c7ff46; --muted:#96a099; --line:rgba(222,237,220,.14); --dark:#070908; --glow:rgba(199,255,70,.14); }
-* { box-sizing:border-box; }
-html { scroll-behavior:smooth; background:var(--paper); }
-body { margin:0; overflow-x:hidden; background:var(--paper); color:var(--ink); font-family:'Manrope',sans-serif; }
-main { overflow:hidden; position:relative; background-image:linear-gradient(rgba(255,255,255,.025) 1px, transparent 1px),linear-gradient(90deg, rgba(255,255,255,.025) 1px, transparent 1px); background-size:48px 48px; }
-a { color:inherit; text-decoration:none; }
-a:focus-visible { outline:2px solid var(--acid); outline-offset:5px; }
-::selection { background:var(--acid); color:#0b0e0d; }
-.link-arrow { width:14px; height:14px; flex:none; fill:none; stroke:currentColor; stroke-width:1.35; stroke-linecap:round; stroke-linejoin:round; }
-.shell { width:min(1180px, calc(100% - 48px)); margin-inline:auto; }
-.ambient { position:absolute; width:680px; height:680px; border-radius:50%; pointer-events:none; filter:blur(4px); opacity:.55; }
-.ambient-one { top:-350px; right:-220px; background:radial-gradient(circle, var(--glow), transparent 67%); }
-.ambient-two { top:1200px; left:-430px; background:radial-gradient(circle, rgba(67,146,130,.13), transparent 66%); }
-.nav { height:86px; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--line); position:relative; z-index:1; }
-.mark { font:600 17px 'DM Mono',monospace; letter-spacing:-.04em; color:var(--acid); }
-.mark span { color:var(--muted); font-weight:300; }
-.nav nav { display:flex; gap:34px; font:400 12px 'DM Mono',monospace; text-transform:uppercase; letter-spacing:.08em; }
-.nav nav a { position:relative; transition:color .2s; }.nav nav a::after { content:''; position:absolute; left:0; right:0; bottom:-8px; height:1px; background:var(--acid); transform:scaleX(0); transform-origin:left; transition:transform .2s; }.nav nav a:hover { color:var(--acid); }.nav nav a:hover::after { transform:scaleX(1); }
-.hero { min-height:760px; padding:64px 0 70px; position:relative; border-bottom:1px solid var(--line); }
-.hero-kicker { max-width:290px; font:400 11px/1.5 'DM Mono',monospace; text-transform:uppercase; letter-spacing:.06em; color:var(--muted); display:flex; gap:10px; }
-.pulse { width:8px; height:8px; margin-top:4px; background:var(--acid); border-radius:50%; box-shadow:0 0 0 5px var(--glow),0 0 18px var(--acid); flex:none; }
-.hero-orbit { position:absolute; width:230px; height:230px; border:1px solid rgba(199,255,70,.24); right:-60px; top:90px; border-radius:50%; opacity:.55; animation:spin 20s linear infinite; z-index:0; pointer-events:none; }
-.hero-orbit::before,.hero-orbit::after { content:''; position:absolute; border-radius:50%; background:var(--acid); box-shadow:0 0 18px var(--acid); }.hero-orbit::before { width:7px; height:7px; top:34px; left:34px; }.hero-orbit::after { width:4px; height:4px; bottom:29px; right:56px; }
-.hero-orbit span { position:absolute; inset:32px; border:1px dashed rgba(231,250,224,.18); border-radius:50%; display:grid; place-items:center; font:500 13px 'DM Mono',monospace; color:var(--acid); animation:spin-reverse 20s linear infinite; }.hero-orbit i { position:absolute; width:100%; height:1px; top:50%; background:linear-gradient(90deg, transparent,rgba(199,255,70,.6),transparent); }
-.eyebrow { font:400 11px 'DM Mono',monospace; text-transform:uppercase; letter-spacing:.15em; color:var(--muted); }
-.hero h1 { font-size:clamp(70px,11.5vw,164px); line-height:.82; letter-spacing:-.085em; margin:56px 0 26px -8px; font-weight:600; position:relative; z-index:1; text-shadow:0 0 55px rgba(199,255,70,.07); }
-.hero h2 { max-width:760px; font-size:clamp(28px,4.2vw,58px); line-height:1.03; letter-spacing:-.05em; margin:0 0 32px; font-weight:500; text-wrap:balance; }
-.hero-copy { width:min(650px, calc(100% - 220px)); margin-left:26%; color:#bac4bb; font-size:16px; line-height:1.75; }
-.hero-actions { margin:42px 0 0 26%; display:flex; gap:10px; }
-.button { padding:14px 18px; border:1px solid rgba(243,246,240,.36); background:rgba(15,20,18,.55); font:500 11px 'DM Mono',monospace; text-transform:uppercase; display:flex; gap:26px; justify-content:space-between; transition:transform .2s,background .2s,border .2s; }
-.button.primary { background:var(--acid); border-color:var(--acid); color:#11150f; box-shadow:0 12px 30px rgba(199,255,70,.16); }.button:hover { transform:translateY(-3px); background:var(--acid); border-color:var(--acid); color:#11150f; }
-.hero-facts { display:flex; gap:38px; margin:58px 0 0 26%; font:400 10px 'DM Mono',monospace; color:var(--muted); text-transform:uppercase; letter-spacing:.05em; }.hero-facts b { color:var(--acid); font-size:17px; font-weight:400; margin-right:6px; }
-.hero-index { position:absolute; right:0; bottom:70px; font:300 11px 'DM Mono',monospace; color:var(--muted); pointer-events:none; }
-@media (max-width:1100px) {
- .hero-orbit { display:none; }
- .hero-index { display:none; }
-}
-.section-heading { display:flex; justify-content:space-between; align-items:flex-end; padding:23px 0; border-bottom:1px solid rgba(243,246,240,.65); }
-.section-heading p { margin:0; font:500 12px 'DM Mono',monospace; text-transform:uppercase; letter-spacing:.11em; }
-.section-heading span { font:300 11px 'DM Mono',monospace; color:var(--muted); }
-.capabilities,.work,.tools,.about { padding-top:100px; }
-.cap-grid { display:grid; grid-template-columns:repeat(3,1fr); }
-.cap-card { padding:42px 30px 36px 0; min-height:320px; border-right:1px solid var(--line); transition:background .25s,padding .25s; }
-.cap-card + .cap-card { padding-left:30px; }
-.cap-card:last-child { border-right:0; }
-.cap-number { font:300 11px 'DM Mono',monospace; color:var(--muted); }
-.cap-card:hover { background:linear-gradient(135deg,rgba(199,255,70,.09),transparent 68%); }.cap-card h3 { font-size:24px; margin:54px 0 16px; letter-spacing:-.035em; }.cap-card p { color:#b0bab2; font-size:14px; line-height:1.7; min-height:74px; }.cap-card:hover .cap-number { color:var(--acid); }
-.tag-list { display:flex; flex-wrap:wrap; gap:7px; margin-top:24px; }
-.tag-list span { font:400 10px 'DM Mono',monospace; border:1px solid var(--line); background:rgba(255,255,255,.025); padding:6px 8px; color:#b8c2bb; }
-.project { display:grid; grid-template-columns:160px minmax(0,1fr) 150px; gap:34px; padding:52px 0; border-bottom:1px solid var(--line); transition:background .25s; }.project:hover { background:linear-gradient(90deg,rgba(199,255,70,.075),transparent 72%); }.project:hover .project-meta span { color:var(--acid); }
-.project-meta span { font:300 11px 'DM Mono',monospace; color:var(--muted); }
-.project-meta p { font:400 10px/1.5 'DM Mono',monospace; text-transform:uppercase; color:var(--muted); margin-top:14px; max-width:150px; }
-.project-body h3 { margin:0 0 18px; font-size:clamp(30px,4vw,52px); letter-spacing:-.055em; font-weight:500; }
-.project-body > p { max-width:720px; color:#b0bab2; line-height:1.75; margin:0; }
-.project-body ul { display:grid; grid-template-columns:1fr 1fr; padding:0; margin:26px 0 0; list-style:none; gap:9px 20px; }
-.project-body li { font:400 11px 'DM Mono',monospace; }
-.project-body li::before { content:'—'; color:var(--muted); margin-right:9px; }
-.project-links { display:flex; align-items:flex-end; justify-content:flex-end; flex-direction:column; gap:10px; }
-.project-links a { width:100%; display:flex; justify-content:space-between; align-items:center; padding:11px 0; border-bottom:1px solid rgba(243,246,240,.55); font:400 10px 'DM Mono',monospace; text-transform:uppercase; }.project-links a:hover { color:var(--acid); border-color:var(--acid); }.project-links a:hover .link-arrow,.tool-grid a:hover .link-arrow,.contact-grid a:hover .link-arrow { transform:translate(2px,-2px); }.link-arrow { transition:transform .2s; }
-.tool-grid { display:grid; grid-template-columns:1fr 1fr; }
-.tool-grid a { min-height:130px; border-bottom:1px solid var(--line); padding:26px 0; display:flex; justify-content:space-between; gap:30px; transition:color .2s,transform .2s; }
-.tool-grid a:nth-child(odd) { padding-right:30px; border-right:1px solid var(--line); }
-.tool-grid a:nth-child(even) { padding-left:30px; }
-.tool-grid a:last-child:nth-child(odd) { border-right:0; padding-right:0; }
-.tool-grid strong { font-size:18px; }
-.tool-grid p { color:var(--muted); font-size:12px; margin:9px 0 0; }
-.tool-grid a:hover { color:var(--acid); transform:translateX(8px); }.tool-grid a:hover strong { box-shadow:inset 0 -8px rgba(199,255,70,.28); }
-.education { display:grid; grid-template-columns:160px 1fr; gap:34px; padding:30px 0; border-bottom:1px solid var(--line); }.education-list { padding:18px 0 70px; }.education-list .education:last-child { border-bottom:0; }
-.education > span { font:300 11px 'DM Mono',monospace; color:var(--muted); }
-.education h3 { margin:0 0 8px; font-size:22px; }
-.education p { margin:0; color:#b0bab2; line-height:1.6; }
-.education-note { font-size:13px; }
-.footer { background:radial-gradient(circle at 85% 15%,rgba(199,255,70,.13),transparent 26%),var(--dark); color:#f3f6f0; width:100%; max-width:none; padding:90px max(24px,calc((100% - 1180px)/2)) 32px; margin-top:30px; border-top:1px solid var(--line); }
-.footer .eyebrow { color:#a4a69c; }
-.footer h2 { font-size:clamp(45px,7vw,96px); line-height:.98; letter-spacing:-.065em; font-weight:500; margin:48px 0 80px; }
-.footer h2 em { color:var(--acid); font-style:normal; }
-.contact-grid { border-top:1px solid #484b42; }
-.contact-grid a { display:grid; grid-template-columns:120px 1fr 24px; padding:22px 0; border-bottom:1px solid #484b42; align-items:center; transition:padding .2s,color .2s; }.contact-grid a:hover { padding-inline:10px; }
-.contact-grid span { font:400 10px 'DM Mono',monospace; text-transform:uppercase; color:#92958a; }
-.contact-grid strong { font-weight:400; }
-.contact-grid a:hover strong { color:var(--acid); }
-.footer-bottom { display:flex; justify-content:space-between; padding-top:60px; color:#787b71; font:300 9px 'DM Mono',monospace; text-transform:uppercase; }
-@keyframes spin { to { transform:rotate(360deg); } } @keyframes spin-reverse { to { transform:rotate(-360deg); } }
-@media (prefers-reduced-motion:reduce) { *,*::before,*::after { scroll-behavior:auto!important; animation-duration:.01ms!important; animation-iteration-count:1!important; transition-duration:.01ms!important; } }
-@media (max-width:980px) and (min-width:761px) {
- .hero h1 { font-size:clamp(72px, 11vw, 120px); }
- .hero-orbit { right:2%; width:250px; height:250px; }
- .project { grid-template-columns:120px minmax(0,1fr); gap:26px; }
- .project-links { grid-column:2; flex-direction:row; justify-content:flex-start; }
- .project-links a { width:150px; }
-}
-@media (max-width:760px) {
- .shell { width:min(100% - 28px,1180px); }
- .nav { height:70px; }.nav nav { gap:15px; font-size:10px; }.nav nav a::after { bottom:-6px; }
- .hero { min-height:auto; padding:55px 0 60px; }.hero-kicker { margin-bottom:34px; }.hero h1 { margin-top:0; font-size:19vw; }.hero h2 { font-size:36px; }
- .hero-copy,.hero-actions { width:100%; margin-left:0; }.hero-copy { margin-top:30px; }.hero-actions { flex-direction:column; }.hero-facts { margin-left:0; gap:16px; flex-direction:column; }.hero-index { display:none; }
- .section-heading span { display:none; }.capabilities,.work,.tools,.about { padding-top:70px; }
- .cap-grid { grid-template-columns:1fr; }.cap-card,.cap-card + .cap-card { border-right:0; border-bottom:1px solid var(--line); padding:34px 0; min-height:0; }.cap-card h3 { margin-top:28px; }.cap-card p { min-height:0; }
- .project { grid-template-columns:1fr; gap:20px; padding:38px 0; }.project:hover { padding-left:0; }.project-meta { display:flex; justify-content:space-between; }.project-meta p { margin:0; max-width:none; }.project-body ul { grid-template-columns:1fr; }.project-links { align-items:flex-start; }.project-links a { width:150px; }
- .tool-grid { grid-template-columns:1fr; }.tool-grid a:nth-child(n) { border-right:0; padding:24px 0; }
- .education { grid-template-columns:1fr; gap:20px; padding-bottom:70px; }.contact-grid a { grid-template-columns:80px 1fr 20px; }.contact-grid strong { font-size:12px; overflow-wrap:anywhere; }.footer-bottom { gap:20px; }.footer-bottom span:last-child { text-align:right; }
+ --radius-plate: 18px;
+ --radius-control: 10px;
+ --shell: min(1240px, calc(100% - 48px));
+ --gutter: clamp(20px, 3vw, 32px);
+ --section: clamp(84px, 11vw, 148px);
+
+ --font-body: var(--font-text), 'Segoe UI', system-ui, sans-serif;
+ --font-head: var(--font-display), 'Segoe UI', system-ui, sans-serif;
}
-/* Resume site: skills, experience, live project list, side info. */
-.skill-grid { display:grid; grid-template-columns:repeat(3,1fr); }
-.skill-card { padding:34px 28px 34px 0; border-right:1px solid var(--line); border-bottom:1px solid var(--line); transition:background .25s; }
-.skill-card:nth-child(3n+2), .skill-card:nth-child(3n) { padding-left:28px; }
-.skill-card:nth-child(3n) { border-right:0; }
-.skill-card:hover { background:linear-gradient(135deg,rgba(199,255,70,.09),transparent 68%); }
-.skill-card h3 { font-size:22px; margin:30px 0 12px; letter-spacing:-.035em; }
-.skill-card p { margin:0; color:#b0bab2; font-size:14px; line-height:1.65; }
-.exp-item { display:grid; grid-template-columns:200px 1fr; gap:34px; padding:34px 0; border-bottom:1px solid var(--line); }
-.exp-item > span { font:300 11px/1.6 'DM Mono',monospace; color:var(--muted); text-transform:uppercase; letter-spacing:.05em; }
-.exp-item h3 { margin:0 0 16px; font-size:22px; letter-spacing:-.03em; }
-.exp-item ul { margin:0; padding:0; list-style:none; display:grid; gap:9px; color:#b0bab2; line-height:1.6; }
-.exp-item li::before { content:'—'; color:var(--muted); margin-right:10px; }
-.status-note { margin:0; padding:44px 0; border-bottom:1px solid var(--line); color:var(--muted); font:400 12px/1.8 'DM Mono',monospace; }
-.status-note a { color:var(--acid); border-bottom:1px solid rgba(199,255,70,.4); }
-.status-note b { color:var(--ink); font-weight:500; }
-.project-updated { margin:20px 0 0; color:var(--muted); font:300 10px 'DM Mono',monospace; text-transform:uppercase; letter-spacing:.05em; }
-.about-grid { display:grid; grid-template-columns:1.25fr 1fr; gap:64px; padding-bottom:70px; }
-.about-grid .education-list { padding-bottom:0; }
-.side-info { padding-top:18px; }
-.mini-heading { margin:0 0 14px; padding-bottom:10px; border-bottom:1px solid rgba(243,246,240,.35); font:500 11px 'DM Mono',monospace; text-transform:uppercase; letter-spacing:.11em; }
-.side-info .mini-heading:not(:first-child) { margin-top:40px; }
-.lang-list { list-style:none; margin:0; padding:0; }
-.lang-list li { display:flex; justify-content:space-between; padding:10px 0; border-bottom:1px solid var(--line); }
-.lang-list b { font:400 11px 'DM Mono',monospace; color:var(--acid); font-weight:400; }
-.side-info .tag-list { margin-top:0; }
-.contact-note { color:#92958a; font-style:normal; font-size:12px; }
-@media (max-width:980px) {
- .skill-grid { grid-template-columns:repeat(2,1fr); }
- .skill-card:nth-child(n) { padding:34px 24px 34px 0; border-right:1px solid var(--line); }
- .skill-card:nth-child(2n) { padding-left:24px; border-right:0; }
- .about-grid { grid-template-columns:1fr; gap:40px; }
+* { box-sizing: border-box; }
+html { background: var(--paper); scroll-behavior: smooth; scroll-padding-top: 88px; }
+body {
+ margin: 0;
+ color: var(--ink);
+ font: 400 17px/1.6 var(--font-body);
+ -webkit-font-smoothing: antialiased;
+ text-rendering: optimizeLegibility;
}
-@media (max-width:760px) {
- .skill-grid { grid-template-columns:1fr; }
- .skill-card:nth-child(n) { padding:30px 0; border-right:0; }
- .exp-item { grid-template-columns:1fr; gap:14px; padding:28px 0; }
- .about-grid { padding-bottom:50px; }
+a { color: inherit; }
+h1, h2, h3, p, ul, dl, dd, figure { margin: 0; }
+ul { padding: 0; list-style: none; }
+::selection { background: var(--z); color: #fff; }
+:focus-visible { outline: 3px solid var(--z); outline-offset: 3px; border-radius: 4px; }
+
+@media (prefers-reduced-motion: reduce) {
+ html { scroll-behavior: auto; }
+ *, *::before, *::after { animation-duration: 0.01ms !important; animation-delay: 0ms !important; transition-duration: 0.01ms !important; }
}
diff --git a/app/layout.tsx b/app/layout.tsx
index c830ca8..40a057a 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -1,6 +1,11 @@
import type { Metadata } from 'next'
+import localFont from 'next/font/local'
import './globals.css'
+// Both fonts are bundled (SIL Open Font License), so building never depends on the network.
+const display = localFont({ src: './fonts/BricolageGrotesque-Variable.woff2', variable: '--font-display', weight: '200 800', display: 'swap' })
+const text = localFont({ src: './fonts/InstrumentSans-Variable.woff2', variable: '--font-text', weight: '400 700', display: 'swap' })
+
export const metadata: Metadata = {
title: 'Emil Shanayev — Resume',
description: 'Resume of Emil Shanayev: backend, DevOps, AI systems, scientific software and game technology.',
@@ -9,7 +14,7 @@ export const metadata: Metadata = {
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
return (
-
+
{children}
)
diff --git a/app/page.tsx b/app/page.tsx
index ab37908..4a842f5 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -1,6 +1,6 @@
+import Site from '@/components/Site'
import resume from '@/data/resume.json'
import type { Resume } from '@/lib/types'
-import Site from './site'
export default function Home() {
return
diff --git a/app/site.tsx b/app/site.tsx
deleted file mode 100644
index 506e26a..0000000
--- a/app/site.tsx
+++ /dev/null
@@ -1,212 +0,0 @@
-'use client'
-
-import { useEffect, useState } from 'react'
-import type { Live, Project, Resume } from '@/lib/types'
-
-// Deployed on shanaty.ru the build points at the same-origin file; elsewhere (dev, GitHub Pages) it uses the public URL.
-const DATA_URL = process.env.NEXT_PUBLIC_DATA_URL ?? 'https://shanaty.ru/data/resume.json'
-const GITEA_PROFILE = 'https://git.shanaty.ru/Emil_Shanaty'
-
-const pad = (n: number) => String(n).padStart(2, '0')
-const monthYear = (iso?: string) => (iso ? new Date(iso).toLocaleDateString('en', { month: 'short', year: 'numeric' }) : '')
-
-const ArrowIcon = () => (
-
-)
-
-type Status = 'loading' | 'live' | 'offline'
-
-export default function Site({ initial }: Readonly<{ initial: Resume }>) {
- const [resume, setResume] = useState(initial)
- const [projects, setProjects] = useState([])
- const [generatedAt, setGeneratedAt] = useState()
- const [status, setStatus] = useState('loading')
-
- useEffect(() => {
- const controller = new AbortController()
- fetch(DATA_URL, { cache: 'no-store', signal: controller.signal })
- .then((response) => (response.ok ? response.json() : Promise.reject(new Error(`HTTP ${response.status}`))))
- .then((data: Live) => {
- if (!data?.resume || !Array.isArray(data.projects)) throw new Error('unexpected data')
- setResume(data.resume)
- setProjects(data.projects)
- setGeneratedAt(data.generated_at)
- setStatus('live')
- })
- .catch((error: Error) => {
- if (error.name !== 'AbortError') setStatus('offline')
- })
- return () => controller.abort()
- }, [])
-
- // Once the list has loaded and is empty (no repo carries the `resume` topic yet), the section is hidden entirely.
- const showProjects = status !== 'live' || projects.length > 0
- const primary = resume.contacts[0]
- const github = resume.contacts.find((contact) => contact.label === 'GitHub')
-
- return (
-
-
-
-
-
-
- {resume.availability}
- AI
- {resume.eyebrow}
- {resume.name}
- {resume.role}
- {resume.bio}
-
-
- {status === 'live' && projects.length > 0 && {pad(projects.length)} projects, live from Gitea}
- {pad(resume.skills.length)} areas of practice
-
- {status === 'live' && projects.length > 0 && 01—{pad(projects.length)}
}
-
-
-
-
-
Skills / stack
-
Where I work
-
-
- {resume.skills.map((skill, index) => (
-
- {pad(index + 1)}
- {skill.title}
- {skill.text}
- {skill.stack.map((tag) => {tag})}
-
- ))}
-
-
-
-
-
-
Experience
-
Building, running, leading
-
-
- {resume.experience.map((entry) => (
-
-
{entry.meta}
-
-
{entry.title}
-
{entry.points.map((point) => - {point}
)}
-
-
- ))}
-
-
-
- {showProjects && (
-
-
-
Selected work
-
- {status === 'live' && `Live from Gitea · updated ${monthYear(generatedAt)}`}
- {status === 'loading' && 'Loading from Gitea…'}
- {status === 'offline' && 'Live list unavailable'}
-
-
- {status === 'loading' && (
-
- Loading projects…
-
-
- )}
- {status === 'offline' && (
-
- The live project list could not be loaded right now. Browse the repositories directly at{' '}
- {GITEA_PROFILE.replace('https://', '')}
- {github && <> or github.com/{github.value}>}.
-
- )}
-
- {projects.map((project, index) => (
-
-
-
{pad(index + 1)}
-
{project.kind ?? project.language ?? 'Repository'}
-
-
-
{project.title ?? project.name}
- {project.description &&
{project.description}
}
- {project.highlights && project.highlights.length > 0 && (
-
{project.highlights.map((highlight) => - {highlight}
)}
- )}
- {project.tech.length > 0 &&
{project.tech.map((tag) => {tag})}
}
- {project.updated &&
Updated {monthYear(project.updated)}
}
-
-
-
- ))}
-
-
- )}
-
-
-
-
Education & more
-
Learning by building
-
-
-
- {resume.education.map((entry) => (
-
-
{entry.period}
-
{entry.institution}
{entry.program}
-
- ))}
-
-
-
Languages
-
- {resume.languages.map((language) => (
- - {language.name}{language.level}
- ))}
-
-
Interests
-
{resume.interests.map((interest) => {interest})}
-
-
-
-
-
-
- )
-}
diff --git a/components/Cover.tsx b/components/Cover.tsx
new file mode 100644
index 0000000..70fbcfe
--- /dev/null
+++ b/components/Cover.tsx
@@ -0,0 +1,102 @@
+import { useMemo } from 'react'
+
+// Deterministic isometric "sculpture" for a project without a demo image: the repo name seeds a small voxel
+// stack, so every project gets its own picture that stays the same between visits.
+
+function hash(text: string): number {
+ let h = 2166136261
+ for (let i = 0; i < text.length; i++) {
+ h ^= text.charCodeAt(i)
+ h = Math.imul(h, 16777619)
+ }
+ return h >>> 0
+}
+
+function random(seed: number) {
+ let a = seed
+ return () => {
+ a = (a + 0x6d2b79f5) | 0
+ let t = Math.imul(a ^ (a >>> 15), 1 | a)
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296
+ }
+}
+
+const GRID = 5
+const TILE = 54
+const RISE = 30
+const ACCENTS = ['#e0384b', '#5aa300', '#2c63e0']
+const NEUTRAL = { top: '#f9fafb', left: '#d6dbe2', right: '#b8c0cb' }
+
+function mix(hex: string, other: number, amount: number): string {
+ const channel = (i: number) => parseInt(hex.slice(1 + i * 2, 3 + i * 2), 16)
+ const to = (i: number) => Math.round(channel(i) * (1 - amount) + other * amount).toString(16).padStart(2, '0')
+ return `#${to(0)}${to(1)}${to(2)}`
+}
+
+type Cube = { i: number; j: number; k: number; top: string; left: string; right: string }
+
+function build(name: string): Cube[] {
+ const next = random(hash(name))
+ const cubes: Cube[] = []
+ const accent = ACCENTS[Math.floor(next() * ACCENTS.length)]
+ const second = ACCENTS[Math.floor(next() * ACCENTS.length)]
+ for (let i = 0; i < GRID; i++) {
+ for (let j = 0; j < GRID; j++) {
+ const edge = Math.abs(i - 2) + Math.abs(j - 2)
+ const height = Math.max(0, Math.round(next() ** 1.3 * 4 - edge * 0.35))
+ for (let k = 0; k < height; k++) {
+ const isTop = k === height - 1
+ const pick = next()
+ const color = isTop && pick < 0.3 ? accent : isTop && pick < 0.4 ? second : null
+ cubes.push(
+ color
+ ? { i, j, k, top: mix(color, 255, 0.22), left: color, right: mix(color, 0, 0.24) }
+ : { i, j, k, ...NEUTRAL },
+ )
+ }
+ }
+ }
+ if (cubes.length < 5) cubes.push({ i: 2, j: 2, k: 0, ...NEUTRAL }, { i: 2, j: 2, k: 1, top: mix(accent, 255, 0.22), left: accent, right: mix(accent, 0, 0.24) })
+ return cubes.sort((a, b) => a.i + a.j - (b.i + b.j) || a.k - b.k)
+}
+
+const face = (points: [number, number][]) => points.map((p) => p.join(',')).join(' ')
+
+export default function Cover({ name }: Readonly<{ name: string }>) {
+ const cubes = useMemo(() => build(name), [name])
+ const originX = 220
+ const originY = 150
+ const point = (i: number, j: number, z: number, dx: number, dy: number): [number, number] => [
+ +(originX + (i - j) * (TILE / 2) + dx).toFixed(1),
+ +(originY + (i + j) * (TILE / 4) + dy - z).toFixed(1),
+ ]
+
+ return (
+
+ )
+}
diff --git a/components/Gizmo.tsx b/components/Gizmo.tsx
new file mode 100644
index 0000000..fc379f4
--- /dev/null
+++ b/components/Gizmo.tsx
@@ -0,0 +1,59 @@
+'use client'
+
+import { useScrollProgress } from '@/lib/useScroll'
+import styles from './gizmo.module.css'
+
+const AXES = [
+ { label: 'X', vector: [1, 0, 0], color: 'var(--x)' },
+ { label: 'Y', vector: [0, 1, 0], color: 'var(--y)' },
+ { label: 'Z', vector: [0, 0, 1], color: 'var(--z)' },
+] as const
+
+const PITCH = 0.46
+const LENGTH = 29
+const CENTER = 48
+
+function project([x, y, z]: readonly [number, number, number], yaw: number) {
+ const x1 = x * Math.cos(yaw) + z * Math.sin(yaw)
+ const z1 = -x * Math.sin(yaw) + z * Math.cos(yaw)
+ const y2 = y * Math.cos(PITCH) - z1 * Math.sin(PITCH)
+ const depth = y * Math.sin(PITCH) + z1 * Math.cos(PITCH)
+ return { x: CENTER + x1 * LENGTH, y: CENTER - y2 * LENGTH, depth }
+}
+
+/** Orientation gizmo of a 3D viewport. It orbits as the page scrolls and returns to the top when pressed. */
+export default function Gizmo() {
+ const progress = useScrollProgress()
+ const yaw = 0.62 + progress * Math.PI * 1.35
+
+ const ends = AXES.flatMap((axis) =>
+ ([1, -1] as const).map((sign) => ({
+ axis,
+ positive: sign === 1,
+ ...project([axis.vector[0] * sign, axis.vector[1] * sign, axis.vector[2] * sign], yaw),
+ })),
+ ).sort((a, b) => a.depth - b.depth)
+
+ return (
+
+ )
+}
diff --git a/components/Hero.tsx b/components/Hero.tsx
new file mode 100644
index 0000000..6e959ec
--- /dev/null
+++ b/components/Hero.tsx
@@ -0,0 +1,41 @@
+'use client'
+
+import { useRef } from 'react'
+import type { Resume } from '@/lib/types'
+import styles from './hero.module.css'
+
+type Props = { resume: Resume; mailHref: string; showProjects: boolean }
+
+export default function Hero({ resume, mailHref, showProjects }: Readonly) {
+ const hero = useRef(null)
+
+ // The floor sways a couple of degrees with the pointer, like nudging the viewport.
+ const onPointerMove = (event: React.PointerEvent) => {
+ if (event.pointerType !== 'mouse' || !hero.current) return
+ const offset = event.clientX / window.innerWidth - 0.5
+ hero.current.style.setProperty('--sway', `${(offset * 3).toFixed(2)}deg`)
+ }
+
+ return (
+
+
+
+
+ {resume.availability}
+
+
{resume.name}
+
{resume.role}
+
{resume.bio}
+
+
+
+
+ )
+}
diff --git a/components/Projects.tsx b/components/Projects.tsx
new file mode 100644
index 0000000..de8cfc4
--- /dev/null
+++ b/components/Projects.tsx
@@ -0,0 +1,108 @@
+'use client'
+
+import { useMemo, useState } from 'react'
+import { planSpans, type Span } from '@/lib/plan'
+import type { Project } from '@/lib/types'
+import Cover from './Cover'
+import styles from './projects.module.css'
+
+const monthYear = (iso?: string) => (iso ? new Date(iso).toLocaleDateString('en', { month: 'short', year: 'numeric' }) : '')
+
+// Aspect ratio of a plate for each span on desktop, used to decide whether a demo image can be cropped to fill it.
+const PLATE_RATIO: Record = { 2: 4 / 3, 3: 3 / 2, 4: 16 / 9, 6: 16 / 10 }
+
+function Plate({ project, span }: Readonly<{ project: Project; span: Span }>) {
+ const [broken, setBroken] = useState(false)
+ const demo = project.demo
+ const title = project.title ?? project.name
+ if (!demo || broken) return
+
+ const drift = Math.abs(Math.log(demo.width / demo.height / PLATE_RATIO[span]))
+ const fits = drift < Math.log(1.7)
+ const image = (className: string, extra: React.ImgHTMLAttributes) => (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ )
+ return (
+ <>
+ {/* An image that does not match the plate is shown whole, over a blurred copy of itself. */}
+ {!fits && image(styles.backdrop, { alt: '', 'aria-hidden': true, loading: 'lazy' })}
+ {image(fits ? styles.cover : styles.contain, { alt: `${title}, demo`, loading: 'lazy', onError: () => setBroken(true) })}
+ >
+ )
+}
+
+function Item({ project, span }: Readonly<{ project: Project; span: Span }>) {
+ const title = project.title ?? project.name
+ const links = [
+ { label: 'Repository', href: project.url },
+ ...(project.homepage ? [{ label: 'Live site', href: project.homepage }] : []),
+ ...(project.links ?? []),
+ ]
+ return (
+
+
+
+
+
+
{title}
+ {(project.kind ?? project.language) &&
{project.kind ?? project.language}
}
+ {project.description &&
{project.description}
}
+ {project.highlights && project.highlights.length > 0 && (
+
{project.highlights.map((point) => - {point}
)}
+ )}
+
+ {project.tech.length > 0 &&
{project.tech.map((tag) => - {tag}
)}
}
+
+ {links.map((link) => (
+ {link.label}
+ ))}
+
+
+
+
+ )
+}
+
+type Props = {
+ projects: Project[]
+ status: 'loading' | 'live' | 'offline'
+ generatedAt?: string
+ profileUrl: string
+}
+
+export default function Projects({ projects, status, generatedAt, profileUrl }: Readonly) {
+ const spans = useMemo(() => planSpans(projects.length), [projects.length])
+ return (
+
+
+
+
Selected work
+ {status === 'live' &&
Read live from Gitea, updated {monthYear(generatedAt)}
}
+
+
+ {status === 'loading' && (
+
+
+
+ )}
+ {status === 'loading' && (
+
+ )}
+ {status === 'offline' && (
+
+ The project list could not be loaded right now. All repositories are on {profileUrl.replace('https://', '')}.
+
+ )}
+
+
+ {projects.map((project, index) => (
+
+ ))}
+
+
+
+ )
+}
diff --git a/components/Sections.tsx b/components/Sections.tsx
new file mode 100644
index 0000000..e80fab1
--- /dev/null
+++ b/components/Sections.tsx
@@ -0,0 +1,103 @@
+import type { Resume } from '@/lib/types'
+import styles from './sections.module.css'
+
+export function Skills({ resume }: Readonly<{ resume: Resume }>) {
+ return (
+
+
+
What I work with
+
+ {resume.skills.map((skill) => (
+
+ {skill.title}
+ {skill.text}
+ {skill.stack.map((tag) => - {tag}
)}
+
+ ))}
+
+
+
+ )
+}
+
+export function Experience({ resume }: Readonly<{ resume: Resume }>) {
+ return (
+
+
+
Experience
+
+ {resume.experience.map((entry) => (
+
+ {entry.meta}
+
+
{entry.title}
+
{entry.points.map((point) => - {point}
)}
+
+
+ ))}
+
+
+
+ )
+}
+
+export function Education({ resume }: Readonly<{ resume: Resume }>) {
+ return (
+
+
+
Education and languages
+
+
+
Education
+
+ {resume.education.map((entry) => (
+
+
{entry.institution}
+
{entry.program}
+
{entry.period}
+
+ ))}
+
+
+
+
Languages
+
+ {resume.languages.map((language) => (
+
+
- {language.name}
+ - {language.level}
+
+ ))}
+
+
+
+
Interests
+
{resume.interests.join(', ')}.
+
+
+
+
+ )
+}
+
+export function Contact({ resume }: Readonly<{ resume: Resume }>) {
+ const [primary, ...others] = resume.contacts
+ return (
+
+ )
+}
diff --git a/components/Site.tsx b/components/Site.tsx
new file mode 100644
index 0000000..dd159a2
--- /dev/null
+++ b/components/Site.tsx
@@ -0,0 +1,58 @@
+'use client'
+
+import { useEffect, useState } from 'react'
+import type { Live, Project, Resume } from '@/lib/types'
+import Gizmo from './Gizmo'
+import Hero from './Hero'
+import Projects from './Projects'
+import { Contact, Education, Experience, Skills } from './Sections'
+import TopBar from './TopBar'
+
+// Deployed on shanaty.ru the build points at the same-origin file; elsewhere (dev, GitHub Pages) it uses the public URL.
+const DATA_URL = process.env.NEXT_PUBLIC_DATA_URL ?? 'https://shanaty.ru/data/resume.json'
+const GITEA_PROFILE = 'https://git.shanaty.ru/Emil_Shanaty'
+
+type Status = 'loading' | 'live' | 'offline'
+
+export default function Site({ initial }: Readonly<{ initial: Resume }>) {
+ const [resume, setResume] = useState(initial)
+ const [projects, setProjects] = useState([])
+ const [generatedAt, setGeneratedAt] = useState()
+ const [status, setStatus] = useState('loading')
+
+ useEffect(() => {
+ const controller = new AbortController()
+ fetch(DATA_URL, { cache: 'no-store', signal: controller.signal })
+ .then((response) => (response.ok ? response.json() : Promise.reject(new Error(`HTTP ${response.status}`))))
+ .then((data: Live) => {
+ if (!data?.resume || !Array.isArray(data.projects)) throw new Error('unexpected data')
+ setResume(data.resume)
+ setProjects(data.projects)
+ setGeneratedAt(data.generated_at)
+ setStatus('live')
+ })
+ .catch((error: Error) => {
+ if (error.name !== 'AbortError') setStatus('offline')
+ })
+ return () => controller.abort()
+ }, [])
+
+ // Once the list has loaded and is empty (no repository carries the `resume` topic yet) the section is left out.
+ const showProjects = status !== 'live' || projects.length > 0
+ const mailHref = resume.contacts[0].href
+
+ return (
+ <>
+
+
+
+
+ {showProjects && }
+
+
+
+
+
+ >
+ )
+}
diff --git a/components/TopBar.tsx b/components/TopBar.tsx
new file mode 100644
index 0000000..24c1d92
--- /dev/null
+++ b/components/TopBar.tsx
@@ -0,0 +1,24 @@
+'use client'
+
+import { useScrolled } from '@/lib/useScroll'
+import styles from './topbar.module.css'
+
+type Props = { name: string; mailHref: string; showProjects: boolean }
+
+export default function TopBar({ name, mailHref, showProjects }: Readonly) {
+ const scrolled = useScrolled()
+ return (
+
+ )
+}
diff --git a/components/gizmo.module.css b/components/gizmo.module.css
new file mode 100644
index 0000000..ca064e9
--- /dev/null
+++ b/components/gizmo.module.css
@@ -0,0 +1,38 @@
+.gizmo {
+ position: fixed;
+ z-index: 40;
+ top: 84px;
+ right: max(16px, calc((100vw - 1240px) / 2 - 8px));
+ width: 88px;
+ height: 88px;
+ padding: 0;
+ border: 0;
+ border-radius: 50%;
+ background: rgba(244, 246, 248, 0.62);
+ box-shadow: 0 0 0 1px rgba(21, 24, 29, 0.08);
+ -webkit-backdrop-filter: blur(8px);
+ backdrop-filter: blur(8px);
+ cursor: pointer;
+}
+
+.gizmo svg {
+ display: block;
+ width: 100%;
+ height: 100%;
+}
+
+.letter {
+ fill: #fff;
+ font: 700 10.5px var(--font-body);
+ pointer-events: none;
+}
+
+@media (max-width: 760px) {
+ .gizmo {
+ top: auto;
+ bottom: 16px;
+ right: 16px;
+ width: 64px;
+ height: 64px;
+ }
+}
diff --git a/components/hero.module.css b/components/hero.module.css
new file mode 100644
index 0000000..5bed102
--- /dev/null
+++ b/components/hero.module.css
@@ -0,0 +1,191 @@
+.hero {
+ --sway: 0deg;
+ position: relative;
+ isolation: isolate;
+ display: flex;
+ flex-direction: column;
+ min-height: min(100svh, 1000px);
+ overflow: clip;
+ background: linear-gradient(180deg, #f5f7fa 0%, #e6e9ee 100%);
+}
+
+/* The floor is a stage of its own below the text, so the grid never runs through the copy at any window height. */
+.stage {
+ position: relative;
+ flex: 1;
+ min-height: 260px;
+ overflow: hidden;
+ background: linear-gradient(180deg, #d8dde4 0%, var(--studio-deep) 100%);
+ border-top: 1px solid rgba(21, 24, 29, 0.2);
+}
+
+.stage::after {
+ content: '';
+ position: absolute;
+ inset: auto 0 0 0;
+ height: 45%;
+ background: linear-gradient(180deg, transparent, var(--paper));
+}
+
+.floor {
+ position: absolute;
+ inset: 0;
+ perspective: 620px;
+ perspective-origin: 50% 0;
+ animation: open 1.7s cubic-bezier(0.2, 0.7, 0.2, 1) 0.1s both;
+}
+
+.plane {
+ position: absolute;
+ left: -70%;
+ right: -70%;
+ top: 0;
+ height: 300%;
+ transform-origin: 50% 0;
+ transform: rotateX(76deg) rotateZ(var(--sway));
+ background-image:
+ linear-gradient(to right, rgba(21, 24, 29, 0.2) 1px, transparent 1px),
+ linear-gradient(to bottom, rgba(21, 24, 29, 0.2) 1px, transparent 1px);
+ background-size: 80px 80px;
+ background-position: center top;
+ -webkit-mask-image: linear-gradient(to bottom, transparent 0%, #000 18%, #000 100%);
+ mask-image: linear-gradient(to bottom, transparent 0%, #000 18%, #000 100%);
+}
+
+/* The red X axis crosses the floor; the blue Z axis runs into the distance, like the axes of a 3D viewport. */
+.plane::before,
+.plane::after {
+ content: '';
+ position: absolute;
+}
+
+.plane::before {
+ left: 0;
+ right: 0;
+ top: 240px;
+ height: 3px;
+ background: var(--x);
+}
+
+.plane::after {
+ top: 0;
+ bottom: 0;
+ left: calc(50% - 1.5px);
+ width: 3px;
+ background: var(--z);
+}
+
+.content {
+ position: relative;
+ width: var(--shell);
+ margin: 0 auto;
+ padding: 136px 0 72px;
+}
+
+.status {
+ display: inline-flex;
+ align-items: center;
+ gap: 10px;
+ max-width: 100%;
+ padding: 8px 16px 8px 13px;
+ border-radius: 999px;
+ background: rgba(255, 255, 255, 0.72);
+ box-shadow: inset 0 0 0 1px var(--line);
+ font-size: 15px;
+ font-weight: 500;
+ line-height: 1.35;
+}
+
+.dot {
+ flex: none;
+ width: 9px;
+ height: 9px;
+ border-radius: 50%;
+ background: var(--y);
+ box-shadow: 0 0 0 4px rgba(90, 163, 0, 0.2);
+}
+
+.name {
+ margin: 30px 0 0 -0.045em;
+ font: 800 clamp(56px, 12.6vw, 204px) / 0.88 var(--font-head);
+ letter-spacing: -0.048em;
+}
+
+.role {
+ margin-top: 22px;
+ font: 500 clamp(22px, 2.7vw, 38px) / 1.15 var(--font-head);
+ letter-spacing: -0.022em;
+}
+
+.bio {
+ max-width: 58ch;
+ margin-top: 44px;
+ font-size: 19px;
+ line-height: 1.62;
+ color: rgba(21, 24, 29, 0.88);
+}
+
+.actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 12px;
+ margin-top: 32px;
+}
+
+.button {
+ padding: 14px 24px;
+ border-radius: var(--radius-control);
+ font: 600 16px/1.2 var(--font-body);
+ text-decoration: none;
+ transition: background-color 0.2s, color 0.2s, box-shadow 0.2s;
+}
+
+.primary {
+ background: var(--ink);
+ color: var(--paper);
+}
+
+.primary:hover {
+ background: var(--z);
+}
+
+.secondary {
+ box-shadow: inset 0 0 0 1.5px var(--ink);
+}
+
+.secondary:hover {
+ background: var(--ink);
+ color: var(--paper);
+}
+
+/* One orchestrated moment on load: the floor opens out from the Z axis while the text settles in. */
+.status,
+.name,
+.role,
+.bio,
+.actions {
+ animation: rise 0.9s cubic-bezier(0.2, 0.7, 0.2, 1) both;
+}
+
+.name { animation-delay: 0.06s; }
+.role { animation-delay: 0.16s; }
+.bio { animation-delay: 0.26s; }
+.actions { animation-delay: 0.34s; }
+
+@keyframes rise {
+ from { opacity: 0; transform: translateY(16px); }
+ to { opacity: 1; transform: none; }
+}
+
+@keyframes open {
+ from { clip-path: inset(0 50% 0 50%); }
+ to { clip-path: inset(0 0 0 0); }
+}
+
+@media (max-width: 760px) {
+ .content { padding: 112px 0 72px; }
+ .bio { margin-top: 32px; font-size: 18px; }
+ .stage { min-height: 200px; }
+ .plane::before { top: 170px; }
+ .plane { background-size: 56px 56px; }
+}
diff --git a/components/projects.module.css b/components/projects.module.css
new file mode 100644
index 0000000..cf5011a
--- /dev/null
+++ b/components/projects.module.css
@@ -0,0 +1,237 @@
+.section {
+ padding: 24px 0 var(--section);
+}
+
+.shell {
+ width: var(--shell);
+ margin: 0 auto;
+}
+
+.head {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 8px 24px;
+ margin-bottom: clamp(36px, 5vw, 64px);
+}
+
+.head h2 {
+ font: 700 clamp(40px, 5.6vw, 80px) / 1 var(--font-head);
+ letter-spacing: -0.04em;
+}
+
+.note {
+ font-size: 15px;
+ color: var(--muted);
+}
+
+.notice {
+ padding: 28px 0;
+ border-top: 1px solid var(--line);
+ border-bottom: 1px solid var(--line);
+ color: var(--muted);
+}
+
+.notice a {
+ color: var(--z-text);
+}
+
+/* Mobile first: one column. Tablet: two. Desktop: six columns, with the spans planned per project count. */
+.grid {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr);
+ gap: 56px 28px;
+}
+
+.item {
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+ min-width: 0;
+}
+
+.plate {
+ position: relative;
+ display: block;
+ aspect-ratio: 3 / 2;
+ overflow: hidden;
+ border-radius: var(--radius-plate);
+ background: radial-gradient(120% 90% at 50% 0%, #f3f5f8 0%, var(--studio) 55%, #d3d9e0 100%);
+ box-shadow: inset 0 0 0 1px rgba(21, 24, 29, 0.09);
+}
+
+.plate img,
+.plate svg {
+ display: block;
+ width: 100%;
+ height: 100%;
+}
+
+.cover {
+ object-fit: cover;
+}
+
+.contain {
+ position: relative;
+ object-fit: contain;
+ padding: 6%;
+ filter: drop-shadow(0 10px 24px rgba(21, 24, 29, 0.28));
+}
+
+.backdrop {
+ position: absolute;
+ inset: 0;
+ object-fit: cover;
+ transform: scale(1.3);
+ filter: blur(26px) saturate(1.2);
+ opacity: 0.55;
+}
+
+.caption {
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.caption h3 {
+ font: 700 clamp(24px, 2.2vw, 30px) / 1.1 var(--font-head);
+ letter-spacing: -0.03em;
+}
+
+.kind {
+ font-size: 15px;
+ font-weight: 500;
+ color: var(--muted);
+}
+
+.description {
+ max-width: 62ch;
+ color: rgba(21, 24, 29, 0.86);
+}
+
+.points {
+ display: grid;
+ gap: 4px;
+ font-size: 15px;
+ color: rgba(21, 24, 29, 0.78);
+}
+
+.points li {
+ position: relative;
+ padding-left: 18px;
+}
+
+.points li::before {
+ content: '';
+ position: absolute;
+ left: 2px;
+ top: 0.62em;
+ width: 6px;
+ height: 6px;
+ border-radius: 1px;
+ background: var(--z);
+}
+
+.foot {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ margin-top: auto;
+ padding-top: 8px;
+}
+
+.tags {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+
+.tags li {
+ padding: 3px 11px;
+ border-radius: 999px;
+ box-shadow: inset 0 0 0 1px var(--line);
+ font-size: 13.5px;
+ line-height: 1.5;
+}
+
+.links {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px 20px;
+ font-size: 15px;
+ font-weight: 600;
+}
+
+.links a {
+ text-decoration: underline;
+ text-decoration-thickness: 1.5px;
+ text-underline-offset: 4px;
+ text-decoration-color: rgba(21, 24, 29, 0.3);
+ transition: text-decoration-color 0.2s, color 0.2s;
+}
+
+.links a:hover {
+ color: var(--z-text);
+ text-decoration-color: currentColor;
+}
+
+/* Placeholder while the list loads: three empty plates that hold the space. */
+.skeleton {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 28px;
+}
+
+.skeleton span {
+ aspect-ratio: 3 / 2;
+ border-radius: var(--radius-plate);
+ background: linear-gradient(160deg, #eef1f4, var(--studio));
+}
+
+@media (min-width: 641px) and (max-width: 1000px) {
+ .grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+ /* An odd last project spans the full row instead of leaving a hole. */
+ .item:last-child:nth-child(odd) {
+ grid-column: 1 / -1;
+ }
+ .item:last-child:nth-child(odd):not(:only-child) .plate {
+ aspect-ratio: 16 / 9;
+ }
+}
+
+@media (max-width: 640px) {
+ .skeleton {
+ grid-template-columns: minmax(0, 1fr);
+ }
+ .skeleton span:nth-child(n + 2) {
+ display: none;
+ }
+}
+
+@media (min-width: 1001px) {
+ .grid {
+ grid-template-columns: repeat(6, minmax(0, 1fr));
+ gap: 72px 32px;
+ }
+ .item[data-span='2'] { grid-column: span 2; }
+ .item[data-span='3'] { grid-column: span 3; }
+ .item[data-span='4'] { grid-column: span 4; }
+ .item[data-span='6'] { grid-column: span 6; }
+
+ .item[data-span='2'] .plate { aspect-ratio: 4 / 3; }
+ .item[data-span='3'] .plate { aspect-ratio: 3 / 2; }
+ .item[data-span='4'] .plate { aspect-ratio: 16 / 9; }
+
+ /* A single project gets the whole width: plate and text side by side. */
+ .item[data-span='6'] {
+ display: grid;
+ grid-template-columns: minmax(0, 7fr) minmax(0, 5fr);
+ align-items: center;
+ gap: 40px;
+ }
+ .item[data-span='6'] .plate { aspect-ratio: 16 / 10; }
+}
diff --git a/components/sections.module.css b/components/sections.module.css
new file mode 100644
index 0000000..e62bd7d
--- /dev/null
+++ b/components/sections.module.css
@@ -0,0 +1,226 @@
+.section {
+ padding: var(--section) 0 0;
+}
+
+.shell {
+ width: var(--shell);
+ margin: 0 auto;
+}
+
+.title {
+ margin-bottom: clamp(32px, 4.5vw, 56px);
+ font: 700 clamp(36px, 5vw, 72px) / 1 var(--font-head);
+ letter-spacing: -0.04em;
+}
+
+/* Skills: two columns of rows divided by a rule, no boxes. */
+.skills {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 0 clamp(32px, 6vw, 96px);
+}
+
+.skill {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ padding: 28px 0 36px;
+ border-top: 1px solid var(--line);
+}
+
+.skill h3,
+.entry h3 {
+ font: 700 clamp(22px, 2.2vw, 28px) / 1.15 var(--font-head);
+ letter-spacing: -0.03em;
+}
+
+.skill p {
+ max-width: 52ch;
+ color: rgba(21, 24, 29, 0.82);
+}
+
+.tags {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ margin-top: 6px;
+}
+
+.tags li {
+ padding: 3px 11px;
+ border-radius: 999px;
+ background: rgba(21, 24, 29, 0.06);
+ font-size: 13.5px;
+ line-height: 1.5;
+}
+
+/* Experience */
+.entry {
+ display: grid;
+ grid-template-columns: minmax(150px, 1fr) minmax(0, 3fr);
+ gap: 24px 32px;
+ padding: 32px 0 40px;
+ border-top: 1px solid var(--line);
+}
+
+.meta {
+ color: var(--muted);
+ font-size: 15px;
+}
+
+.list {
+ display: grid;
+ gap: 10px;
+ margin-top: 18px;
+ max-width: 66ch;
+}
+
+.list li {
+ position: relative;
+ padding-left: 22px;
+}
+
+.list li::before {
+ content: '';
+ position: absolute;
+ left: 2px;
+ top: 0.7em;
+ width: 7px;
+ height: 7px;
+ border-radius: 1px;
+ background: var(--z);
+}
+
+/* Education, languages, interests */
+.columns {
+ display: grid;
+ grid-template-columns: 1.4fr 1fr 1.2fr;
+ gap: 40px clamp(32px, 5vw, 72px);
+ padding-top: 28px;
+ border-top: 1px solid var(--line);
+}
+
+.label {
+ margin-bottom: 14px;
+ font: 700 20px/1.2 var(--font-head);
+ letter-spacing: -0.02em;
+}
+
+.stack {
+ display: grid;
+ gap: 22px;
+}
+
+.strong {
+ font-weight: 600;
+}
+
+.languages {
+ display: grid;
+ gap: 8px;
+}
+
+.languages div {
+ display: flex;
+ justify-content: space-between;
+ gap: 16px;
+ padding-bottom: 8px;
+ border-bottom: 1px solid var(--line);
+}
+
+.languages dd {
+ color: var(--muted);
+}
+
+/* Contact: the one inverted band, closing the page. */
+.contact {
+ margin-top: var(--section);
+ padding: clamp(72px, 9vw, 128px) 0 40px;
+ background: var(--ink);
+ color: var(--paper);
+}
+
+.contactTitle {
+ font: 700 clamp(36px, 5vw, 72px) / 1 var(--font-head);
+ letter-spacing: -0.04em;
+}
+
+.contactLead {
+ margin-top: 16px;
+ color: rgba(244, 246, 248, 0.7);
+}
+
+.mail {
+ display: inline-block;
+ margin-top: clamp(28px, 4vw, 48px);
+ font: 700 clamp(26px, 6.4vw, 96px) / 1.05 var(--font-head);
+ letter-spacing: -0.045em;
+ overflow-wrap: anywhere;
+ text-decoration: none;
+ background: linear-gradient(var(--z), var(--z)) left bottom / 100% 0.08em no-repeat;
+ transition: background-size 0.25s, color 0.25s;
+}
+
+.mail:hover {
+ background-size: 100% 100%;
+}
+
+.contacts {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
+ gap: 0 32px;
+ margin-top: clamp(56px, 7vw, 96px);
+}
+
+.contacts li {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ padding: 18px 0;
+ border-top: 1px solid rgba(244, 246, 248, 0.2);
+}
+
+.contacts span {
+ font-size: 14px;
+ color: rgba(244, 246, 248, 0.6);
+}
+
+.contacts a {
+ text-decoration: underline;
+ text-decoration-thickness: 1.5px;
+ text-underline-offset: 4px;
+ text-decoration-color: rgba(244, 246, 248, 0.35);
+ overflow-wrap: anywhere;
+}
+
+.contacts a:hover {
+ text-decoration-color: currentColor;
+}
+
+.legal {
+ margin-top: clamp(48px, 6vw, 80px);
+ font-size: 14px;
+ color: rgba(244, 246, 248, 0.55);
+}
+
+@media (max-width: 900px) {
+ .skills {
+ grid-template-columns: minmax(0, 1fr);
+ }
+ .columns {
+ grid-template-columns: minmax(0, 1fr);
+ }
+}
+
+@media (max-width: 760px) {
+ .legal {
+ padding-right: 76px; /* keeps clear of the floating gizmo */
+ }
+}
+
+@media (max-width: 700px) {
+ .entry {
+ grid-template-columns: minmax(0, 1fr);
+ gap: 10px;
+ }
+}
diff --git a/components/topbar.module.css b/components/topbar.module.css
new file mode 100644
index 0000000..6b34037
--- /dev/null
+++ b/components/topbar.module.css
@@ -0,0 +1,69 @@
+.bar {
+ position: fixed;
+ z-index: 50;
+ inset: 0 0 auto 0;
+ border-bottom: 1px solid transparent;
+ transition: background-color 0.25s, border-color 0.25s;
+}
+
+.solid {
+ background: rgba(244, 246, 248, 0.86);
+ border-bottom-color: var(--line);
+ -webkit-backdrop-filter: blur(12px);
+ backdrop-filter: blur(12px);
+}
+
+.inner {
+ width: var(--shell);
+ height: 68px;
+ margin: 0 auto;
+ display: flex;
+ align-items: center;
+ gap: 32px;
+}
+
+.mark {
+ margin-right: auto;
+ font: 700 20px/1 var(--font-head);
+ letter-spacing: -0.03em;
+ text-decoration: none;
+}
+
+.nav {
+ display: flex;
+ gap: 28px;
+ font-size: 15px;
+ font-weight: 500;
+}
+
+.nav a {
+ text-decoration: none;
+ padding: 6px 0;
+ background: linear-gradient(currentColor, currentColor) left bottom / 0 1.5px no-repeat;
+ transition: background-size 0.25s;
+}
+
+.nav a:hover {
+ background-size: 100% 1.5px;
+}
+
+.mail {
+ padding: 10px 18px;
+ border-radius: var(--radius-control);
+ background: var(--ink);
+ color: var(--paper);
+ font-size: 15px;
+ font-weight: 600;
+ text-decoration: none;
+ transition: background-color 0.2s;
+}
+
+.mail:hover {
+ background: var(--z);
+}
+
+@media (max-width: 860px) {
+ .nav {
+ display: none;
+ }
+}
diff --git a/data/resume.json b/data/resume.json
index 370389b..295d3e2 100644
--- a/data/resume.json
+++ b/data/resume.json
@@ -1,10 +1,10 @@
{
"name": "Emil Shanayev",
- "eyebrow": "backend · devops · AI systems · scientific computing · game tech",
+ "eyebrow": "Backend, DevOps, AI systems, scientific computing and game tech",
"role": "Backend, DevOps & AI Systems Developer",
"bio": "I build AI-native products, backend services and the infrastructure to run them: from Python/FastAPI and Go services, WebSocket APIs and containerised CI/CD pipelines to distributed molecular workloads, LLM-controlled worlds and a Vulkan game engine. I run self-hosted services on my own Linux servers.",
"location": "Russia",
- "availability": "Open to internships, full-time, part-time and project work · remote, hybrid or on-site",
+ "availability": "Open to internships, and to full-time, part-time or project work. Remote, hybrid or on-site.",
"contacts": [
{
"label": "Email",
@@ -13,7 +13,7 @@
"note": "preferred"
},
{
- "label": "Email",
+ "label": "Email, alternative",
"value": "emil28092005@gmail.com",
"href": "mailto:emil28092005@gmail.com"
},
@@ -140,14 +140,14 @@
],
"education": [
{
- "period": "2026 — 2030",
+ "period": "2026 to 2030",
"institution": "HSE University, Moscow",
- "program": "Bachelor’s programme · Game and Digital Product Development · 1st-year student"
+ "program": "Bachelor’s programme in Game and Digital Product Development. First year."
},
{
- "period": "2023 — 2025",
+ "period": "2023 to 2025",
"institution": "Innopolis University",
- "program": "Data Structures and Artificial Intelligence (DSAI) · 2 years, incomplete higher education"
+ "program": "Data Structures and Artificial Intelligence (DSAI). Two years, higher education not completed."
}
],
"languages": [
@@ -161,12 +161,12 @@
}
],
"interests": [
- "DevOps & Infrastructure as Code",
- "High-load backend systems",
- "Linux & systems administration",
- "Computer graphics & game development",
- "Open source",
- "Learning from documentation and by building"
+ "DevOps and Infrastructure as Code",
+ "high-load backend systems",
+ "Linux and systems administration",
+ "computer graphics and game development",
+ "open source",
+ "learning from documentation and by building"
],
"projectNotes": {
"Cortex_Engine": {
diff --git a/lib/plan.ts b/lib/plan.ts
new file mode 100644
index 0000000..3d1ab6c
--- /dev/null
+++ b/lib/plan.ts
@@ -0,0 +1,39 @@
+export type Span = 2 | 3 | 4 | 6
+
+/**
+ * Column spans (out of 6) for `count` projects, so that every row is exactly full whatever the count:
+ * rows of two or three, a feature row first, and never a lone project in the last row (a single project
+ * spans the whole width). Two-project rows alternate 4+2 and 2+4 for rhythm.
+ */
+export function planSpans(count: number): Span[] {
+ if (count <= 0) return []
+ if (count === 1) return [6]
+
+ const sizes: number[] = []
+ const pattern = [2, 3, 3]
+ let remaining = count
+ for (let i = 0; remaining > 0; i++) {
+ const size = Math.min(pattern[i % pattern.length], remaining)
+ sizes.push(size)
+ remaining -= size
+ }
+
+ const last = sizes.length - 1
+ if (sizes[last] === 1) {
+ if (sizes[last - 1] === 3) {
+ sizes[last - 1] = 2
+ sizes[last] = 2
+ } else {
+ sizes[last - 1] = 3
+ sizes.pop()
+ }
+ }
+
+ const spans: Span[] = []
+ let pairs = 0
+ for (const size of sizes) {
+ if (size === 3) spans.push(2, 2, 2)
+ else spans.push(...(pairs++ % 2 === 0 ? ([4, 2] as const) : ([2, 4] as const)))
+ }
+ return spans
+}
diff --git a/lib/types.ts b/lib/types.ts
index 39030da..3f663ae 100644
--- a/lib/types.ts
+++ b/lib/types.ts
@@ -20,6 +20,8 @@ export type Resume = {
}
// A public Gitea repository tagged `resume`, merged with the optional notes from data/resume.json.
+export type Demo = { url: string; width: number; height: number }
+
export type Project = {
name: string
title?: string
@@ -33,6 +35,9 @@ export type Project = {
language?: string
stars?: number
updated?: string
+ rank?: number
+ // Image copied from `demo.*` in the repo root by the server sync; the size lets the layout reserve space.
+ demo?: Demo
}
// Shape of /data/resume.json, written every minute by the server-side sync.
diff --git a/lib/useScroll.ts b/lib/useScroll.ts
new file mode 100644
index 0000000..b362cfd
--- /dev/null
+++ b/lib/useScroll.ts
@@ -0,0 +1,43 @@
+'use client'
+
+import { useEffect, useState } from 'react'
+
+const reducedMotion = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches
+
+/** Scroll position of the page as 0..1. Updates at most once per frame; frozen when the user prefers reduced motion. */
+export function useScrollProgress(): number {
+ const [progress, setProgress] = useState(0)
+ useEffect(() => {
+ if (reducedMotion()) return
+ let frame = 0
+ const update = () => {
+ frame = 0
+ const max = document.documentElement.scrollHeight - window.innerHeight
+ setProgress(max > 0 ? Math.min(1, Math.max(0, window.scrollY / max)) : 0)
+ }
+ const schedule = () => {
+ if (!frame) frame = requestAnimationFrame(update)
+ }
+ update()
+ window.addEventListener('scroll', schedule, { passive: true })
+ window.addEventListener('resize', schedule)
+ return () => {
+ window.removeEventListener('scroll', schedule)
+ window.removeEventListener('resize', schedule)
+ if (frame) cancelAnimationFrame(frame)
+ }
+ }, [])
+ return progress
+}
+
+/** True once the page has scrolled past the very top; only changes state when that flips. */
+export function useScrolled(threshold = 8): boolean {
+ const [scrolled, setScrolled] = useState(false)
+ useEffect(() => {
+ const update = () => setScrolled(window.scrollY > threshold)
+ update()
+ window.addEventListener('scroll', update, { passive: true })
+ return () => window.removeEventListener('scroll', update)
+ }, [threshold])
+ return scrolled
+}