'use client';
import { useEffect, useId, useRef, useState } from 'react';
import { cn, prefersReducedMotion, round, useInView, useLocale, useTexts, type Locale } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
import { Magnetic, Parallax, Stagger, useScrollFade, useScrollProgress } from '@/components/ui/motion';
import { NumberTicker } from '@/components/ui/number-ticker';
import { Reveal } from '@/components/ui/reveal';
import { Section, SectionHeader } from '@/components/ui/section';
/*
* A résumé as a web page: experience, education, skills, languages and a
* download of the PDF. Dates are given as YYYY-MM, a missing end means
* the role is current. Durations are worked out from the dates.
*
* classic side column with photo, contact, skills and languages, experience on a line that fills while scrolling
* timeline every role as a bar over the years, click one to read it, a list on phones
* skills skills by group with their level, pick one to see where it was used
* compact the one-page paper version, with download and print
* summary the short version on top of a page, numbers that count up, current role and availability
*/
export type ResumeVariant = 'classic' | 'timeline' | 'skills' | 'compact' | 'summary';
export interface ResumePerson {
name: string;
role: string;
summary?: string;
location?: string;
email?: string;
phone?: string;
website?: string;
photo?: React.ReactNode;
/** A short availability line with a live dot, e.g. "Open to new roles from April" */
available?: string;
}
export interface ResumeRole {
id: string;
role: string;
company: string;
place?: string;
/** YYYY-MM */
start: string;
/** YYYY-MM, leave out for the current role */
end?: string;
summary?: string;
points?: string[];
/** Skill names used in this role, they link the skills variant to the roles */
skills?: string[];
/** e.g. "Freelance" or "Part time", shown as a quiet tag */
kind?: string;
}
export interface ResumeEducation {
id: string;
title: string;
school: string;
start: string;
end?: string;
note?: string;
}
export interface ResumeSkill {
name: string;
/** 1 to 5 */
level: number;
group: string;
}
export interface ResumeProps {
variant?: ResumeVariant;
/** Optional heading above the variant */
eyebrow?: React.ReactNode;
title?: React.ReactNode;
description?: React.ReactNode;
person: ResumePerson;
experience?: ResumeRole[];
education?: ResumeEducation[];
skills?: ResumeSkill[];
languages?: { name: string; level: string }[];
/** summary: numbers, e.g. { value: 12, suffix: '+', label: 'years in product design' } */
stats?: { value: number; suffix?: string; label: string }[];
/** The PDF to download */
download?: { href: string; size?: string };
/** Extra links next to the download, e.g. portfolio */
links?: { label: string; href: string; icon?: IconName }[];
labels?: Partial<Texts>;
className?: string;
}
const TEXTS = {
en: {
experience: 'Experience',
education: 'Education',
skills: 'Skills',
languages: 'Languages',
contact: 'Contact',
present: 'today',
to: 'to',
years: '{n} yrs',
year: '1 yr',
months: '{n} mos',
month: '1 mo',
download: 'Download PDF',
print: 'Print',
current: 'Now',
usedIn: 'Used in',
pick: 'Pick a skill to see where it was used',
roles: '{n} roles',
role1: '1 role',
level: 'Level {n} of 5',
all: 'All',
groups: 'Skill groups',
details: 'Details of the selected role',
since: 'since {d}',
nowAt: 'Currently',
total: 'In total',
},
de: {
experience: 'Berufserfahrung',
education: 'Ausbildung',
skills: 'Fähigkeiten',
languages: 'Sprachen',
contact: 'Kontakt',
present: 'heute',
to: 'bis',
years: '{n} J.',
year: '1 J.',
months: '{n} Mon.',
month: '1 Mon.',
download: 'PDF herunterladen',
print: 'Drucken',
current: 'Aktuell',
usedIn: 'Eingesetzt bei',
pick: 'Wähle eine Fähigkeit, um zu sehen, wo sie zum Einsatz kam',
roles: '{n} Stationen',
role1: '1 Station',
level: 'Stufe {n} von 5',
all: 'Alle',
groups: 'Gruppen',
details: 'Details zur gewählten Station',
since: 'seit {d}',
nowAt: 'Derzeit',
total: 'Insgesamt',
},
};
type Texts = typeof TEXTS.en;
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
const TAG: Record<Locale, string> = { en: 'en-US', de: 'de-DE' };
const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));
const focusRing = 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background';
const fit = '[&>*]:size-full [&_img]:object-cover';
/** A fixed "today" would age, the current month is only used in the browser. On the server roles end with the latest given date. */
const ym = (s: string) => {
const [y, m] = s.split('-').map(Number);
return { y, m: m || 1 };
};
const monthIndex = (s: string) => {
const { y, m } = ym(s);
return y * 12 + (m - 1);
};
function useNow(fallback: string) {
const [now, setNow] = useState(fallback);
useEffect(() => {
const d = new Date();
setNow(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`);
}, []);
return now;
}
const latest = (roles: { start: string; end?: string }[]) => roles.reduce((a, r) => (monthIndex(r.end ?? r.start) > monthIndex(a) ? r.end ?? r.start : a), roles[0]?.start ?? '2000-01');
function monthName(s: string, locale: Locale, style: 'short' | 'numeric' = 'short') {
const { y, m } = ym(s);
return new Intl.DateTimeFormat(TAG[locale], { month: style === 'numeric' ? '2-digit' : 'short', year: 'numeric', timeZone: 'UTC' }).format(new Date(Date.UTC(y, m - 1, 1)));
}
function span(r: { start: string; end?: string }, t: Texts, locale: Locale) {
return `${monthName(r.start, locale)} ${t.to} ${r.end ? monthName(r.end, locale) : t.present}`;
}
function duration(r: { start: string; end?: string }, now: string, t: Texts) {
const n = monthIndex(r.end ?? now) - monthIndex(r.start) + 1;
const y = Math.floor(n / 12);
const m = n % 12;
const parts = [y ? (y === 1 ? t.year : fill(t.years, { n: y })) : '', m ? (m === 1 ? t.month : fill(t.months, { n: m })) : ''].filter(Boolean);
return parts.join(' ');
}
const MOTION_CSS = `
@keyframes kitcv-in{from{opacity:0;transform:translateY(8px)}}
@keyframes kitcv-swap{from{opacity:0;filter:blur(4px);transform:translateY(6px)}}
@keyframes kitcv-grow{from{transform:scaleX(0)}}
@media (prefers-reduced-motion:reduce){[class*="kitcv-"],.kitcv-anim{animation:none!important;transition:none!important}}`;
function CvMotion() {
return (
<style href="kitcv-motion" precedence="default">
{MOTION_CSS}
</style>
);
}
function Live({ text }: { text: string }) {
return (
<span className="inline-flex min-h-8 items-center gap-2 rounded-full bg-[color-mix(in_oklab,var(--success)_14%,transparent)] px-3 text-[13px] font-medium text-success-foreground">
<span className="relative flex size-2">
<span aria-hidden className="absolute inset-0 animate-ping rounded-full bg-success opacity-60 motion-reduce:animate-none" />
<span className="relative size-2 rounded-full bg-success" />
</span>
{text}
</span>
);
}
/** Five dots, filled up to the level, they fill one after another when in view */
function Level({ level, label, delay = 0 }: { level: number; label: string; delay?: number }) {
const [ref, inView] = useInView<HTMLSpanElement>();
return (
<span ref={ref} role="img" aria-label={label} className="flex shrink-0 gap-1">
{[1, 2, 3, 4, 5].map((i) => (
<span key={i} className={cn('size-2 rounded-full transition-colors duration-500', i <= level && inView ? 'bg-primary' : 'bg-border-strong')} style={{ transitionDelay: inView ? `${delay + i * 70}ms` : '0ms' }} />
))}
</span>
);
}
function ContactList({ person, t }: { person: ResumePerson; t: Texts }) {
const rows: { icon: IconName; text: string; href?: string }[] = [
person.location ? { icon: 'map-pin', text: person.location } : null,
person.email ? { icon: 'mail', text: person.email, href: `mailto:${person.email}` } : null,
person.phone ? { icon: 'phone', text: person.phone, href: `tel:${person.phone.replace(/[^+\d]/g, '')}` } : null,
person.website ? { icon: 'globe', text: person.website.replace(/^https?:\/\//, ''), href: person.website } : null,
].filter(Boolean) as { icon: IconName; text: string; href?: string }[];
return (
<ul aria-label={t.contact} className="grid gap-1">
{rows.map((r) => (
<li key={r.text}>
{r.href ? (
<a href={r.href} className={cn('group flex min-h-11 sm:min-h-10 items-center gap-3 rounded-md text-[14px] text-foreground', focusRing)}>
<Icon name={r.icon} size={16} className="text-muted-foreground transition-colors group-hover:text-primary" />
<span className="min-w-0 break-words underline-offset-4 group-hover:underline">{r.text}</span>
</a>
) : (
<span className="flex min-h-10 items-center gap-3 text-[14px] text-foreground">
<Icon name={r.icon} size={16} className="text-muted-foreground" />
{r.text}
</span>
)}
</li>
))}
</ul>
);
}
function Actions({ download, links = [], t, className }: { download?: ResumeProps['download']; links?: ResumeProps['links']; t: Texts; className?: string }) {
if (!download && !links.length) return null;
return (
<div className={cn('flex flex-wrap gap-3', className)}>
{download && (
<Magnetic className="max-sm:flex max-sm:w-full">
<Button href={download.href} iconLeft="download" download className="max-sm:w-full">
{t.download}
{download.size && <span className="font-normal opacity-75">{download.size}</span>}
</Button>
</Magnetic>
)}
{links.map((l) => (
<Button key={l.label} href={l.href} variant="outline" iconLeft={l.icon} icon={l.icon ? undefined : 'arrow-up-right'} className="max-sm:w-full">
{l.label}
</Button>
))}
</div>
);
}
/* ------------------------------------------------------------------ */
/* classic */
/* ------------------------------------------------------------------ */
function Classic({ person, experience = [], education = [], skills = [], languages = [], download, links, t }: ResumeProps & { t: Texts }) {
const locale = useLocale();
const now = useNow(latest(experience));
const line = useScrollProgress<HTMLOListElement>({ start: 0.1, end: 0.75 });
const groups = [...new Set(skills.map((s) => s.group))];
return (
<div className="grid grid-cols-1 gap-12 lg:grid-cols-[minmax(0,320px)_minmax(0,1fr)] lg:grid-rows-[auto_1fr] lg:gap-x-16 lg:gap-y-10">
<aside className="lg:col-start-1 lg:row-start-1">
<div className="flex items-center gap-5 lg:block">
<div className="relative size-24 shrink-0 overflow-hidden rounded-2xl bg-muted sm:size-28 lg:aspect-square lg:size-auto lg:w-full" data-clip-ok>
<Parallax speed={30} className={cn('absolute -inset-y-4 inset-x-0', fit)}>
{person.photo}
</Parallax>
</div>
<div className="min-w-0 lg:mt-6">
<h3 className="text-[24px] font-semibold leading-tight tracking-[-0.02em] text-foreground sm:text-[28px]">{person.name}</h3>
<p className="mt-1 text-[15px] text-muted-foreground">{person.role}</p>
</div>
</div>
{person.available && (
<div className="mt-5">
<Live text={person.available} />
</div>
)}
<div className="mt-6 border-t border-border pt-5">
<ContactList person={person} t={t} />
</div>
<Actions download={download} t={t} className="mt-6" />
</aside>
<aside className="max-lg:order-last lg:col-start-1 lg:row-start-2">
<div className="grid grid-cols-1 gap-8 border-t border-border pt-8 sm:grid-cols-2 lg:grid-cols-1 lg:border-0 lg:pt-0">
{groups.length > 0 && (
<div>
<h4 className="text-[13px] font-medium text-muted-foreground">{t.skills}</h4>
<ul className="mt-3 grid gap-2.5">
{skills.slice(0, 8).map((s, i) => (
<li key={s.name} className="flex items-center justify-between gap-4 text-[14px] text-foreground">
<span className="min-w-0">{s.name}</span>
<Level level={s.level} label={fill(t.level, { n: s.level })} delay={i * 40} />
</li>
))}
</ul>
</div>
)}
{languages.length > 0 && (
<div>
<h4 className="text-[13px] font-medium text-muted-foreground">{t.languages}</h4>
<ul className="mt-3 grid gap-2.5">
{languages.map((l) => (
<li key={l.name} className="flex items-baseline justify-between gap-4 text-[14px]">
<span className="text-foreground">{l.name}</span>
<span className="text-muted-foreground">{l.level}</span>
</li>
))}
</ul>
</div>
)}
</div>
</aside>
<div className="min-w-0 lg:col-start-2 lg:row-span-2 lg:row-start-1">
{person.summary && <p className="max-w-[42rem] text-pretty text-lead text-foreground">{person.summary}</p>}
<h4 className={cn('text-[13px] font-medium text-muted-foreground', person.summary && 'mt-12')}>{t.experience}</h4>
<ol ref={line} className="relative mt-6">
<span aria-hidden className="absolute bottom-2 left-[5px] top-2 w-px bg-border" />
<span aria-hidden className="kitcv-anim absolute bottom-2 left-[5px] top-2 w-px origin-top bg-primary" style={{ transform: 'scaleY(var(--kit-progress, 1))' }} />
{experience.map((r, i) => (
<Reveal as="li" key={r.id} delay={i * 60} className="relative pb-10 pl-9 last:pb-0">
<span aria-hidden className={cn('absolute left-0 top-[7px] size-[11px] rounded-full border-2 bg-background', r.end ? 'border-border-strong' : 'border-primary')}>
{!r.end && <span className="absolute inset-[2px] rounded-full bg-primary" />}
</span>
<div className="flex flex-wrap items-baseline justify-between gap-x-6 gap-y-1">
<h5 className="text-[17px] font-semibold tracking-[-0.01em] text-foreground">
{r.role} <span className="font-normal text-muted-foreground">· {r.company}</span>
</h5>
<p className="text-[13.5px] tabular-nums text-muted-foreground">{span(r, t, locale)}</p>
</div>
<p className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-[13.5px] text-muted-foreground">
<span className="tabular-nums">{duration(r, now, t)}</span>
{r.place && <span aria-hidden>·</span>}
{r.place && <span>{r.place}</span>}
{r.kind && <span className="rounded-full border border-border px-2 py-0.5 text-[12px]">{r.kind}</span>}
</p>
{r.summary && <p className="mt-3 max-w-[42rem] text-pretty text-[15px] leading-relaxed text-foreground/85">{r.summary}</p>}
{r.points?.length ? (
<ul className="mt-3 grid max-w-[42rem] gap-1.5">
{r.points.map((p) => (
<li key={p} className="flex gap-2.5 text-[14.5px] leading-relaxed text-foreground/85">
<span aria-hidden className="mt-[0.62em] size-1 shrink-0 rounded-full bg-primary" />
{p}
</li>
))}
</ul>
) : null}
</Reveal>
))}
</ol>
{education.length > 0 && (
<>
<h4 className="mt-14 text-[13px] font-medium text-muted-foreground">{t.education}</h4>
<Stagger as="ul" step={70} className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2">
{education.map((e) => (
<li key={e.id} className="kit-card p-5">
<p className="text-[13px] tabular-nums text-muted-foreground">{span(e, t, locale)}</p>
<p className="mt-1.5 text-[15.5px] font-semibold text-foreground">{e.title}</p>
<p className="mt-0.5 text-[14px] text-muted-foreground">{e.school}</p>
{e.note && <p className="mt-2 text-[13.5px] text-foreground/80">{e.note}</p>}
</li>
))}
</Stagger>
</>
)}
{links?.length ? <Actions links={links} t={t} className="mt-10" /> : null}
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* timeline */
/* ------------------------------------------------------------------ */
function Timeline({ person, experience = [], download, t }: ResumeProps & { t: Texts }) {
const locale = useLocale();
const fallback = latest(experience);
const now = useNow(fallback);
const [ref, inView] = useInView<HTMLDivElement>();
const startIdx = Math.min(...experience.map((r) => monthIndex(r.start)));
const first = Math.floor(startIdx / 12);
const lastIdx = Math.max(...experience.map((r) => monthIndex(r.end ?? now)));
const last = Math.floor(lastIdx / 12) + 1;
const years = Array.from({ length: last - first }, (_, i) => first + i);
const total = (last - first) * 12;
const pos = (s: string) => round(((monthIndex(s) - first * 12) / total) * 100, 3);
const current = experience.find((r) => !r.end) ?? experience[experience.length - 1];
const [sel, setSel] = useState(current?.id ?? '');
const chosen = experience.find((r) => r.id === sel) ?? current;
const base = useId();
const companies = [...new Set(experience.map((r) => r.company))];
const key = (e: React.KeyboardEvent) => {
const d = e.key === 'ArrowRight' || e.key === 'ArrowDown' ? 1 : e.key === 'ArrowLeft' || e.key === 'ArrowUp' ? -1 : 0;
if (!d) return;
e.preventDefault();
const order = [...experience].sort((a, b) => monthIndex(a.start) - monthIndex(b.start));
const i = order.findIndex((r) => r.id === sel);
const n = order[(i + d + order.length) % order.length];
setSel(n.id);
(e.currentTarget as HTMLElement).querySelector<HTMLElement>(`[data-id="${n.id}"]`)?.focus();
};
return (
<div>
<div className="flex flex-col gap-6 sm:flex-row sm:items-end sm:justify-between">
<div>
<h3 className="text-h3 font-semibold text-foreground">{person.name}</h3>
<p className="mt-1 text-[15px] text-muted-foreground">{person.role}</p>
</div>
<Actions download={download} t={t} />
</div>
{/* Desktop and tablet: bars over the years */}
<div ref={ref} role="listbox" aria-label={t.experience} aria-orientation="horizontal" onKeyDown={key} className="kit-card mt-10 hidden overflow-hidden p-6 md:block lg:p-8">
<div className="relative grid gap-y-3" style={{ gridTemplateColumns: 'minmax(0,150px) minmax(0,1fr)' }}>
<span />
<div className="relative flex h-6 text-[12px] tabular-nums text-muted-foreground">
{years.map((y, i) => (
<span key={y} className="absolute top-0 -translate-x-1/2" style={{ left: `${round((i / years.length) * 100, 3)}%` }}>
{i === 0 ? <span className="inline-block translate-x-1/2">{y}</span> : y}
</span>
))}
</div>
{companies.map((c, ci) => (
<div key={c} className="contents">
<span className="flex min-h-11 items-center truncate pr-4 text-[14px] font-medium text-foreground">{c}</span>
<div className="relative min-h-11 rounded-md bg-[repeating-linear-gradient(to_right,var(--border)_0,var(--border)_1px,transparent_1px,transparent_calc(100%/var(--n)))]" style={{ '--n': years.length } as React.CSSProperties}>
{experience
.filter((r) => r.company === c)
.map((r) => {
const on = r.id === chosen?.id;
const left = pos(r.start);
const width = Math.max(2.2, round(pos(r.end ?? now) - left + (100 / total), 3));
return (
<button key={r.id} data-id={r.id} type="button" role="option" aria-selected={on} aria-controls={`${base}-detail`} tabIndex={on ? 0 : -1} onClick={() => setSel(r.id)} title={`${r.role}, ${span(r, t, locale)}`} className={cn('absolute inset-y-1 z-[1] origin-left overflow-hidden rounded-md px-2.5 text-left text-[12.5px] font-medium transition-[background-color,color,box-shadow,transform] duration-700 ease-[var(--ease-kit)] motion-reduce:transition-none', focusRing, on ? 'bg-primary text-primary-foreground shadow-[var(--shadow-sm)]' : 'bg-[color-mix(in_oklab,var(--primary)_18%,var(--background))] text-foreground hover:bg-[color-mix(in_oklab,var(--primary)_30%,var(--background))]')} style={{ left: `${left}%`, width: `${Math.min(width, round(100 - left, 3))}%`, transform: `scaleX(${inView ? 1 : 0})`, transitionDelay: inView ? `${ci * 110}ms, ${ci * 110}ms, 0ms, ${ci * 110}ms` : '0ms' }}>
<span className="block truncate leading-[36px]">{r.role}</span>
</button>
);
})}
</div>
</div>
))}
</div>
</div>
{chosen && (
<div id={`${base}-detail`} aria-live="polite" aria-label={t.details} className="mt-6 hidden md:block">
<div key={chosen.id} className="grid grid-cols-[minmax(0,1fr)_minmax(0,1.3fr)] gap-10 border-t border-border pt-6 [animation:kitcv-swap_.5s_var(--ease-kit)]">
<div>
<p className="text-[13px] tabular-nums text-muted-foreground">
{span(chosen, t, locale)} · {duration(chosen, now, t)}
</p>
<h4 className="mt-2 text-[22px] font-semibold tracking-[-0.02em] text-foreground">{chosen.role}</h4>
<p className="mt-1 text-[15px] text-muted-foreground">
{chosen.company}
{chosen.place && ` · ${chosen.place}`}
</p>
{chosen.skills?.length ? (
<ul className="mt-4 flex flex-wrap gap-1.5">
{chosen.skills.map((s) => (
<li key={s} className="rounded-full border border-border px-2.5 py-1 text-[12.5px] text-foreground">
{s}
</li>
))}
</ul>
) : null}
</div>
<div>
{chosen.summary && <p className="text-pretty text-[15.5px] leading-relaxed text-foreground">{chosen.summary}</p>}
{chosen.points?.length ? (
<ul className="mt-3 grid gap-1.5">
{chosen.points.map((p, i) => (
<li key={p} className="flex gap-2.5 text-[14.5px] leading-relaxed text-foreground/85 [animation:kitcv-in_.45s_var(--ease-kit)_both]" style={{ animationDelay: `${120 + i * 60}ms` }}>
<Icon name="check" size={16} className="mt-[3px] text-primary" />
{p}
</li>
))}
</ul>
) : null}
</div>
</div>
</div>
)}
{/* Phones: a list, each role with a small bar of its place in the years */}
<Stagger as="ol" step={70} className="mt-8 grid gap-3 md:hidden">
{[...experience].reverse().map((r) => {
const left = pos(r.start);
const width = Math.max(3, pos(r.end ?? now) - left + 100 / total);
return (
<li key={r.id} className="kit-card p-4">
<div className="relative h-1.5 overflow-hidden rounded-full bg-[color-mix(in_oklab,var(--foreground)_12%,transparent)]" aria-hidden>
<span className="absolute inset-y-0 rounded-full bg-primary" style={{ left: `${left}%`, width: `${round(Math.min(width, 100 - left), 3)}%` }} />
</div>
<div className="mt-1.5 flex justify-between text-[11.5px] tabular-nums text-muted-foreground">
<span>{first}</span>
<span>{last - 1}</span>
</div>
<p className="mt-3 text-[16px] font-semibold text-foreground">{r.role}</p>
<p className="mt-0.5 text-[14px] text-muted-foreground">{r.company}</p>
<p className="mt-1 text-[13px] tabular-nums text-muted-foreground">
{span(r, t, locale)} · {duration(r, now, t)}
</p>
{r.summary && <p className="mt-2.5 text-pretty text-[14px] leading-relaxed text-foreground/85">{r.summary}</p>}
</li>
);
})}
</Stagger>
</div>
);
}
/* ------------------------------------------------------------------ */
/* skills */
/* ------------------------------------------------------------------ */
function Skills({ person, experience = [], skills = [], t }: ResumeProps & { t: Texts }) {
const locale = useLocale();
const groups = [...new Set(skills.map((s) => s.group))];
const [group, setGroup] = useState<string>('');
const [pick, setPick] = useState<string>(skills.find((s) => experience.some((r) => r.skills?.includes(s.name)))?.name ?? '');
const shown = group ? skills.filter((s) => s.group === group) : skills;
const used = experience.filter((r) => r.skills?.includes(pick));
const count = (name: string) => experience.filter((r) => r.skills?.includes(name)).length;
const [row, fade] = useScrollFade<HTMLDivElement>();
const usedList = (compact: boolean) =>
used.length ? (
<ol key={pick} className={cn('divide-y divide-border', compact && 'border-t border-border')}>
{used.map((r, i) => (
<li key={r.id} className={cn('[animation:kitcv-in_.45s_var(--ease-kit)_both]', compact ? 'px-4 py-3' : 'px-6 py-4')} style={{ animationDelay: `${80 + i * 70}ms` }}>
<div className="flex items-baseline justify-between gap-4">
<p className={cn('font-semibold text-foreground', compact ? 'text-[14px]' : 'text-[15px]')}>{r.role}</p>
{!r.end && <span className="shrink-0 rounded-full bg-[color-mix(in_oklab,var(--primary)_14%,transparent)] px-2 py-0.5 text-[11.5px] font-semibold text-foreground">{t.current}</span>}
</div>
<p className="mt-0.5 text-[13px] text-muted-foreground">
{r.company} · <span className="tabular-nums">{span(r, t, locale)}</span>
</p>
{!compact && r.points?.[0] && <p className="mt-2 text-pretty text-[14px] leading-relaxed text-foreground/85">{r.points[0]}</p>}
</li>
))}
</ol>
) : (
<p className={cn('text-[14px] text-muted-foreground', compact ? 'border-t border-border px-4 py-3' : 'px-6 py-8')}>{t.pick}</p>
);
return (
<div>
<div ref={row} role="group" aria-label={t.groups} className={cn('kit-no-scrollbar -mx-6 flex gap-2 overflow-x-auto px-6 sm:mx-0 sm:flex-wrap sm:px-0', fade)}>
{['', ...groups].map((g) => {
const on = g === group;
return (
<button key={g || 'all'} type="button" aria-pressed={on} onClick={() => setGroup(g)} className={cn('inline-flex min-h-11 shrink-0 items-center rounded-full border px-4 text-[14px] font-medium transition-[background-color,border-color,color] duration-300', focusRing, on ? 'border-transparent bg-foreground text-background' : 'border-border text-muted-foreground hover:border-border-strong hover:text-foreground')}>
{g || t.all}
</button>
);
})}
</div>
<div className="mt-6 grid grid-cols-1 gap-10 lg:grid-cols-[minmax(0,1.25fr)_minmax(0,1fr)] lg:gap-14">
<ul key={group} className="grid grid-cols-1 gap-px self-start overflow-hidden rounded-xl border border-border bg-border sm:grid-cols-2">
{shown.map((s, i) => {
const on = s.name === pick;
const n = count(s.name);
return (
<li key={s.name} className={cn('bg-card', on && 'max-lg:sm:col-span-2', shown.length % 2 === 1 && i === shown.length - 1 && 'lg:col-span-2')}>
<button type="button" aria-pressed={on} onClick={() => setPick(s.name)} className={cn('group relative flex min-h-[68px] w-full items-center justify-between gap-4 px-4 py-3 text-left transition-colors duration-300', focusRing, 'focus-visible:ring-inset focus-visible:ring-offset-0', on ? 'bg-[color-mix(in_oklab,var(--primary)_10%,var(--card))]' : 'hover:bg-accent')}>
<span aria-hidden className={cn('absolute inset-y-0 left-0 w-[3px] origin-top bg-primary transition-transform duration-500 ease-[var(--ease-kit)]', on ? 'scale-y-100' : 'scale-y-0')} />
<span className="min-w-0 [animation:kitcv-in_.4s_var(--ease-kit)_both]" style={{ animationDelay: `${i * 30}ms` }}>
<span className="block text-[15px] font-medium text-foreground">{s.name}</span>
<span className="mt-0.5 block text-[12.5px] text-muted-foreground">{n === 1 ? t.role1 : fill(t.roles, { n })}</span>
</span>
<Level level={s.level} label={fill(t.level, { n: s.level })} delay={i * 30} />
</button>
{/* Phones and tablets: where it was used opens right under the skill */}
{on && (
<div className="bg-[color-mix(in_oklab,var(--primary)_5%,var(--card))] lg:hidden" aria-live="polite">
<p className="sr-only">{t.usedIn}</p>
{usedList(true)}
</div>
)}
</li>
);
})}
{(shown.length + (shown.some((x) => x.name === pick) ? 1 : 0)) % 2 === 1 && <li aria-hidden className="hidden bg-card sm:block lg:hidden" />}
</ul>
<div className="hidden lg:sticky lg:top-8 lg:block lg:self-start">
<div className="kit-card overflow-hidden">
<div className="border-b border-border px-6 py-5">
<p className="text-[13px] font-medium text-muted-foreground">{t.usedIn}</p>
<p key={pick} className="mt-1 text-[22px] font-semibold tracking-[-0.02em] text-foreground [animation:kitcv-swap_.45s_var(--ease-kit)]">
{pick || person.name}
</p>
</div>
<div aria-live="polite">{usedList(false)}</div>
</div>
</div>
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* compact */
/* ------------------------------------------------------------------ */
function Compact({ person, experience = [], education = [], skills = [], languages = [], download, t }: ResumeProps & { t: Texts }) {
const locale = useLocale();
const sheet = useScrollProgress<HTMLDivElement>({ start: 0, end: 0.35 });
return (
<div>
<div className="mb-8 flex flex-wrap items-center justify-center gap-3 max-sm:flex-col max-sm:items-stretch">
<Actions download={download} t={t} />
<Button variant="outline" iconLeft="printer" className="max-sm:w-full" onClick={() => window.print()}>
{t.print}
</Button>
</div>
<div ref={sheet} className="mx-auto max-w-[860px]" style={{ perspective: '1600px' }}>
<article className="kitcv-anim origin-top overflow-hidden rounded-md bg-card shadow-[var(--highlight),var(--shadow-lg)] ring-1 ring-border" style={{ transform: 'scale(calc(0.94 + var(--kit-progress, 1) * 0.06)) translateY(calc((1 - var(--kit-progress, 1)) * 24px))' }}>
<div className="grid grid-cols-1 gap-6 border-b border-border p-6 sm:grid-cols-[auto_minmax(0,1fr)] sm:items-center sm:p-10">
<div className="size-20 overflow-hidden rounded-full bg-muted sm:size-24" data-clip-ok>
<div className={cn('size-full', fit)}>{person.photo}</div>
</div>
<div>
<h3 className="text-[26px] font-semibold leading-tight tracking-[-0.02em] text-foreground sm:text-[30px]">{person.name}</h3>
<p className="mt-1 text-[15px] text-primary">{person.role}</p>
<p className="mt-3 flex flex-wrap gap-x-3 gap-y-1 text-[13.5px] text-muted-foreground max-sm:flex-col">
{[person.location, person.email, person.phone, person.website?.replace(/^https?:\/\//, '')].filter(Boolean).map((x, i) => (
<span key={x} className="inline-flex items-center gap-3">
{i > 0 && <span aria-hidden className="size-1 rounded-full bg-border-strong max-sm:hidden" />}
{x}
</span>
))}
</p>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-[minmax(0,1fr)_minmax(0,220px)]">
<div className="p-6 sm:p-10">
{person.summary && <p className="text-pretty text-[14.5px] leading-relaxed text-foreground/90">{person.summary}</p>}
<h4 className="mt-8 border-b border-border pb-2 text-[12px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t.experience}</h4>
<ol className="mt-4 grid gap-5">
{experience.map((r) => (
<li key={r.id} className="grid grid-cols-1 gap-x-6 sm:grid-cols-[110px_minmax(0,1fr)]">
<p className="text-[12.5px] tabular-nums text-muted-foreground">
{monthName(r.start, locale, 'numeric')}
<br className="max-sm:hidden" />
<span className="sm:hidden"> </span>
{t.to} {r.end ? monthName(r.end, locale, 'numeric') : t.present}
</p>
<div>
<p className="text-[14.5px] font-semibold text-foreground">
{r.role}, <span className="font-normal">{r.company}</span>
</p>
{r.points?.length ? (
<ul className="mt-1.5 grid gap-1">
{r.points.slice(0, 2).map((p) => (
<li key={p} className="flex gap-2 text-[13.5px] leading-relaxed text-foreground/80">
<span aria-hidden className="mt-[0.6em] size-1 shrink-0 rounded-full bg-muted-foreground" />
{p}
</li>
))}
</ul>
) : null}
</div>
</li>
))}
</ol>
{education.length > 0 && (
<>
<h4 className="mt-8 border-b border-border pb-2 text-[12px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t.education}</h4>
<ul className="mt-4 grid gap-3">
{education.map((e) => (
<li key={e.id} className="grid grid-cols-1 gap-x-6 sm:grid-cols-[110px_minmax(0,1fr)]">
<p className="text-[12.5px] tabular-nums text-muted-foreground">
{ym(e.start).y} {t.to} {e.end ? ym(e.end).y : t.present}
</p>
<p className="text-[14px] text-foreground">
<span className="font-semibold">{e.title}</span>, {e.school}
</p>
</li>
))}
</ul>
</>
)}
</div>
<div className="border-t border-border bg-surface p-6 sm:border-l sm:border-t-0 sm:p-8">
<h4 className="text-[12px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t.skills}</h4>
<ul className="mt-3 flex flex-wrap gap-1.5 sm:block sm:space-y-2">
{skills.map((s) => (
<li key={s.name} className="rounded-full border border-border px-2.5 py-1 text-[13px] text-foreground sm:rounded-none sm:border-0 sm:p-0">
{s.name}
</li>
))}
</ul>
{languages.length > 0 && (
<>
<h4 className="mt-8 text-[12px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t.languages}</h4>
<ul className="mt-3 grid gap-2">
{languages.map((l) => (
<li key={l.name} className="text-[13px]">
<span className="block text-foreground">{l.name}</span>
<span className="block text-muted-foreground">{l.level}</span>
</li>
))}
</ul>
</>
)}
</div>
</div>
</article>
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* summary */
/* ------------------------------------------------------------------ */
function Summary({ person, experience = [], stats = [], download, links, t }: ResumeProps & { t: Texts }) {
const locale = useLocale();
const now = useNow(latest(experience));
const current = experience.find((r) => !r.end);
const past = experience.filter((r) => r.end).slice(0, 4);
const lineRef = useScrollProgress<HTMLDivElement>({ start: 0.15, end: 0.6 });
return (
<div className="grid grid-cols-1 gap-12 lg:grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)] lg:items-center lg:gap-16">
<div>
<div className="kit-rise flex items-center gap-4">
<div className="size-16 overflow-hidden rounded-full bg-muted ring-2 ring-background" data-clip-ok>
<div className={cn('size-full', fit)}>{person.photo}</div>
</div>
<div>
<p className="text-[16px] font-semibold text-foreground">{person.name}</p>
{person.location && <p className="text-[14px] text-muted-foreground">{person.location}</p>}
</div>
</div>
<h3 className="kit-rise mt-8 text-balance text-h1 font-semibold text-foreground [--kit-delay:80ms]">{person.role}</h3>
{person.summary && <p className="kit-rise mt-5 max-w-[36rem] text-pretty text-lead text-muted-foreground [--kit-delay:160ms]">{person.summary}</p>}
{person.available && (
<div className="kit-rise mt-6 [--kit-delay:220ms]">
<Live text={person.available} />
</div>
)}
<Actions download={download} links={links} t={t} className="kit-rise mt-8 [--kit-delay:280ms]" />
{stats.length > 0 && (
<dl className="mt-12 grid grid-cols-2 gap-px overflow-hidden rounded-xl border border-border bg-border sm:grid-cols-3">
{stats.slice(0, 3).map((s, i) => (
<div key={s.label} className={cn('bg-card p-5', i === 2 && 'max-sm:col-span-2')}>
<dt className="sr-only">{s.label}</dt>
<dd>
<span className="text-[32px] font-semibold leading-none tracking-[-0.03em] text-foreground">
<NumberTicker value={s.value} delay={i * 120} />
{s.suffix}
</span>
<span aria-hidden className="mt-2 block text-[13.5px] leading-snug text-muted-foreground">
{s.label}
</span>
</dd>
</div>
))}
</dl>
)}
</div>
<div ref={lineRef} className="kit-card relative overflow-hidden p-6 sm:p-8">
{current && (
<div className="relative">
<p className="inline-flex items-center gap-2 text-[13px] font-medium text-muted-foreground">
<span className="relative flex size-2">
<span aria-hidden className="absolute inset-0 animate-ping rounded-full bg-primary opacity-60 motion-reduce:animate-none" />
<span className="relative size-2 rounded-full bg-primary" />
</span>
{t.nowAt}
</p>
<p className="mt-2 text-[20px] font-semibold tracking-[-0.01em] text-foreground">{current.role}</p>
<p className="mt-0.5 text-[15px] text-muted-foreground">
{current.company} · <span className="tabular-nums">{fill(t.since, { d: monthName(current.start, locale) })}</span>
</p>
{current.summary && <p className="mt-3 text-pretty text-[14.5px] leading-relaxed text-foreground/85">{current.summary}</p>}
</div>
)}
{past.length > 0 && (
<ol className="relative mt-7 border-t border-border pt-6">
<span aria-hidden className="absolute bottom-3 left-[3px] top-9 w-px bg-border" />
<span aria-hidden className="kitcv-anim absolute bottom-3 left-[3px] top-9 w-px origin-top bg-primary" style={{ transform: 'scaleY(var(--kit-progress, 1))' }} />
{past.map((r, i) => (
<Reveal as="li" key={r.id} delay={i * 80} className="relative grid grid-cols-[minmax(0,1fr)_auto] gap-x-4 pb-5 pl-6 last:pb-0">
<span aria-hidden className="absolute left-0 top-[7px] size-[7px] rounded-full bg-border-strong ring-4 ring-card" />
<span className="min-w-0">
<span className="block text-[14.5px] font-medium text-foreground">{r.role}</span>
<span className="block text-[13px] text-muted-foreground">{r.company}</span>
</span>
<span className="text-right text-[13px] tabular-nums text-muted-foreground">{duration(r, now, t)}</span>
</Reveal>
))}
</ol>
)}
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Resume */
/* ------------------------------------------------------------------ */
/**
* A résumé as a web page.
* classic side column with contact, skills and languages, experience on a filling line
* timeline roles as bars over the years, click one to read it
* skills skills by group with levels, pick one to see where it was used
* compact the one-page paper version with download and print
* summary the short version for the top of a page, with counting numbers
*/
export function Resume(props: ResumeProps) {
const { variant = 'classic', labels, className, eyebrow, title, description } = props;
const t = useTexts(TEXTS, labels);
const body = (() => {
switch (variant) {
case 'timeline':
return <Timeline {...props} t={t} />;
case 'skills':
return <Skills {...props} t={t} />;
case 'compact':
return <Compact {...props} t={t} />;
case 'summary':
return <Summary {...props} t={t} />;
default:
return <Classic {...props} t={t} />;
}
})();
return (
<Section className={className} background={variant === 'compact' ? 'surface' : variant === 'summary' ? 'glow' : 'default'}>
<CvMotion />
{title ? <SectionHeader eyebrow={eyebrow} title={title} description={description} /> : null}
{body}
</Section>
);
}