Redesign the resume site as a 3D-editor viewport
Deploy to GitHub Pages / build (push) Canceled after 0s
Deploy to GitHub Pages / deploy (push) Canceled after 0s

New design: studio backdrop with a perspective floor and X/Z axis lines, an
orientation gizmo that orbits on scroll, Bricolage Grotesque and Instrument
Sans (bundled, no build-time network). Projects are laid out as plates by a
row planner that keeps every row full for any count; a demo image from the
repo root is shown when present, otherwise a generated voxel sculpture.
Text and projects still come live from Gitea.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XtcZtnG4GQA1d6mW5PNrS9
This commit is contained in:
Emil
2026-09-20 21:37:35 +03:00
co-authored by Claude Sonnet 5
parent 32c57a5ada
commit b5ffc67a82
23 changed files with 1440 additions and 374 deletions
+102
View File
@@ -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 (
<svg viewBox="0 0 440 320" preserveAspectRatio="xMidYMid meet" role="presentation" aria-hidden="true">
{Array.from({ length: GRID * GRID }, (_, n) => {
const i = Math.floor(n / GRID)
const j = n % GRID
return (
<polygon
key={n}
points={face([point(i, j, 0, 0, 0), point(i, j, 0, TILE / 2, TILE / 4), point(i, j, 0, 0, TILE / 2), point(i, j, 0, -TILE / 2, TILE / 4)])}
fill="none"
stroke="rgba(21,24,29,0.09)"
/>
)
})}
{cubes.map(({ i, j, k, top, left, right }) => {
const z = (k + 1) * RISE
const base = k * RISE
return (
<g key={`${i}-${j}-${k}`} strokeLinejoin="round" stroke="rgba(21,24,29,0.16)" strokeWidth="0.75">
<polygon points={face([point(i, j, z, -TILE / 2, TILE / 4), point(i, j, z, 0, TILE / 2), point(i, j, base, 0, TILE / 2), point(i, j, base, -TILE / 2, TILE / 4)])} fill={left} />
<polygon points={face([point(i, j, z, 0, TILE / 2), point(i, j, z, TILE / 2, TILE / 4), point(i, j, base, TILE / 2, TILE / 4), point(i, j, base, 0, TILE / 2)])} fill={right} />
<polygon points={face([point(i, j, z, 0, 0), point(i, j, z, TILE / 2, TILE / 4), point(i, j, z, 0, TILE / 2), point(i, j, z, -TILE / 2, TILE / 4)])} fill={top} />
</g>
)
})}
</svg>
)
}
+59
View File
@@ -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 (
<button
type="button"
className={styles.gizmo}
aria-label="Back to top"
onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
>
<svg viewBox="0 0 96 96" aria-hidden="true">
{ends.map(({ axis, positive, x, y }) =>
positive ? (
<g key={axis.label + '+'}>
<line x1={CENTER} y1={CENTER} x2={x} y2={y} stroke={axis.color} strokeWidth="3.2" strokeLinecap="round" />
<circle cx={x} cy={y} r="9.5" fill={axis.color} />
<text x={x} y={y + 3.6} textAnchor="middle" className={styles.letter}>{axis.label}</text>
</g>
) : (
<circle key={axis.label + '-'} cx={x} cy={y} r="6" fill={axis.color} opacity="0.32" />
),
)}
</svg>
</button>
)
}
+41
View File
@@ -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<Props>) {
const hero = useRef<HTMLElement>(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 (
<section className={styles.hero} id="top" ref={hero} onPointerMove={onPointerMove}>
<div className={styles.content}>
<p className={styles.status}>
<span className={styles.dot} />
{resume.availability}
</p>
<h1 className={styles.name}>{resume.name}</h1>
<p className={styles.role}>{resume.role}</p>
<p className={styles.bio}>{resume.bio}</p>
<div className={styles.actions}>
{showProjects && <a className={`${styles.button} ${styles.primary}`} href="#projects">See projects</a>}
<a className={`${styles.button} ${showProjects ? styles.secondary : styles.primary}`} href={mailHref}>Email me</a>
</div>
</div>
<div className={styles.stage} aria-hidden="true">
<div className={styles.floor}>
<div className={styles.plane} />
</div>
</div>
</section>
)
}
+108
View File
@@ -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<Span, number> = { 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 <Cover name={project.name} />
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<HTMLImageElement>) => (
// eslint-disable-next-line @next/next/no-img-element
<img className={className} src={demo.url} width={demo.width} height={demo.height} decoding="async" {...extra} />
)
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 (
<article className={styles.item} data-span={span}>
<a className={styles.plate} href={project.url} target="_blank" rel="noreferrer" aria-label={`${title}: open the repository`}>
<Plate project={project} span={span} />
</a>
<div className={styles.caption}>
<h3>{title}</h3>
{(project.kind ?? project.language) && <p className={styles.kind}>{project.kind ?? project.language}</p>}
{project.description && <p className={styles.description}>{project.description}</p>}
{project.highlights && project.highlights.length > 0 && (
<ul className={styles.points}>{project.highlights.map((point) => <li key={point}>{point}</li>)}</ul>
)}
<div className={styles.foot}>
{project.tech.length > 0 && <ul className={styles.tags}>{project.tech.map((tag) => <li key={tag}>{tag}</li>)}</ul>}
<p className={styles.links}>
{links.map((link) => (
<a key={link.href} href={link.href} target="_blank" rel="noreferrer">{link.label}</a>
))}
</p>
</div>
</div>
</article>
)
}
type Props = {
projects: Project[]
status: 'loading' | 'live' | 'offline'
generatedAt?: string
profileUrl: string
}
export default function Projects({ projects, status, generatedAt, profileUrl }: Readonly<Props>) {
const spans = useMemo(() => planSpans(projects.length), [projects.length])
return (
<section className={styles.section} id="projects">
<div className={styles.shell}>
<div className={styles.head}>
<h2>Selected work</h2>
{status === 'live' && <p className={styles.note}>Read live from Gitea, updated {monthYear(generatedAt)}</p>}
</div>
{status === 'loading' && (
<div className={styles.skeleton} aria-hidden="true">
<span /><span /><span />
</div>
)}
{status === 'loading' && (
<noscript>
<p className={styles.notice}>The project list is loaded live and needs JavaScript. See <a href={profileUrl}>{profileUrl.replace('https://', '')}</a>.</p>
</noscript>
)}
{status === 'offline' && (
<p className={styles.notice}>
The project list could not be loaded right now. All repositories are on <a href={profileUrl}>{profileUrl.replace('https://', '')}</a>.
</p>
)}
<div className={styles.grid} data-count={projects.length}>
{projects.map((project, index) => (
<Item key={project.name} project={project} span={spans[index]} />
))}
</div>
</div>
</section>
)
}
+103
View File
@@ -0,0 +1,103 @@
import type { Resume } from '@/lib/types'
import styles from './sections.module.css'
export function Skills({ resume }: Readonly<{ resume: Resume }>) {
return (
<section className={styles.section} id="skills">
<div className={styles.shell}>
<h2 className={styles.title}>What I work with</h2>
<div className={styles.skills}>
{resume.skills.map((skill) => (
<article className={styles.skill} key={skill.title}>
<h3>{skill.title}</h3>
<p>{skill.text}</p>
<ul className={styles.tags}>{skill.stack.map((tag) => <li key={tag}>{tag}</li>)}</ul>
</article>
))}
</div>
</div>
</section>
)
}
export function Experience({ resume }: Readonly<{ resume: Resume }>) {
return (
<section className={styles.section} id="experience">
<div className={styles.shell}>
<h2 className={styles.title}>Experience</h2>
<div className={styles.entries}>
{resume.experience.map((entry) => (
<article className={styles.entry} key={entry.title}>
<p className={styles.meta}>{entry.meta}</p>
<div>
<h3>{entry.title}</h3>
<ul className={styles.list}>{entry.points.map((point) => <li key={point}>{point}</li>)}</ul>
</div>
</article>
))}
</div>
</div>
</section>
)
}
export function Education({ resume }: Readonly<{ resume: Resume }>) {
return (
<section className={styles.section} id="education">
<div className={styles.shell}>
<h2 className={styles.title}>Education and languages</h2>
<div className={styles.columns}>
<div>
<h3 className={styles.label}>Education</h3>
<div className={styles.stack}>
{resume.education.map((entry) => (
<div key={entry.institution}>
<p className={styles.strong}>{entry.institution}</p>
<p>{entry.program}</p>
<p className={styles.meta}>{entry.period}</p>
</div>
))}
</div>
</div>
<div>
<h3 className={styles.label}>Languages</h3>
<dl className={styles.languages}>
{resume.languages.map((language) => (
<div key={language.name}>
<dt>{language.name}</dt>
<dd>{language.level}</dd>
</div>
))}
</dl>
</div>
<div>
<h3 className={styles.label}>Interests</h3>
<p>{resume.interests.join(', ')}.</p>
</div>
</div>
</div>
</section>
)
}
export function Contact({ resume }: Readonly<{ resume: Resume }>) {
const [primary, ...others] = resume.contacts
return (
<footer className={styles.contact} id="contact">
<div className={styles.shell}>
<h2 className={styles.contactTitle}>Get in touch</h2>
<p className={styles.contactLead}>Email is the fastest way to reach me.</p>
<a className={styles.mail} href={primary.href}>{primary.value}</a>
<ul className={styles.contacts}>
{others.map((contact) => (
<li key={contact.value}>
<span>{contact.label}</span>
<a href={contact.href} target={contact.href.startsWith('http') ? '_blank' : undefined} rel="noreferrer">{contact.value}</a>
</li>
))}
</ul>
<p className={styles.legal}>© 2026 {resume.name}. The text and the projects on this page are read live from Gitea.</p>
</div>
</footer>
)
}
+58
View File
@@ -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<Resume>(initial)
const [projects, setProjects] = useState<Project[]>([])
const [generatedAt, setGeneratedAt] = useState<string>()
const [status, setStatus] = useState<Status>('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 (
<>
<TopBar name={resume.name} mailHref={mailHref} showProjects={showProjects} />
<Gizmo />
<main>
<Hero resume={resume} mailHref={mailHref} showProjects={showProjects} />
{showProjects && <Projects projects={projects} status={status} generatedAt={generatedAt} profileUrl={GITEA_PROFILE} />}
<Skills resume={resume} />
<Experience resume={resume} />
<Education resume={resume} />
</main>
<Contact resume={resume} />
</>
)
}
+24
View File
@@ -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<Props>) {
const scrolled = useScrolled()
return (
<header className={`${styles.bar} ${scrolled ? styles.solid : ''}`}>
<div className={styles.inner}>
<a className={styles.mark} href="#top">{name}</a>
<nav className={styles.nav} aria-label="Sections">
{showProjects && <a href="#projects">Projects</a>}
<a href="#skills">Skills</a>
<a href="#experience">Experience</a>
<a href="#education">Education</a>
</nav>
<a className={styles.mail} href={mailHref}>Email me</a>
</div>
</header>
)
}
+38
View File
@@ -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;
}
}
+191
View File
@@ -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; }
}
+237
View File
@@ -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; }
}
+226
View File
@@ -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;
}
}
+69
View File
@@ -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;
}
}