Make the resume bilingual and part of the shared site chrome
Deploy to GitHub Pages / build (push) Canceled after 0s
Deploy to GitHub Pages / deploy (push) Canceled after 0s

Adds a Russian version (data/resume.ru.json, default language) with an RU/EN
switch stored under the key shared with the front page and the portfolio.
The top bar becomes the site-wide one (brand, Resume, Portfolio, Gitea,
Contact, language, theme) with a second row of anchors for this page's
sections, and the fonts move to Unbounded and Onest so the three pages share
one type system. Portfolio links now point at /portfolio/.

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 23:48:38 +03:00
co-authored by Claude Sonnet 5
parent 4d485fc19d
commit 04c1cc5877
27 changed files with 425 additions and 176 deletions
+15 -10
View File
@@ -1,13 +1,16 @@
'use client'
import { useRef } from 'react'
import { Bi } from '@/lib/i18n'
import type { Portfolio, Resume } from '@/lib/types'
import styles from './hero.module.css'
type Props = { resume: Resume; portfolio: Portfolio; mailHref: string; showProjects: boolean }
type TextKey = 'name' | 'role' | 'bio' | 'availability'
type Props = { en: Resume; ru: Resume; portfolio: Portfolio; mailHref: string; showProjects: boolean }
export default function Hero({ resume, portfolio, mailHref, showProjects }: Readonly<Props>) {
export default function Hero({ en, ru, portfolio, mailHref, showProjects }: Readonly<Props>) {
const hero = useRef<HTMLElement>(null)
const text = (key: TextKey) => <Bi ru={ru[key] || en[key]} en={en[key]} />
// The floor sways a couple of degrees with the pointer, like nudging the viewport.
const onPointerMove = (event: React.PointerEvent) => {
@@ -21,17 +24,19 @@ export default function Hero({ resume, portfolio, mailHref, showProjects }: Read
<div className={styles.content}>
<p className={styles.status}>
<span className={styles.dot} />
{resume.availability}
<span>{text('availability')}</span>
</p>
<h1 className={styles.name}>{resume.name}</h1>
<p className={styles.role}>{resume.role}</p>
<p className={styles.bio}>{resume.bio}</p>
<h1 className={styles.name}>{text('name')}</h1>
<p className={styles.role}>{text('role')}</p>
<p className={styles.bio}>{text('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={portfolio.href} target="_blank" rel="noreferrer">
Full portfolio
{showProjects && (
<a className={`${styles.button} ${styles.primary}`} href="#projects"><Bi ru="Проекты" en="See projects" /></a>
)}
<a className={`${styles.button} ${showProjects ? styles.secondary : styles.primary}`} href={portfolio.href}>
<Bi ru={portfolio.label ?? 'Всё портфолио'} en={portfolio.label ?? 'Full portfolio'} />
</a>
<a className={styles.link} href={mailHref}>Email me</a>
<a className={styles.link} href={mailHref}><Bi ru="Написать" en="Email me" /></a>
</div>
</div>
<div className={styles.stage} aria-hidden="true">
+31
View File
@@ -0,0 +1,31 @@
'use client'
import { useLang } from '@/lib/useLang'
import type { Lang } from '@/lib/i18n'
import styles from './topbar.module.css'
/** RU / EN switch. The choice is stored under the key shared with the rest of shanaty.ru. */
export default function LangSwitch() {
const lang = useLang()
const choose = (next: Lang) => {
const html = document.documentElement
html.dataset.lang = next
html.lang = next
document.title = html.getAttribute(`data-title-${next}`) ?? document.title
try {
localStorage.setItem('lang', next)
} catch {
// Storage can be blocked; the language still changes for this visit.
}
window.dispatchEvent(new Event('langchange'))
}
return (
<div className={styles.seg} role="group" aria-label={lang === 'ru' ? 'Язык' : 'Language'}>
{(['ru', 'en'] as const).map((code) => (
<button key={code} type="button" aria-pressed={lang === code} onClick={() => choose(code)}>
{code.toUpperCase()}
</button>
))}
</div>
)
}
+30 -20
View File
@@ -1,13 +1,12 @@
'use client'
import { useMemo, useState } from 'react'
import { Bi, count, monthYear } from '@/lib/i18n'
import { planSpans, type Span } from '@/lib/plan'
import type { Portfolio, 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 }
@@ -27,21 +26,21 @@ function Plate({ project, span }: Readonly<{ project: Project; span: Span }>) {
<>
{/* 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) })}
{image(fits ? styles.cover : styles.contain, { alt: title, 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 ?? []),
const links: { label: React.ReactNode; href: string }[] = [
{ label: <Bi ru="Репозиторий" en="Repository" />, href: project.url },
...(project.homepage ? [{ label: <Bi ru="Сайт" en="Live site" />, href: project.homepage }] : []),
...(project.links ?? []).map((link) => ({ label: link.label, href: link.href })),
]
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`}>
<a className={styles.plate} href={project.url} target="_blank" rel="noreferrer" aria-label={title}>
<Plate project={project} span={span} />
</a>
<div className={styles.caption}>
@@ -74,12 +73,21 @@ type Props = {
export default function Projects({ projects, status, generatedAt, profileUrl, portfolio }: Readonly<Props>) {
const spans = useMemo(() => planSpans(projects.length), [projects.length])
const profile = profileUrl.replace('https://', '')
const total = portfolio.count ? count(portfolio.count, ['проект', 'проекта', 'проектов'], ['project', 'projects']) : null
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>}
<h2><Bi ru="Избранные проекты" en="Selected work" /></h2>
{status === 'live' && (
<p className={styles.note}>
<Bi
ru={`Данные читаются из Gitea, обновлено ${monthYear(generatedAt, 'ru')}`}
en={`Read live from Gitea, updated ${monthYear(generatedAt, 'en')}`}
/>
</p>
)}
</div>
{status === 'loading' && (
@@ -87,14 +95,12 @@ export default function Projects({ projects, status, generatedAt, profileUrl, po
<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>.
<Bi
ru={<>Список проектов сейчас не загрузился. Все репозитории лежат на <a href={profileUrl}>{profile}</a>.</>}
en={<>The project list could not be loaded right now. All repositories are on <a href={profileUrl}>{profile}</a>.</>}
/>
</p>
)}
@@ -105,14 +111,18 @@ export default function Projects({ projects, status, generatedAt, profileUrl, po
</div>
{status === 'live' && projects.length > 0 && (
<a className={styles.more} href={portfolio.href} target="_blank" rel="noreferrer">
<a className={styles.more} href={portfolio.href}>
<span>
<span className={styles.moreTitle}>There is more in the portfolio</span>
<span className={styles.moreTitle}><Bi ru="Есть ещё в портфолио" en="There is more in the portfolio" /></span>
<span className={styles.moreText}>
{portfolio.count ? `${portfolio.count} projects in total, including the ones above.` : 'The full list of projects, including the ones above.'}
{total ? (
<Bi ru={`Всего ${total.ru}, включая эти.`} en={`${total.en} in total, including the ones above.`} />
) : (
<Bi ru="Полный список проектов, включая эти." en="The full list of projects, including the ones above." />
)}
</span>
</span>
<span className={styles.moreButton}>{portfolio.label ?? 'Open the portfolio'}</span>
<span className={styles.moreButton}><Bi ru="Открыть портфолио" en="Open the portfolio" /></span>
</a>
)}
</div>
+88 -50
View File
@@ -1,78 +1,108 @@
import { Bi } from '@/lib/i18n'
import type { Resume } from '@/lib/types'
import styles from './sections.module.css'
export function Skills({ resume }: Readonly<{ resume: Resume }>) {
// The English resume gives the structure; the Russian one is paired with it by position, and anything the
// Russian file lacks falls back to the English text.
type Pair = { en: Resume; ru: Resume }
export function Skills({ en, ru }: Readonly<Pair>) {
return (
<section className={styles.section} id="skills">
<div className={styles.shell}>
<h2 className={styles.title}>What I work with</h2>
<h2 className={styles.title}><Bi ru="Чем я занимаюсь" en="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>
))}
{en.skills.map((skill, i) => {
const r = ru.skills?.[i]
return (
<article className={styles.skill} key={skill.title}>
<h3><Bi ru={r?.title ?? skill.title} en={skill.title} /></h3>
<p><Bi ru={r?.text ?? skill.text} en={skill.text} /></p>
<ul className={styles.tags}>
{skill.stack.map((tag, t) => (
<li key={tag}><Bi ru={r?.stack?.[t] ?? tag} en={tag} /></li>
))}
</ul>
</article>
)
})}
</div>
</div>
</section>
)
}
export function Experience({ resume }: Readonly<{ resume: Resume }>) {
export function Experience({ en, ru }: Readonly<Pair>) {
return (
<section className={styles.section} id="experience">
<div className={styles.shell}>
<h2 className={styles.title}>Experience</h2>
<h2 className={styles.title}><Bi ru="Опыт" en="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>
))}
{en.experience.map((entry, i) => {
const r = ru.experience?.[i]
return (
<article className={styles.entry} key={entry.title}>
<p className={styles.meta}><Bi ru={r?.meta ?? entry.meta} en={entry.meta} /></p>
<div>
<h3><Bi ru={r?.title ?? entry.title} en={entry.title} /></h3>
<ul className={styles.list}>
{entry.points.map((point, p) => (
<li key={point}><Bi ru={r?.points?.[p] ?? point} en={point} /></li>
))}
</ul>
</div>
</article>
)
})}
</div>
</div>
</section>
)
}
export function Education({ resume }: Readonly<{ resume: Resume }>) {
export function Education({ en, ru }: Readonly<Pair>) {
return (
<section className={styles.section} id="education">
<div className={styles.shell}>
<h2 className={styles.title}>Education and languages</h2>
<h2 className={styles.title}><Bi ru="Образование и языки" en="Education and languages" /></h2>
<div className={styles.columns}>
<div>
<h3 className={styles.label}>Education</h3>
<h3 className={styles.label}><Bi ru="Образование" en="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>
))}
{en.education.map((entry, i) => {
const r = ru.education?.[i]
return (
<div key={entry.institution}>
<p className={styles.strong}><Bi ru={r?.institution ?? entry.institution} en={entry.institution} /></p>
<p><Bi ru={r?.program ?? entry.program} en={entry.program} /></p>
<p className={styles.meta}><Bi ru={r?.period ?? entry.period} en={entry.period} /></p>
</div>
)
})}
</div>
</div>
<div>
<h3 className={styles.label}>Languages</h3>
<h3 className={styles.label}><Bi ru="Языки" en="Languages" /></h3>
<dl className={styles.languages}>
{resume.languages.map((language) => (
<div key={language.name}>
<dt>{language.name}</dt>
<dd>{language.level}</dd>
</div>
))}
{en.languages.map((language, i) => {
const r = ru.languages?.[i]
return (
<div key={language.name}>
<dt><Bi ru={r?.name ?? language.name} en={language.name} /></dt>
<dd><Bi ru={r?.level ?? language.level} en={language.level} /></dd>
</div>
)
})}
</dl>
</div>
<div>
<h3 className={styles.label}>Interests</h3>
<p>{resume.interests.join(', ')}.</p>
<h3 className={styles.label}><Bi ru="Интересы" en="Interests" /></h3>
<p>
<Bi
ru={`${(ru.interests?.length ? ru.interests : en.interests).join(', ')}.`}
en={`${en.interests.join(', ')}.`}
/>
</p>
</div>
</div>
</div>
@@ -80,23 +110,31 @@ export function Education({ resume }: Readonly<{ resume: Resume }>) {
)
}
export function Contact({ resume }: Readonly<{ resume: Resume }>) {
const [primary, ...others] = resume.contacts
export function Contact({ en, ru }: Readonly<Pair>) {
const [primary, ...others] = en.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>
<h2 className={styles.contactTitle}><Bi ru="Связаться" en="Get in touch" /></h2>
<p className={styles.contactLead}><Bi ru="Почта — самый быстрый способ." en="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>
))}
{others.map((contact, i) => {
const label = ru.contacts?.[i + 1]?.label ?? contact.label
return (
<li key={contact.value}>
<span>{label === contact.label ? contact.label : <Bi ru={label} en={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>
<p className={styles.legal}>
<Bi
ru="© 2026 Эмиль Шанаев. Сайт работает на собственном сервере: Gitea, Caddy и Docker. Резюме и проекты подтягиваются из репозиториев."
en="© 2026 Emil Shanayev. This site runs on my own server: Gitea, Caddy and Docker. The resume and projects are read from repositories."
/>
</p>
</div>
</footer>
)
+18 -12
View File
@@ -6,18 +6,20 @@ import Gizmo from './Gizmo'
import Hero from './Hero'
import Projects from './Projects'
import { Contact, Education, Experience, Skills } from './Sections'
import TitleSync from './TitleSync'
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'
// Used until (or unless) the live data supplies the link, so the portfolio button is always there.
const PORTFOLIO_FALLBACK: Portfolio = { href: 'https://git.shanaty.ru/explore/repos?q=portfolio&topic=1' }
const PORTFOLIO_FALLBACK: Portfolio = { href: `${process.env.NEXT_PUBLIC_SITE_ORIGIN ?? 'https://shanaty.ru'}/portfolio/` }
type Status = 'loading' | 'live' | 'offline'
export default function Site({ initial }: Readonly<{ initial: Resume }>) {
const [resume, setResume] = useState<Resume>(initial)
export default function Site({ initialEn, initialRu }: Readonly<{ initialEn: Resume; initialRu: Resume }>) {
const [en, setEn] = useState<Resume>(initialEn)
const [ru, setRu] = useState<Resume>(initialRu)
const [projects, setProjects] = useState<Project[]>([])
const [portfolio, setPortfolio] = useState<Portfolio>(PORTFOLIO_FALLBACK)
const [generatedAt, setGeneratedAt] = useState<string>()
@@ -29,7 +31,8 @@ export default function Site({ initial }: Readonly<{ initial: Resume }>) {
.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)
setEn(data.resume)
if (data.resume_ru) setRu(data.resume_ru)
setProjects(data.projects)
if (data.portfolio?.href) setPortfolio(data.portfolio)
setGeneratedAt(data.generated_at)
@@ -43,20 +46,23 @@ export default function Site({ initial }: Readonly<{ initial: Resume }>) {
// 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
const mailHref = en.contacts[0].href
return (
<>
<TopBar name={resume.name} portfolio={portfolio} showProjects={showProjects} />
<TitleSync />
<TopBar showProjects={showProjects} />
<Gizmo />
<main>
<Hero resume={resume} portfolio={portfolio} mailHref={mailHref} showProjects={showProjects} />
{showProjects && <Projects projects={projects} status={status} generatedAt={generatedAt} profileUrl={GITEA_PROFILE} portfolio={portfolio} />}
<Skills resume={resume} />
<Experience resume={resume} />
<Education resume={resume} />
<Hero en={en} ru={ru} portfolio={portfolio} mailHref={mailHref} showProjects={showProjects} />
{showProjects && (
<Projects projects={projects} status={status} generatedAt={generatedAt} profileUrl={GITEA_PROFILE} portfolio={portfolio} />
)}
<Skills en={en} ru={ru} />
<Experience en={en} ru={ru} />
<Education en={en} ru={ru} />
</main>
<Contact resume={resume} />
<Contact en={en} ru={ru} />
</>
)
}
+7 -1
View File
@@ -1,6 +1,7 @@
'use client'
import { useEffect, useState } from 'react'
import { useLang } from '@/lib/useLang'
import styles from './topbar.module.css'
type Theme = 'dark' | 'light'
@@ -8,6 +9,7 @@ type Theme = 'dark' | 'light'
/** Switches between the dark (default) and light theme; the choice is remembered in this browser. */
export default function ThemeToggle() {
const [theme, setTheme] = useState<Theme>('dark')
const lang = useLang()
// The page starts dark; a small script in <head> applies a stored "light" choice before the first paint.
useEffect(() => {
@@ -30,7 +32,11 @@ export default function ThemeToggle() {
type="button"
className={styles.toggle}
onClick={toggle}
aria-label={theme === 'dark' ? 'Switch to the light theme' : 'Switch to the dark theme'}
aria-label={
theme === 'dark'
? lang === 'ru' ? 'Включить светлую тему' : 'Switch to the light theme'
: lang === 'ru' ? 'Включить тёмную тему' : 'Switch to the dark theme'
}
>
{theme === 'dark' ? (
<svg viewBox="0 0 24 24" aria-hidden="true">
+23
View File
@@ -0,0 +1,23 @@
'use client'
import { useEffect } from 'react'
/** Keeps the tab title in the visitor's language: Next re-applies its default title while hydrating. */
export default function TitleSync() {
useEffect(() => {
const apply = () => {
const html = document.documentElement
const wanted = html.getAttribute(`data-title-${html.dataset.lang}`)
if (wanted && document.title !== wanted) document.title = wanted
}
apply()
const observer = new MutationObserver(apply)
observer.observe(document.head, { childList: true, subtree: true, characterData: true })
window.addEventListener('langchange', apply)
return () => {
observer.disconnect()
window.removeEventListener('langchange', apply)
}
}, [])
return null
}
+25 -12
View File
@@ -1,27 +1,40 @@
'use client'
import { Bi, SITE } from '@/lib/i18n'
import { useScrolled } from '@/lib/useScroll'
import type { Portfolio } from '@/lib/types'
import LangSwitch from './LangSwitch'
import ThemeToggle from './ThemeToggle'
import styles from './topbar.module.css'
type Props = { name: string; portfolio: Portfolio; showProjects: boolean }
export default function TopBar({ name, portfolio, showProjects }: Readonly<Props>) {
/** The same bar as on the front page and the portfolio, plus a row of anchors for the sections of this page. */
export default function TopBar({ showProjects }: Readonly<{ showProjects: boolean }>) {
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>
<a href="#contact">Contact</a>
<a className={styles.brand} href={`${SITE}/`} aria-label="shanaty.ru">
<svg viewBox="0 0 100 100" width="30" height="30" aria-hidden="true">
<path d="M70 30A20 20 0 1 0 50 50A20 20 0 1 1 30 70" transform="translate(50 50) scale(.78) translate(-50 -50)" fill="none" stroke="currentColor" strokeWidth="13" strokeLinecap="round" />
<circle cx="50" cy="50" r="8" fill="var(--y)" />
</svg>
<span>shanaty.ru</span>
</a>
<nav className={styles.nav} aria-label="shanaty.ru">
<a href={`${SITE}/resume/`} aria-current="page"><Bi ru="Резюме" en="Resume" /></a>
<a href={`${SITE}/portfolio/`}><Bi ru="Портфолио" en="Portfolio" /></a>
<a href="https://git.shanaty.ru/Emil_Shanaty">Gitea</a>
<a href="#contact"><Bi ru="Контакты" en="Contact" /></a>
</nav>
<LangSwitch />
<ThemeToggle />
<a className={styles.cta} href={portfolio.href} target="_blank" rel="noreferrer">{portfolio.label ?? 'Portfolio'}</a>
</div>
<div className={styles.subwrap}>
<nav className={styles.sub} aria-label="Page sections">
{showProjects && <a href="#projects"><Bi ru="Проекты" en="Projects" /></a>}
<a href="#skills"><Bi ru="Навыки" en="Skills" /></a>
<a href="#experience"><Bi ru="Опыт" en="Experience" /></a>
<a href="#education"><Bi ru="Образование" en="Education" /></a>
</nav>
</div>
</header>
)
+1 -1
View File
@@ -1,7 +1,7 @@
.gizmo {
position: fixed;
z-index: 40;
top: 84px;
top: 128px;
right: max(16px, calc((100vw - 1240px) / 2 - 8px));
width: 88px;
height: 88px;
+11 -4
View File
@@ -107,14 +107,15 @@
.name {
margin: 30px 0 0 -0.045em;
font: var(--name-weight) clamp(56px, 12.6vw, 204px) / 0.88 var(--font-head);
letter-spacing: var(--name-tracking);
font: 700 clamp(38px, 7.1vw, 118px) / 1 var(--font-head);
letter-spacing: -0.05em;
overflow-wrap: break-word;
}
.role {
margin-top: 22px;
font: 500 clamp(22px, 2.7vw, 38px) / 1.15 var(--font-head);
letter-spacing: -0.022em;
font: 500 clamp(18px, 2.1vw, 30px) / 1.2 var(--font-head);
letter-spacing: -0.035em;
}
.bio {
@@ -182,6 +183,12 @@
to { clip-path: inset(0 0 0 0); }
}
@media (max-width: 1000px) {
.name {
font-size: clamp(44px, 12.5vw, 104px);
}
}
@media (max-width: 760px) {
.content { padding: 112px 0 72px; }
.bio { margin-top: 32px; font-size: 18px; }
+6 -6
View File
@@ -17,8 +17,8 @@
}
.head h2 {
font: 700 clamp(40px, 5.6vw, 80px) / 1 var(--font-head);
letter-spacing: -0.04em;
font: 700 clamp(28px, 4.2vw, 60px) / 1.05 var(--font-head);
letter-spacing: -0.045em;
}
.note {
@@ -96,8 +96,8 @@
}
.caption h3 {
font: 700 clamp(24px, 2.2vw, 30px) / 1.1 var(--font-head);
letter-spacing: -0.03em;
font: 700 clamp(19px, 1.9vw, 25px) / 1.15 var(--font-head);
letter-spacing: -0.04em;
}
.kind {
@@ -257,8 +257,8 @@
}
.moreTitle {
font: 700 clamp(28px, 3.8vw, 54px) / 1.05 var(--font-head);
letter-spacing: -0.035em;
font: 700 clamp(22px, 3vw, 40px) / 1.1 var(--font-head);
letter-spacing: -0.045em;
}
.moreText {
+10 -10
View File
@@ -9,8 +9,8 @@
.title {
margin-bottom: clamp(32px, 4.5vw, 56px);
font: 700 clamp(36px, 5vw, 72px) / 1 var(--font-head);
letter-spacing: -0.04em;
font: 700 clamp(28px, 4.2vw, 60px) / 1.05 var(--font-head);
letter-spacing: -0.045em;
}
/* Skills: two columns of rows divided by a rule, no boxes. */
@@ -30,8 +30,8 @@
.skill h3,
.entry h3 {
font: 700 clamp(22px, 2.2vw, 28px) / 1.15 var(--font-head);
letter-spacing: -0.03em;
font: 700 clamp(18px, 1.8vw, 23px) / 1.2 var(--font-head);
letter-spacing: -0.04em;
}
.skill p {
@@ -102,8 +102,8 @@
.label {
margin-bottom: 14px;
font: 700 20px/1.2 var(--font-head);
letter-spacing: -0.02em;
font: 700 17px/1.2 var(--font-head);
letter-spacing: -0.03em;
}
.stack {
@@ -141,8 +141,8 @@
}
.contactTitle {
font: 700 clamp(36px, 5vw, 72px) / 1 var(--font-head);
letter-spacing: -0.04em;
font: 700 clamp(28px, 4.2vw, 60px) / 1.05 var(--font-head);
letter-spacing: -0.045em;
}
.contactLead {
@@ -153,8 +153,8 @@
.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;
font: 700 clamp(17px, 5vw, 76px) / 1.08 var(--font-head);
letter-spacing: -0.05em;
overflow-wrap: anywhere;
text-decoration: none;
background: linear-gradient(var(--z), var(--z)) left bottom / 100% 0.08em no-repeat;
+63 -22
View File
@@ -19,26 +19,30 @@
margin: 0 auto;
display: flex;
align-items: center;
gap: 32px;
gap: 14px;
}
.mark {
.brand {
display: flex;
align-items: center;
gap: 10px;
margin-right: auto;
font: 700 20px/1 var(--font-head);
letter-spacing: -0.03em;
text-decoration: none;
font: 700 18px/1 var(--font-head);
letter-spacing: -0.03em;
}
.nav {
display: flex;
gap: 28px;
margin-right: 10px;
font-size: 15px;
font-weight: 500;
}
.nav a {
text-decoration: none;
padding: 6px 0;
text-decoration: none;
background: linear-gradient(currentColor, currentColor) left bottom / 0 1.5px no-repeat;
transition: background-size 0.25s;
}
@@ -47,26 +51,32 @@
background-size: 100% 1.5px;
}
.cta {
margin-left: -12px;
padding: 10px 18px;
.nav a[aria-current='page'] {
font-weight: 700;
background-size: 100% 2px;
}
.seg {
display: flex;
padding: 3px;
border-radius: var(--radius-control);
box-shadow: inset 0 0 0 1px var(--line);
}
.seg button {
padding: 6px 10px;
border: 0;
border-radius: 7px;
background: transparent;
color: var(--muted);
font: 600 13px/1.2 var(--font-body);
letter-spacing: 0.02em;
cursor: pointer;
}
.seg button[aria-pressed='true'] {
background: var(--ink);
color: var(--paper);
font-size: 15px;
font-weight: 600;
text-decoration: none;
transition: background-color 0.2s;
}
.cta:hover {
background: var(--z);
}
@media (max-width: 860px) {
.nav {
display: none;
}
}
.toggle {
@@ -97,3 +107,34 @@
stroke-linecap: round;
stroke-linejoin: round;
}
/* Second row: anchors for the sections of this page. */
.subwrap {
width: var(--shell);
margin: 0 auto;
}
.sub {
display: flex;
gap: 22px;
height: 34px;
align-items: flex-start;
font-size: 14px;
color: var(--muted);
}
.sub a {
text-decoration: none;
transition: color 0.2s;
}
.sub a:hover {
color: var(--ink);
}
@media (max-width: 860px) {
.nav,
.subwrap {
display: none;
}
}