Redesign the resume site as a 3D-editor viewport
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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user