007

Resume Template

Free

A personal CV site that morphs between two layouts: a signature Flip-driven toggle reflows the whole page from a spacious web resume into an A4 document, backed by a real print stylesheet so the site is also the PDF.

ScrollTriggerFlip Lenis intermediate
5 more details
flip-layouttext-animationclick-toggleclipboard 5 colour palettes
Colour

Updated

About this template

A production-ready single-page CV site for engineers, designers, and anyone who needs a personal resume that does not look like a template. The art direction is a document lying on a desk: a deep indigo ground, a warm cream sheet, graphite type, and one highlighter yellow used sparingly to mark the lines that matter.

Read the full template overview

The signature moment is the Web / Resume toggle in the utility bar. Flip records the position of every structural block, one class switch reflows the page from a spacious web layout into an exact A4 page with every role expanded and the highlighter turned off, and Flip animates the difference while the type scales down with it.

Print uses the same document layout, so the page prints as a clean one-page CV from either mode, even without JavaScript, so a buyer's site and their PDF never drift apart. Swap the placeholder name, roles, and dates for your own and deploy anywhere as static files.

Resume Template - website template preview

What's included

11 items
  • Complete deployable single-page CV: utility bar, header, contact and skills column, experience, selected work, education, speaking, footer
  • Signature Web / Resume layout morph powered by GSAP Flip, animating the reflow between a web page and an A4 document while the type scales with it and the reader keeps their place
  • Resume mode is an exact A4 page and print uses the same layout tokens, so the site is a one-page PDF from either mode
  • Highlighter marks swept across key lines the first time they scroll into view, and dropped automatically in Resume mode and in print
  • Expandable roles with animated heights, forced open in Resume mode and in print so nothing is hidden from a reader
  • Chronology spine: a connecting rule and mono date column that encode the run of roles rather than decorate it
  • Copy-email control that confirms in place and falls back to showing the address when the clipboard is unavailable
  • Masked name reveal on load; the rest of the page is simply there, no scroll fades
  • Lenis smooth scrolling wired in and fully optional
  • Accessible: the toggle is a real pressed-state control with a live region, keyboard reaches every interaction, reduced motion gets a complete static page, and the page reads fully without JavaScript
  • Five measured palettes, each with its own display face (grotesk, warm serif, editorial serif, mono, slab), swapped from one data-palette attribute

Perfect for

5 use cases
  • Personal CV and resume sites for engineers, designers, and product people
  • Freelancers who need one link that also prints as a PDF
  • Job seekers who want a resume that stands apart from a document export
  • Academic and consulting profiles with long dated histories
  • Learning how GSAP Flip animates a genuine layout change rather than a transform

How it works

3 sections

The Flip morph

One script initialises each section with a single setup and teardown path and one motion branch, skipping any section it cannot find. The layout morph is the core: Flip.getState records every [data-flip-id] block, a single class on the body swaps a set of layout custom properties from the web values to an A4 page, and Flip.from animates the difference with absolute and nested positioning while a --scale tween shrinks the type with the page.

Roles and highlighters

Expandable roles animate their own heights, then hand them back to CSS so Resume mode and print can force them open. Highlighter marks are background gradients swept from zero width by ScrollTrigger once each, and are removed entirely in Resume mode and print because a marked-up page is a screen affordance, not a document one.

Reduced motion and print

A prefers-reduced-motion branch applies the same layout switch instantly with the highlighters pre-drawn, and without JavaScript the toggle stays hidden while the page reads as a complete document that still prints correctly.

Plugins ScrollTrigger, Flip
Difficulty Intermediate
Smooth scroll Lenis integration
Colour palettes 5 ready-made palettes, each measured for AA contrast. Switch with one attribute, or copy the values you want into :root. Try them in the preview above.
Includes HTML + JS + CSS source, documentation, AI setup prompt, lifetime updates

Lighthouse, as measured

Google Lighthouse on this template's demo, from the latest scan. A measurement of the demo as shipped, not a promise for your page.

Accessibility
100
Best practices
100

No performance score, on purpose. That figure depends on how you deploy: your server's compression and caching, your CDN, the connection and the device doing the test, none of which the code controls. The same page can score very differently on two consecutive runs, so measure it where it will live.

Questions about this template

Can I actually print this as a PDF employers will accept?

Yes, that is the point of it. Print uses the same A4 layout as Resume mode, so Print to PDF from either layout mode emits a clean one-page CV rather than a screenshot of a website. Expandable roles are forced open in print, so nothing a reader needs is hidden in the output.

What is the Web / Resume toggle for?

It reflows the entire page between a spacious two-column web CV and an A4 document, animating the actual layout change with GSAP Flip rather than fading between two copies. It lets one URL serve both a recruiter skimming on a phone and someone who wants the formal document.

My career history is long. Will it cope?

Yes. Roles sit on a chronology spine with a mono date column, and each one expands and collapses with an animated height, so fifteen years does not become fifteen screens. Academic and consulting histories with long dated runs are an explicit use case.

Do I need to know GSAP to change my job titles?

No. All the content is plain HTML in index.html: your name, roles, dates and bullet points are text you edit directly. You would only touch the JavaScript if you wanted to change how the layout morph behaves, which most people never do.

Does the copy-email button work everywhere?

It copies to the clipboard and confirms in place, and where the clipboard API is unavailable or blocked it falls back to simply displaying the address so it can be read or selected. It never silently fails, which is the failure mode that matters on a CV.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta name="description" content="Nadia Osei, product engineer in Lisbon. Ten years building interfaces that stay fast under real data. Read the site, or switch it to a printable one-page CV.">
    <title>Nadia Osei - Resume Template | GSAP Vault</title>
    <!-- Reveal gate: set before first paint so nothing the script animates
         in is ever painted in its finished state first. If script.js never
         confirms (it adds .js-ready), the gate comes off after 4s and the
         page reads as a plain document. data-cfasync stops Cloudflare
         Rocket Loader from deferring it. -->
    <script data-cfasync="false">(function(d){d.classList.add('has-js');setTimeout(function(){if(!d.classList.contains('js-ready'))d.classList.remove('has-js')},4000)})(document.documentElement)</script>
    <link rel="stylesheet" href="assets/style.css">
    <link rel="preconnect" href="https://fonts.googleapis.com">
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
    <link href="https://fonts.googleapis.com/css2?family=Azeret+Mono:wght@400;500;600&family=Bricolage+Grotesque:opsz,wght@12..96,400;12..96,600;12..96,800&family=Fraunces:opsz,wght@9..144,700&family=IBM+Plex+Mono:wght@700&family=Literata:opsz,wght@7..72,400;7..72,600&family=Playfair+Display:wght@700&family=Zilla+Slab:wght@700&display=swap" rel="stylesheet">
</head>
<body>

    <!-- ============ STICKY UTILITY BAR ============
         The mode toggle is the signature control. Without
         JavaScript it stays hidden and the page reads as the
         web layout; printing still produces the A4 CV either way. -->
    <header class="bar">
        <a class="bar-brand" href="#top">
            <span class="bar-monogram" aria-hidden="true">N.O.</span>
            <span class="bar-name">Nadia Osei<span class="bar-role">Product engineer</span></span>
        </a>

        <div class="modes" role="group" aria-label="Layout" data-modes>
            <button type="button" class="mode-btn is-on" data-mode="web" aria-pressed="true">Web</button>
            <button type="button" class="mode-btn" data-mode="paper" aria-pressed="false">R&eacute;sum&eacute;</button>
        </div>

        <div class="bar-actions">
            <button type="button" class="btn btn--ghost" data-copy="nadia@osei.dev">
                <span data-copy-label>Copy email</span>
            </button>
            <button type="button" class="btn btn--solid" data-print><span class="btn-long">Download </span>PDF</button>
        </div>
    </header>

    <p class="sr-only" role="status" aria-live="polite" data-mode-status></p>

    <main class="desk" id="top">
        <article class="sheet" data-flip-id="sheet">

            <!-- ============ HEAD ============ -->
            <div class="sheet-head">
                <div class="head-main" data-flip-id="head-main">
                    <p class="eyebrow">Curriculum vitae</p>

                    <h1 class="name">
                        <span class="mask"><span class="word" data-word>Nadia</span></span>
                        <span class="mask"><span class="word" data-word>Osei</span></span>
                    </h1>

                    <p class="role-line">
                        <span class="role-part">Product engineer</span>
                        <span class="dot" aria-hidden="true">/</span>
                        <span class="role-part">Lisbon, remote across CET</span>
                    </p>

                    <p class="available" data-mode-fade>
                        <span class="available-dot" aria-hidden="true"></span>
                        Open to staff roles from September
                    </p>

                    <p class="summary">
                        I build the parts of a product people actually touch: <mark class="hl">interfaces that stay fast under real data</mark>, design systems teams keep using, and the unglamorous plumbing that stops both from rotting. Ten years across health, fintech, and developer tools, most of it <mark class="hl">leading the frontend of one team at a time</mark>.
                    </p>
                </div>

                <!-- At a glance. A screen affordance: Resume mode and
                     print drop it, because the history below says it. -->
                <dl class="head-meta" data-flip-id="head-meta">
                    <div>
                        <dt>Right now</dt>
                        <dd>Staff product engineer at Kestrel Health</dd>
                    </div>
                    <div>
                        <dt>Focus</dt>
                        <dd>Frontend platform, design systems, accessibility</dd>
                    </div>
                    <div>
                        <dt>Previously</dt>
                        <dd>Orbital Bank, Hexlet Tools</dd>
                    </div>
                    <div>
                        <dt>Writing</dt>
                        <dd>osei.dev/notes</dd>
                    </div>
                </dl>
            </div>

            <div class="sheet-body" data-flip-id="body">

                <!-- ============ SIDE COLUMN ============
                     Contact, skills, languages and education. In
                     Resume mode the chips become comma lists, the
                     way a printed CV sets them. -->
                <aside class="side">

                    <section class="side-block side-block--contact" data-flip-id="side-contact">
                        <h2 class="side-title">Contact</h2>
                        <ul class="contact">
                            <li><a href="mailto:nadia@osei.dev">nadia@osei.dev</a></li>
                            <li><a href="tel:+351910000000">+351 91 000 0000</a></li>
                            <li><a href="#top">osei.dev</a></li>
                            <li><a href="#top">github.com/nadiaosei</a></li>
                            <li><a href="#top">linkedin.com/in/nadiaosei</a></li>
                        </ul>
                    </section>

                    <section class="side-block" data-flip-id="side-skills">
                        <h2 class="side-title">Skills</h2>

                        <p class="skill-label">Programming</p>
                        <ul class="chips">
                            <li>TypeScript</li><li>JavaScript</li><li>Ruby</li><li>SQL</li><li>Go</li>
                        </ul>

                        <p class="skill-label">Frontend</p>
                        <ul class="chips">
                            <li>React</li><li>Svelte</li><li>GSAP</li><li>CSS architecture</li><li>WCAG 2.2 AA</li>
                        </ul>

                        <p class="skill-label">Platform</p>
                        <ul class="chips">
                            <li>Node</li><li>Postgres</li><li>Docker</li><li>GitHub Actions</li><li>Playwright</li>
                        </ul>

                        <p class="skill-label">Practice</p>
                        <ul class="chips">
                            <li>Design systems</li><li>Technical writing</li><li>Mentoring</li><li>Hiring</li>
                        </ul>
                    </section>

                    <section class="side-block" data-flip-id="side-langs">
                        <h2 class="side-title">Spoken</h2>
                        <ul class="chips">
                            <li>English (native)</li><li>Portuguese (B2)</li><li>German (B1)</li>
                        </ul>
                    </section>

                    <section class="side-block" data-flip-id="side-edu">
                        <h2 class="side-title">Education</h2>
                        <p class="edu-name">BSc Computer Science</p>
                        <p class="edu-meta">University of Manchester, first class, 2012&ndash;2015</p>
                    </section>

                    <section class="side-block" data-flip-id="side-talks">
                        <h2 class="side-title">Speaking &amp; writing</h2>
                        <ul class="talks">
                            <li>
                                <p class="talk-title">Performance budgets that survive a roadmap</p>
                                <p class="talk-meta">Front Conference, Zurich, 2025</p>
                            </li>
                            <li>
                                <p class="talk-title">Migrating a legacy frontend without a freeze</p>
                                <p class="talk-meta">Smashing Magazine essay, 2024</p>
                            </li>
                            <li>
                                <p class="talk-title">Accessible date pickers, the whole problem</p>
                                <p class="talk-meta">osei.dev essay, 2023</p>
                            </li>
                        </ul>
                    </section>

                </aside>

                <!-- ============ MAIN COLUMN ============ -->
                <div class="col">

                    <!-- Experience: the spine rule and the mono date
                         column carry the chronology. Each role expands
                         for detail (set aria-expanded="true" to start one
                         open, as the current role does), and Resume mode
                         opens all of them. -->
                    <section class="block" data-flip-id="block-exp">
                        <h2 class="block-title">Experience</h2>

                        <ol class="spine">
                            <li class="entry" data-flip-id="exp-1">
                                <p class="entry-date">2023 &ndash; now</p>
                                <div class="entry-body">
                                    <h3 class="entry-role">Staff product engineer</h3>
                                    <p class="entry-org">Kestrel Health <span class="entry-place">Lisbon, remote</span></p>
                                    <p class="entry-lede">Rebuilt the scheduling flow <mark class="hl">340 clinics book through every day</mark>.</p>
                                    <button type="button" class="entry-more" data-expand aria-expanded="true" aria-controls="exp-1">
                                        <span class="entry-more-label">Detail</span>
                                        <span class="entry-more-icon" aria-hidden="true"></span>
                                    </button>
                                    <div class="entry-detail" id="exp-1" data-detail>
                                        <ul class="bullets">
                                            <li>Took booking abandonment from 34% to 12% by rewriting the availability picker around real clinic data rather than an idealised calendar.</li>
                                            <li>Led four engineers through a nine-month Ember to React migration of 180,000 lines with no feature freeze and no rollback.</li>
                                            <li>Put performance budgets in CI, so regressions fail the build instead of arriving in a quarterly report.</li>
                                            <li>Wrote the accessibility standard the product org now audits against.</li>
                                        </ul>
                                    </div>
                                </div>
                            </li>

                            <li class="entry" data-flip-id="exp-2">
                                <p class="entry-date">2020 &ndash; 2023</p>
                                <div class="entry-body">
                                    <h3 class="entry-role">Senior frontend engineer</h3>
                                    <p class="entry-org">Orbital Bank <span class="entry-place">Berlin</span></p>
                                    <p class="entry-lede">Built the design system <mark class="hl">eleven product teams still ship on</mark>.</p>
                                    <button type="button" class="entry-more" data-expand aria-expanded="false" aria-controls="exp-2">
                                        <span class="entry-more-label">Detail</span>
                                        <span class="entry-more-icon" aria-hidden="true"></span>
                                    </button>
                                    <div class="entry-detail" id="exp-2" data-detail>
                                        <ul class="bullets">
                                            <li>Shipped 64 components with visual regression tests, adopted by 11 teams inside a year.</li>
                                            <li>Owned the card-issuing dashboard end to end, from the Figma review to the on-call rota.</li>
                                            <li>Cut first contentful paint from 4.1s to 1.2s on a throttled 3G connection.</li>
                                        </ul>
                                    </div>
                                </div>
                            </li>

                            <li class="entry" data-flip-id="exp-3">
                                <p class="entry-date">2018 &ndash; 2020</p>
                                <div class="entry-body">
                                    <h3 class="entry-role">Frontend engineer</h3>
                                    <p class="entry-org">Hexlet Tools <span class="entry-place">Berlin</span></p>
                                    <p class="entry-lede">Documentation and plugin marketplace for a developer CLI.</p>
                                    <button type="button" class="entry-more" data-expand aria-expanded="false" aria-controls="exp-3">
                                        <span class="entry-more-label">Detail</span>
                                        <span class="entry-more-icon" aria-hidden="true"></span>
                                    </button>
                                    <div class="entry-detail" id="exp-3" data-detail>
                                        <ul class="bullets">
                                            <li>Rebuilt the docs site around search-first navigation; monthly reads grew to 1.9 million.</li>
                                            <li>Shipped the editor plugin marketplace, including the review queue the team still uses.</li>
                                        </ul>
                                    </div>
                                </div>
                            </li>

                            <li class="entry" data-flip-id="exp-4">
                                <p class="entry-date">2016 &ndash; 2018</p>
                                <div class="entry-body">
                                    <h3 class="entry-role">Developer</h3>
                                    <p class="entry-org">Studio Fennec <span class="entry-place">London</span></p>
                                    <p class="entry-lede">Agency work: twenty-odd client sites, one of everything.</p>
                                </div>
                            </li>
                        </ol>
                    </section>

                    <!-- Selected work: the project's kind sits where a date
                         would, so it reads in the same grammar as the roles. -->
                    <section class="block" data-flip-id="block-work">
                        <h2 class="block-title">Selected work</h2>
                        <ul class="works">
                            <li class="work" data-flip-id="work-1">
                                <p class="work-kind">Open source</p>
                                <div class="work-body">
                                    <h3 class="work-name">Ledger</h3>
                                    <p class="work-desc">A spreadsheet that reads its formulas as code. <span class="work-stat">8,200 stars, 61 contributors, maintained since 2021.</span></p>
                                </div>
                            </li>
                            <li class="work" data-flip-id="work-2">
                                <p class="work-kind">Personal</p>
                                <div class="work-body">
                                    <h3 class="work-name">Quiet Hours</h3>
                                    <p class="work-desc">A focus timer for macOS and the web with no streaks, badges or notifications. <span class="work-stat">40,000 people use it.</span></p>
                                </div>
                            </li>
                            <li class="work" data-flip-id="work-3">
                                <p class="work-kind">Contract</p>
                                <div class="work-body">
                                    <h3 class="work-name">Specimen</h3>
                                    <p class="work-desc">A specimen builder for variable fonts. <span class="work-stat">Now the type foundry&rsquo;s whole storefront.</span></p>
                                </div>
                            </li>
                        </ul>
                    </section>

                </div>
            </div>

            <div class="sheet-foot" data-flip-id="foot">
                <p>
                    References on request
                    <span class="dot" aria-hidden="true">/</span>
                    Last updated July 2026
                </p>
            </div>

        </article>

        <footer class="page-foot">
            <p>&copy; 2026 Nadia Osei.</p>
        </footer>
    </main>

    <!-- GSAP Core + plugins -->
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/ScrollTrigger.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/Flip.min.js"></script>

    <!-- Lenis smooth scroll -->
    <script src="https://unpkg.com/lenis@1.3.17/dist/lenis.min.js"></script>

    <!-- Template code -->
    <script src="assets/script.js"></script>
</body>
</html>
/**
 * Nadia Osei, Resume Template
 *
 * A single-page CV that morphs between two layouts. The signature
 * moment is the Web / Resume toggle: Flip records the position of
 * every structural block, one class switch reflows the page into an
 * A4 document, and Flip animates the difference while the type
 * scales down with it. Around it sit a masked name reveal,
 * highlighter sweeps drawn on scroll, expandable roles, and a
 * copy-email control. Sections are independent: the script skips
 * anything it cannot find, so you can delete sections freely.
 *
 * @plugins ScrollTrigger, Flip
 * @techniques flip, layout-morph, text-animation, click-toggle
 */

/* Runs the init straight away if the DOM is already parsed (a script
   executed late or deferred, e.g. by Cloudflare Rocket Loader), and
   waits for DOMContentLoaded otherwise. */
(function onReady(init) {
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }
})(function initResume() {

    const root = document.documentElement;

    /* The inline script in <head> added .has-js before first paint, which
       pre-hides everything this file animates in. If a CDN script failed
       to load, take the class back off so the page reads as a complete,
       static document instead of staying hidden. */
    if (typeof gsap === 'undefined' || typeof ScrollTrigger === 'undefined' || typeof Flip === 'undefined') {
        root.classList.remove('has-js');
        return;
    }

    /* Tells the head script's 4s failsafe that the gate is in good hands */
    root.classList.add('js-ready');

    gsap.registerPlugin(ScrollTrigger, Flip);

    /* OPTIONAL: Lenis smooth scroll. Delete this helper, its call below and
       the Lenis <script> tag to drop it. Smooth scroll is opt-out with
       data-smooth="off" on <html> or ?smooth=off in the URL, and it only
       runs in the motion branch, so reduced motion never gets it. */
    function initLenis() {
        const wantsSmooth = (new URLSearchParams(location.search).get('smooth')
            || root.dataset.smooth) !== 'off';
        /* An existing window.lenis belongs to a host page: leave it alone */
        if (!wantsSmooth || typeof Lenis === 'undefined' || window.lenis) return null;

        const lenis = new Lenis({ allowNestedScroll: true, anchors: true, autoRaf: false });

        /* Lenis and ScrollTrigger share ONE clock. On its own rAF, Lenis
           would move the page while ScrollTrigger reads the previous frame. */
        const tick = function (time) { lenis.raf(time * 1000); };
        /* A refresh restores the native scroll position while Lenis may
           still be easing toward an older target: resync it. */
        const resync = function () {
            lenis.scrollTo(window.scrollY, { immediate: true, force: true });
        };

        lenis.on('scroll', ScrollTrigger.update);
        gsap.ticker.add(tick);
        gsap.ticker.lagSmoothing(0);
        ScrollTrigger.addEventListener('refresh', resync);
        window.lenis = lenis;

        return function destroyLenis() {
            gsap.ticker.remove(tick);
            gsap.ticker.lagSmoothing(500, 33);
            ScrollTrigger.removeEventListener('refresh', resync);
            lenis.destroy();
            if (window.lenis === lenis) delete window.lenis;
        };
    }

    /* Jump the page (no easing) whether or not Lenis is driving it. Lenis
       caches the page height, so after a layout change it is told to
       re-measure first, or it would clamp the jump to the old height. */
    function jumpTo(y) {
        if (window.lenis) {
            window.lenis.resize();
            window.lenis.scrollTo(y, { immediate: true, force: true });
        } else {
            window.scrollTo(0, y);
        }
    }

    const ctx = gsap.context(function (rootContext) {
        const mm = gsap.matchMedia();

        mm.add({
            isMotion: '(prefers-reduced-motion: no-preference)',
            isReduced: '(prefers-reduced-motion: reduce)'
        }, function (context) {
            const isMotion = context.conditions.isMotion;
            const removers = [];
            const resets = [];
            const destroySmooth = isMotion ? initLenis() : null;

            /* Every listener goes through here. context.add(null, fn) wraps
               the handler without running it, so tweens it creates later
               still belong to this context and revert() cleans them up. */
            function on(target, type, fn) {
                const listener = context.add(null, fn);
                target.addEventListener(type, listener);
                removers.push(function () { target.removeEventListener(type, listener); });
            }

            /* ============================================
               NAME REVEAL
               Each word rides up out of its own mask.
               ============================================ */
            (function initName() {
                const words = gsap.utils.toArray('[data-word]');
                if (!words.length || !isMotion) return;

                /* fromTo, not from: the start state matches the CSS exactly,
                   so there is nothing to snap and nothing to flash. y is
                   pinned to 0 because GSAP parses the CSS translateY(118%)
                   into a pixel y, which would otherwise survive the tween. */
                gsap.fromTo(words, { yPercent: 118, y: 0 }, {
                    yPercent: 0,
                    y: 0,
                    duration: 1,
                    ease: 'power3.out',
                    stagger: 0.08,
                    delay: 0.15
                });
            })();

            /* ============================================
               HIGHLIGHTER SWEEPS
               Each mark is drawn left to right the first time
               it scrolls in, the way someone marks a page.
               ============================================ */
            (function initHighlighters() {
                const marks = gsap.utils.toArray('.hl');
                if (!marks.length) return;

                if (!isMotion) {
                    gsap.set(marks, { backgroundSize: '100% 48%' });
                    return;
                }

                marks.forEach(function (mark) {
                    gsap.to(mark, {
                        backgroundSize: '100% 48%',
                        duration: 0.65,
                        ease: 'power2.out',
                        scrollTrigger: { trigger: mark, start: 'top 90%', once: true }
                    });
                });
            })();

            /* ============================================
               EXPANDABLE ROLES
               Height is animated, then handed back to CSS so
               Resume mode and print can force everything open.
               ============================================ */
            const expanders = gsap.utils.toArray('[data-expand]');

            function panelFor(btn) {
                const id = btn.getAttribute('aria-controls');
                return id ? document.getElementById(id) : null;
            }

            function isOpen(btn) {
                return btn.getAttribute('aria-expanded') === 'true';
            }

            (function initExpanders() {
                if (!expanders.length) return;

                expanders.forEach(function (btn) {
                    const panel = panelFor(btn);
                    if (!panel) return;

                    on(btn, 'click', function () {
                        const open = isOpen(btn);
                        gsap.killTweensOf(panel);
                        const from = panel.offsetHeight;   /* before the CSS state flips */

                        btn.setAttribute('aria-expanded', String(!open));
                        panel.style.height = open ? '' : 'auto';

                        if (!isMotion) {
                            ScrollTrigger.refresh();
                            return;
                        }

                        const to = panel.offsetHeight;

                        gsap.fromTo(panel, { height: from }, {
                            height: to,
                            duration: open ? 0.4 : 0.45,
                            ease: open ? 'power2.inOut' : 'power2.out',
                            onComplete: function () {
                                panel.style.height = open ? '' : 'auto';
                                ScrollTrigger.refresh();
                            }
                        });
                    });
                });

                /* Put each role back as the markup authored it */
                const authored = expanders.map(isOpen);
                resets.push(function () {
                    expanders.forEach(function (btn, i) {
                        const panel = panelFor(btn);
                        btn.setAttribute('aria-expanded', String(authored[i]));
                        if (panel) panel.style.height = '';
                    });
                });
            })();

            /* ============================================
               SIGNATURE: WEB / RESUME MORPH
               1. Flip records every [data-flip-id] block.
               2. One class on <body> swaps the layout tokens
                  in style.css: the web page becomes A4.
               3. Flip animates each block from where it was,
                  while --scale tweens so the type shrinks
                  with the page instead of snapping.
               The block the reader was looking at stays put,
               so switching from deep in the page never
               throws them back to the top.
               ============================================ */
            (function initModes() {
                const group = document.querySelector('[data-modes]');
                if (!group) return;

                const body = document.body;
                const buttons = gsap.utils.toArray('[data-mode]', group);
                const status = document.querySelector('[data-mode-status]');
                const name = document.querySelector('.name');
                const bar = document.querySelector('.bar');
                const desk = document.querySelector('.desk');
                const details = expanders.map(panelFor).filter(Boolean);
                let current = 'web';
                let morph = null;

                /* Inline heights from the expanders would beat the document
                   tokens, so clear them for Resume mode and restore each
                   open panel's height on the way back. */
                function syncDetailHeights(mode) {
                    expanders.forEach(function (btn) {
                        const panel = panelFor(btn);
                        if (!panel) return;
                        gsap.killTweensOf(panel);
                        panel.style.height = mode === 'paper' || !isOpen(btn) ? '' : 'auto';
                    });
                }

                /* The first row still on screen below the bar, from the
                   main column, where the reader's eye is. Rows, not whole
                   sections: a section's top can be a screen away. */
                function findAnchor() {
                    const top = bar ? bar.getBoundingClientRect().bottom : 0;
                    const candidates = gsap.utils.toArray('.head-main, .col .entry, .col .work, .sheet-foot');
                    for (let i = 0; i < candidates.length; i++) {
                        const box = candidates[i].getBoundingClientRect();
                        if (box.height && box.bottom > top + 24) return candidates[i];
                    }
                    return null;
                }

                function readScale() {
                    return parseFloat(getComputedStyle(body).getPropertyValue('--scale')) || 1;
                }

                function setMode(next) {
                    if (next === current) return;

                    /* A second click mid-morph finishes the first one, so
                       the new state is always measured from a settled page */
                    if (morph) morph.progress(1);

                    const targets = gsap.utils.toArray('[data-flip-id]');
                    const deskFrom = desk ? desk.offsetHeight : 0;
                    const anchor = window.scrollY > 0 ? findAnchor() : null;
                    const anchorTop = anchor ? anchor.getBoundingClientRect().top : 0;
                    const state = isMotion ? Flip.getState(targets) : null;
                    const fromScale = readScale();
                    const fromName = name ? parseFloat(getComputedStyle(name).fontSize) : 0;
                    const closed = details.filter(function (panel) { return !panel.offsetHeight; });

                    syncDetailHeights(next);
                    body.classList.toggle('mode-paper', next === 'paper');

                    buttons.forEach(function (btn) {
                        btn.setAttribute('aria-pressed', String(btn.dataset.mode === next));
                    });
                    current = next;

                    if (status) {
                        status.textContent = next === 'paper'
                            ? 'Resume layout: one A4 page, as it prints.'
                            : 'Web layout.';
                    }

                    /* Hold the anchor block at the same height on screen */
                    if (anchor) {
                        jumpTo(window.scrollY + anchor.getBoundingClientRect().top - anchorTop);
                    }

                    if (!state) {
                        ScrollTrigger.refresh();
                        return;
                    }

                    const toScale = readScale();
                    const toName = name ? parseFloat(getComputedStyle(name).fontSize) : 0;
                    const duration = 0.8;
                    const ease = 'power3.inOut';

                    /* Flip lifts every block out of flow while it moves, which
                       would collapse the page under the reader. Hold the desk
                       at the taller of its two heights until the morph ends. */
                    if (desk) desk.style.minHeight = Math.max(deskFrom, desk.offsetHeight) + 'px';

                    body.classList.add('is-morphing');
                    morph = gsap.timeline({
                        onComplete: function () {
                            body.classList.remove('is-morphing');
                            if (desk) desk.style.minHeight = '';
                            body.style.removeProperty('--scale');
                            if (name) name.style.fontSize = '';
                            morph = null;
                            ScrollTrigger.refresh();
                        }
                    });

                    /* Flip.from measures the new layout the moment it is
                       called, so it goes first, before the type tweens
                       below put the old sizes back for the first frame. */
                    morph.add(Flip.from(state, {
                        duration: duration,
                        ease: ease,
                        absolute: true,
                        nested: true,
                        prune: true,
                        /* Blocks that only exist in one mode, such as the
                           at-a-glance list, fade rather than pop. */
                        onEnter: function (els) {
                            return gsap.fromTo(els, { opacity: 0 }, { opacity: 1, duration: 0.35, delay: duration * 0.55 });
                        },
                        onLeave: function (els) {
                            return gsap.to(els, { opacity: 0, duration: 0.2 });
                        }
                    }), 0);

                    /* The type settles a little ahead of the boxes, so it
                       is close to its final size while they are still
                       travelling and has room to reflow cleanly. */
                    const typeTime = duration * 0.8;
                    const typeEase = 'power2.inOut';
                    morph.fromTo(body, { '--scale': fromScale }, { '--scale': toScale, duration: typeTime, ease: typeEase }, 0);
                    if (name) {
                        morph.fromTo(name, { fontSize: fromName }, { fontSize: toName, duration: typeTime, ease: typeEase }, 0);
                    }

                    /* Screen-only details inside a moving block (the
                       availability line) return after the block has
                       settled, rather than popping in at full strength. */
                    const fades = gsap.utils.toArray('[data-mode-fade]');
                    if (next === 'web' && fades.length) {
                        morph.fromTo(fades, { opacity: 0 }, {
                            opacity: 1,
                            duration: 0.35,
                            clearProps: 'opacity'
                        }, duration * 0.6);
                    }

                    /* Role details a reader never opened arrive with the
                       document: hidden while their rows move, then in. */
                    if (next === 'paper' && closed.length) {
                        morph.fromTo(closed, { opacity: 0 }, {
                            opacity: 1,
                            duration: 0.35,
                            ease: 'power1.out',
                            clearProps: 'opacity'
                        }, duration * 0.62);
                    }
                }

                buttons.forEach(function (btn) {
                    on(btn, 'click', function () { setMode(btn.dataset.mode); });
                });

                resets.push(function () {
                    if (morph) morph.progress(1);
                    if (desk) desk.style.minHeight = '';
                    body.classList.remove('mode-paper', 'is-morphing');
                    buttons.forEach(function (btn) {
                        btn.setAttribute('aria-pressed', String(btn.dataset.mode === 'web'));
                    });
                });
            })();

            /* ============================================
               COPY EMAIL
               Confirms in place, then returns to its label.
               ============================================ */
            (function initCopy() {
                gsap.utils.toArray('[data-copy]').forEach(function (btn) {
                    const label = btn.querySelector('[data-copy-label]');
                    if (!label) return;
                    const original = label.textContent;
                    let restore = null;

                    on(btn, 'click', function () {
                        const value = btn.dataset.copy;

                        function confirm(text) {
                            if (restore) restore.kill();
                            label.textContent = text;
                            if (isMotion) {
                                gsap.fromTo(label, { opacity: 0.3 }, { opacity: 1, duration: 0.3 });
                            }
                            restore = gsap.delayedCall(1.8, function () {
                                label.textContent = original;
                            });
                        }

                        /* No clipboard (an insecure origin, a refused
                           permission): show the address so it can be read */
                        if (navigator.clipboard && navigator.clipboard.writeText) {
                            navigator.clipboard.writeText(value).then(
                                function () { confirm('Copied'); },
                                function () { confirm(value); }
                            );
                        } else {
                            confirm(value);
                        }
                    });

                    resets.push(function () { label.textContent = original; });
                });
            })();

            /* ============================================
               PRINT
               The print stylesheet always emits the A4 CV,
               so this works from either mode.
               ============================================ */
            (function initPrint() {
                gsap.utils.toArray('[data-print]').forEach(function (btn) {
                    on(btn, 'click', function () { window.print(); });
                });
            })();

            /* ============================================
               CLEANUP
               revert() undoes this context's tweens and its
               ScrollTriggers; this removes what it cannot see.
               ============================================ */
            return function cleanup() {
                removers.forEach(function (remove) { remove(); });
                removers.length = 0;
                resets.forEach(function (reset) { reset(); });
                resets.length = 0;
                if (destroySmooth) destroySmooth();
            };
        });

        const handleUnload = function () { rootContext.revert(); };
        window.addEventListener('beforeunload', handleUnload);

        return function cleanupPage() {
            window.removeEventListener('beforeunload', handleUnload);
        };
    });

    /* Exposed so a host page or SPA router can tear the page down:
       window.gsapContext.revert() */
    window.gsapContext = ctx;
});
!function(t){"loading"===document.readyState?document.addEventListener("DOMContentLoaded",t):t()}(function(){const t=document.documentElement;if("undefined"==typeof gsap||"undefined"==typeof ScrollTrigger||"undefined"==typeof Flip)return void t.classList.remove("has-js");t.classList.add("js-ready"),gsap.registerPlugin(ScrollTrigger,Flip);const e=gsap.context(function(e){gsap.matchMedia().add({isMotion:"(prefers-reduced-motion: no-preference)",isReduced:"(prefers-reduced-motion: reduce)"},function(e){const o=e.conditions.isMotion,n=[],r=[],i=o?function(){if("off"===(new URLSearchParams(location.search).get("smooth")||t.dataset.smooth)||"undefined"==typeof Lenis||window.lenis)return null;const e=new Lenis({allowNestedScroll:!0,anchors:!0,autoRaf:!1}),o=function(t){e.raf(1e3*t)},n=function(){e.scrollTo(window.scrollY,{immediate:!0,force:!0})};return e.on("scroll",ScrollTrigger.update),gsap.ticker.add(o),gsap.ticker.lagSmoothing(0),ScrollTrigger.addEventListener("refresh",n),window.lenis=e,function(){gsap.ticker.remove(o),gsap.ticker.lagSmoothing(500,33),ScrollTrigger.removeEventListener("refresh",n),e.destroy(),window.lenis===e&&delete window.lenis}}():null;function a(t,o,r){const i=e.add(null,r);t.addEventListener(o,i),n.push(function(){t.removeEventListener(o,i)})}!function(){const t=gsap.utils.toArray("[data-word]");t.length&&o&&gsap.fromTo(t,{yPercent:118,y:0},{yPercent:0,y:0,duration:1,ease:"power3.out",stagger:.08,delay:.15})}(),function(){const t=gsap.utils.toArray(".hl");t.length&&(o?t.forEach(function(t){gsap.to(t,{backgroundSize:"100% 48%",duration:.65,ease:"power2.out",scrollTrigger:{trigger:t,start:"top 90%",once:!0}})}):gsap.set(t,{backgroundSize:"100% 48%"}))}();const s=gsap.utils.toArray("[data-expand]");function c(t){const e=t.getAttribute("aria-controls");return e?document.getElementById(e):null}function l(t){return"true"===t.getAttribute("aria-expanded")}return function(){if(!s.length)return;s.forEach(function(t){const e=c(t);e&&a(t,"click",function(){const n=l(t);gsap.killTweensOf(e);const r=e.offsetHeight;if(t.setAttribute("aria-expanded",String(!n)),e.style.height=n?"":"auto",!o)return void ScrollTrigger.refresh();const i=e.offsetHeight;gsap.fromTo(e,{height:r},{height:i,duration:n?.4:.45,ease:n?"power2.inOut":"power2.out",onComplete:function(){e.style.height=n?"":"auto",ScrollTrigger.refresh()}})})});const t=s.map(l);r.push(function(){s.forEach(function(e,o){const n=c(e);e.setAttribute("aria-expanded",String(t[o])),n&&(n.style.height="")})})}(),function(){const t=document.querySelector("[data-modes]");if(!t)return;const e=document.body,n=gsap.utils.toArray("[data-mode]",t),i=document.querySelector("[data-mode-status]"),u=document.querySelector(".name"),d=document.querySelector(".bar"),f=document.querySelector(".desk"),g=s.map(c).filter(Boolean);let p="web",m=null;function h(){return parseFloat(getComputedStyle(e).getPropertyValue("--scale"))||1}function y(t){if(t===p)return;m&&m.progress(1);const r=gsap.utils.toArray("[data-flip-id]"),a=f?f.offsetHeight:0,y=window.scrollY>0?function(){const t=d?d.getBoundingClientRect().bottom:0,e=gsap.utils.toArray(".head-main, .col .entry, .col .work, .sheet-foot");for(let o=0;o<e.length;o++){const n=e[o].getBoundingClientRect();if(n.height&&n.bottom>t+24)return e[o]}return null}():null,w=y?y.getBoundingClientRect().top:0,S=o?Flip.getState(r):null,T=h(),v=u?parseFloat(getComputedStyle(u).fontSize):0,b=g.filter(function(t){return!t.offsetHeight});var E,C;if(E=t,s.forEach(function(t){const e=c(t);e&&(gsap.killTweensOf(e),e.style.height="paper"!==E&&l(t)?"auto":"")}),e.classList.toggle("mode-paper","paper"===t),n.forEach(function(e){e.setAttribute("aria-pressed",String(e.dataset.mode===t))}),p=t,i&&(i.textContent="paper"===t?"Resume layout: one A4 page, as it prints.":"Web layout."),y&&(C=window.scrollY+y.getBoundingClientRect().top-w,window.lenis?(window.lenis.resize(),window.lenis.scrollTo(C,{immediate:!0,force:!0})):window.scrollTo(0,C)),!S)return void ScrollTrigger.refresh();const L=h(),A=u?parseFloat(getComputedStyle(u).fontSize):0,k=.8;f&&(f.style.minHeight=Math.max(a,f.offsetHeight)+"px"),e.classList.add("is-morphing"),m=gsap.timeline({onComplete:function(){e.classList.remove("is-morphing"),f&&(f.style.minHeight=""),e.style.removeProperty("--scale"),u&&(u.style.fontSize=""),m=null,ScrollTrigger.refresh()}}),m.add(Flip.from(S,{duration:k,ease:"power3.inOut",absolute:!0,nested:!0,prune:!0,onEnter:function(t){return gsap.fromTo(t,{opacity:0},{opacity:1,duration:.35,delay:.55*k})},onLeave:function(t){return gsap.to(t,{opacity:0,duration:.2})}}),0);const x=.8*k,z="power2.inOut";m.fromTo(e,{"--scale":T},{"--scale":L,duration:x,ease:z},0),u&&m.fromTo(u,{fontSize:v},{fontSize:A,duration:x,ease:z},0);const H=gsap.utils.toArray("[data-mode-fade]");"web"===t&&H.length&&m.fromTo(H,{opacity:0},{opacity:1,duration:.35,clearProps:"opacity"},.48),"paper"===t&&b.length&&m.fromTo(b,{opacity:0},{opacity:1,duration:.35,ease:"power1.out",clearProps:"opacity"},.496)}n.forEach(function(t){a(t,"click",function(){y(t.dataset.mode)})}),r.push(function(){m&&m.progress(1),f&&(f.style.minHeight=""),e.classList.remove("mode-paper","is-morphing"),n.forEach(function(t){t.setAttribute("aria-pressed",String("web"===t.dataset.mode))})})}(),gsap.utils.toArray("[data-copy]").forEach(function(t){const e=t.querySelector("[data-copy-label]");if(!e)return;const n=e.textContent;let i=null;a(t,"click",function(){const r=t.dataset.copy;function a(t){i&&i.kill(),e.textContent=t,o&&gsap.fromTo(e,{opacity:.3},{opacity:1,duration:.3}),i=gsap.delayedCall(1.8,function(){e.textContent=n})}navigator.clipboard&&navigator.clipboard.writeText?navigator.clipboard.writeText(r).then(function(){a("Copied")},function(){a(r)}):a(r)}),r.push(function(){e.textContent=n})}),gsap.utils.toArray("[data-print]").forEach(function(t){a(t,"click",function(){window.print()})}),function(){n.forEach(function(t){t()}),n.length=0,r.forEach(function(t){t()}),r.length=0,i&&i()}});const o=function(){e.revert()};return window.addEventListener("beforeunload",o),function(){window.removeEventListener("beforeunload",o)}});window.gsapContext=e});
/* ============================================
   Nadia Osei, Resume Template
   Tokens: indigo desk, cream sheet, graphite ink,
   one highlighter yellow. The sheet is a document
   lying on a desk; Resume mode turns the desk into
   a print preview and the sheet into an A4 page.
   ============================================ */

/* The whole palette. Every colour on the page resolves from one of
   these tokens, so a reskin is this block and nothing else. It splits
   into two groups on purpose: the DESK is the chrome the document
   lies on, the SHEET is the document. Keeping them separate is what
   lets the desk go light without dragging the sticky bar with it. */
:root {
    /* -- The desk: page ground and sticky-bar chrome -- */
    --desk: #1E2A4A;        /* page ground, deep indigo */
    --desk-deep: #16203A;   /* sticky bar, scrollbar track, pressed states */
    --desk-lift: #33406A;   /* scrollbar thumb */
    --bar-ink: #F4EFE4;     /* primary type in the sticky bar */
    --bar-dim: #B9BFD2;     /* secondary type in the bar, 8.8:1 on --desk-deep */
    --bar-line: #6A7290;    /* ghost-button border, 3.4:1 on --desk-deep */
    --bar-rule: #2C3654;    /* the hairline under the bar */
    --bar-fill: #2A3556;    /* the mode-toggle track */
    --on-desk: #B9BFD2;     /* the page foot, the only type on the desk itself */

    /* -- The sheet: the document -- */
    --sheet: #F4EFE4;       /* the CV sheet, warm cream */
    --ink: #23262B;         /* body type on the sheet, 13.6:1 */
    --ink-soft: #5C626B;    /* secondary type, 5.4:1 on the sheet */
    --rule: #D8D0C1;        /* rules and dividers */
    --mark: #EDE04A;        /* highlighter, the only accent */
    --ok: #2E7D4F;          /* the one state colour: the availability dot */
    --sheet-shadow: rgba(8, 14, 30, 0.7);   /* the sheet lifting off the desk */

    /* -- Resume mode: the desk becomes a print preview -- */
    --paper-desk: #DBD8D1;
    --on-paper-desk: #4E5158;
    --paper-shadow: rgba(20, 24, 32, 0.55);

    --font-display: 'Bricolage Grotesque', sans-serif;
    --font-display-weight: 800;  /* Bricolage Grotesque: the alternates set their own */
    --font-body: 'Literata', Georgia, serif;
    --font-mono: 'Azeret Mono', monospace;

    --ease-soft: cubic-bezier(0.22, 1, 0.36, 1);
    --pad: clamp(1rem, 4vw, 3.5rem);

    /* -- Layout tokens --
       Every size that differs between the web page and the A4
       document is one of these. Resume mode and print swap the set
       (see DOCUMENT TOKENS below), so the rules further down are
       written once and never restated per mode. */
    --scale: 1;                                   /* multiplies all sheet type */
    --name-size: clamp(3.1rem, 7.2vw, 6.2rem);
    --sheet-max: 1140px;
    --sheet-min-h: 0px;
    --sheet-pad: clamp(1.5rem, 4.5vw, 4.25rem);
    --head-gap: clamp(1.5rem, 4vw, 3.5rem);       /* name column to at-a-glance */
    --side-col: 15rem;
    --body-gap: clamp(2rem, 5vw, 4.5rem);         /* side column to main column */
    --body-top: clamp(2.25rem, 4.5vw, 3.5rem);    /* head to body */
    --block-gap: clamp(2.5rem, 4.5vw, 3.25rem);   /* between sections */
    --side-gap: 2.25rem;
    --title-gap: 1.4rem;                          /* section title to content */
    --entry-gap: 1.9rem;                          /* between dated rows */
    --date-col: 7.5rem;
    --gutter: 1.5rem;
    --leading: 1.55;                              /* running text */

    /* scrollbars and native controls follow the palette */
    color-scheme: dark;
}

* { margin: 0; padding: 0; box-sizing: border-box; }

body {
    background: var(--desk);
    color: var(--bar-ink);
    font-family: var(--font-body);
    font-size: 1rem;
    line-height: 1.6;
    -webkit-font-smoothing: antialiased;
    overflow-x: hidden;
    transition: background-color 0.7s var(--ease-soft);
}

img { max-width: 100%; display: block; }
ul, ol { list-style: none; }
a { color: inherit; }

.sr-only {
    position: absolute;
    width: 1px; height: 1px;
    padding: 0; margin: -1px;
    overflow: hidden;
    clip: rect(0 0 0 0);
    white-space: nowrap;
    border: 0;
}

:focus-visible { outline: 2px solid var(--ink); outline-offset: 3px; }
.bar :focus-visible { outline-color: var(--mark); }

::selection { background: var(--mark); color: var(--ink); }

::-webkit-scrollbar { width: 10px; }
::-webkit-scrollbar-track { background: var(--desk-deep); }
::-webkit-scrollbar-thumb { background: var(--desk-lift); border: 2px solid var(--desk-deep); }
::-webkit-scrollbar-thumb:hover { background: var(--mark); }

/* ============================================
   STICKY UTILITY BAR
   ============================================ */

.bar {
    position: sticky;
    top: 0;
    z-index: 60;
    display: flex;
    align-items: center;
    gap: 1rem;
    padding: 0.7rem var(--pad);
    background: var(--desk-deep);
    border-bottom: 1px solid var(--bar-rule);
}

.bar-brand {
    display: flex;
    align-items: center;
    gap: 0.7rem;
    text-decoration: none;
    margin-right: auto;
    min-width: 0;
}

/* Initials on a square of highlighter: the one accent, used as a
   signature rather than a logo */
.bar-monogram {
    display: grid;
    place-items: center;
    width: 2.1rem; height: 2.1rem;
    flex: none;
    background: var(--mark);
    color: var(--ink);
    font-family: var(--font-display);
    font-weight: var(--font-display-weight);
    font-size: 0.74rem;
    letter-spacing: 0.01em;
}

.bar-name {
    display: flex;
    flex-direction: column;
    font-family: var(--font-display);
    font-weight: 600;
    font-size: 0.92rem;
    line-height: 1.2;
    white-space: nowrap;
}

.bar-role {
    font-family: var(--font-mono);
    font-weight: 400;
    font-size: 0.62rem;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    color: var(--bar-dim);
}

/* The mode toggle: the control the template is built around.
   Two equal halves and one highlighter thumb that slides between
   them; the thumb follows aria-pressed, so it can never disagree
   with what a screen reader is told. */
.modes {
    position: relative;
    display: grid;
    grid-template-columns: 1fr 1fr;
    padding: 3px;
    background: var(--bar-fill);
    border-radius: 999px;
}

.modes::before {
    content: "";
    position: absolute;
    top: 3px; bottom: 3px; left: 3px;
    width: calc(50% - 3px);
    border-radius: 999px;
    background: var(--mark);
    transition: transform 0.45s var(--ease-soft);
}
.modes:has([data-mode="paper"][aria-pressed="true"])::before { transform: translateX(100%); }

.mode-btn {
    position: relative;
    padding: 0.42rem 1rem;
    border: 0;
    border-radius: 999px;
    background: transparent;
    color: var(--bar-dim);
    font-family: var(--font-mono);
    font-size: 0.68rem;
    font-weight: 500;
    letter-spacing: 0.09em;
    text-transform: uppercase;
    cursor: pointer;
    transition: color 0.3s;
}

.mode-btn:hover { color: var(--bar-ink); }
.mode-btn[aria-pressed="true"] { color: var(--ink); }

/* Without JS the toggle does nothing, so it stays hidden */
.modes { display: none; }
.has-js .modes { display: grid; }

.bar-actions { display: flex; gap: 0.5rem; }

.btn {
    padding: 0.5rem 1rem;
    border: 1px solid transparent;
    border-radius: 999px;
    font-family: var(--font-mono);
    font-size: 0.68rem;
    font-weight: 500;
    letter-spacing: 0.09em;
    text-transform: uppercase;
    text-decoration: none;
    white-space: nowrap;
    cursor: pointer;
    transition: background-color 0.25s, color 0.25s, border-color 0.25s;
}

.btn--ghost {
    background: transparent;
    border-color: var(--bar-line);
    color: var(--bar-ink);
}
.btn--ghost:hover { border-color: var(--mark); color: var(--mark); }

.btn--solid {
    background: var(--bar-ink);
    color: var(--desk-deep);
}
.btn--solid:hover { background: var(--mark); }

/* Both bar actions need JS, so neither is offered without it.
   The email address stays readable in the contact list either way. */
[data-print], [data-copy] { display: none; }
.has-js [data-print], .has-js [data-copy] { display: inline-block; }

/* ============================================
   DESK + SHEET
   ============================================ */

.desk {
    padding: clamp(1.25rem, 4vw, 3.5rem) var(--pad) 0;
}

.sheet {
    display: flex;
    flex-direction: column;
    max-width: var(--sheet-max);
    min-height: var(--sheet-min-h);
    margin: 0 auto;
    padding: var(--sheet-pad);
    background: var(--sheet);
    color: var(--ink);
    border-radius: 3px;
    box-shadow: 0 30px 70px -30px var(--sheet-shadow);
}

/* ============================================
   HEAD
   ============================================ */

.sheet-head {
    display: grid;
    grid-template-columns: minmax(0, 1fr) var(--meta-col, minmax(0, 15rem));
    gap: var(--head-gap);
    align-items: end;
}

/* In the A4 document the head becomes two columns that line up with
   the body: name and role over the side column, summary over the
   main one. The placements below only take effect when the
   document tokens switch .head-main to a grid. */
.head-main {
    display: var(--head-display, block);
    grid-template-columns: var(--side-col) minmax(0, 1fr);
    column-gap: var(--body-gap);
    align-items: start;
}
.head-main > :not(.summary) { grid-column: 1; }
.head-main > .summary { grid-column: 2; grid-row: 1 / span 4; }

/* Career at a glance: what the reader wants before they commit to
   the full history. A screen affordance, so the document drops it. */
.head-meta {
    display: var(--screen-display, flex);
    flex-direction: column;
    gap: 1rem;
    padding-bottom: 0.3rem;
}

.head-meta > div { display: grid; gap: 0.2rem; }

.head-meta dt {
    font-family: var(--font-mono);
    font-size: 0.6rem;
    font-weight: 500;
    letter-spacing: 0.14em;
    text-transform: uppercase;
    color: var(--ink-soft);
}

.head-meta dd {
    font-size: 0.92rem;
    line-height: 1.45;
    border-top: 1px solid var(--rule);
    padding-top: 0.3rem;
}

.eyebrow {
    font-family: var(--font-mono);
    font-size: max(calc(0.66rem * var(--scale)), 8px);
    font-weight: 500;
    letter-spacing: 0.16em;
    text-transform: uppercase;
    color: var(--ink-soft);
}

.name {
    margin-top: 0.3em;
    font-family: var(--font-display);
    font-weight: var(--font-display-weight);
    font-size: var(--name-size);
    line-height: 0.95;
    letter-spacing: -0.035em;
    display: flex;
    flex-wrap: wrap;
    gap: 0 0.26em;
}

/* Each word rides up out of its own mask on load. The start state
   lives here rather than in the script, so the word is never painted
   in place and then snapped down. */
/* clip-path rather than overflow: it clips top and bottom only, so a
   word wider than its column (mid-morph) is never cut off at the side */
.mask { display: block; clip-path: inset(-0.2em -100vw 0 -100vw); padding-bottom: 0.06em; }
.word { display: block; }
.has-js .word { transform: translateY(118%); }


.role-line {
    margin-top: 0.75em;
    font-family: var(--font-display);
    font-weight: 400;
    font-size: calc(1.3rem * var(--scale));
    line-height: 1.3;
    color: var(--ink);
}

.dot { color: var(--ink-soft); padding: 0 0.35em; }

/* In the document's narrow head column, role and place stack */
.role-part { display: var(--role-part, inline); }
.role-line .dot { display: var(--role-dot, inline); }

.available {
    display: var(--screen-display, inline-flex);
    align-items: center;
    gap: 0.55rem;
    margin-top: 1.1rem;
    padding: 0.4rem 0.9rem 0.4rem 0.8rem;
    border: 1px solid var(--rule);
    border-radius: 999px;
    font-family: var(--font-mono);
    font-size: 0.68rem;
    letter-spacing: 0.02em;
    color: var(--ink);
}

.available-dot {
    width: 7px; height: 7px;
    border-radius: 50%;
    background: var(--ok);
    flex: none;
}

.summary {
    margin-top: var(--summary-top, 1.5em);
    max-width: 60ch;
    font-size: calc(1.12rem * var(--scale));
    line-height: var(--leading);
}

/* Highlighter: a real document behaviour, swept in on scroll.
   Sits behind the text so the ink stays fully legible. */
.hl {
    background-color: transparent;
    background-image: var(--hl-image, linear-gradient(var(--mark), var(--mark)));
    background-repeat: no-repeat;
    background-position: 0 84%;
    background-size: 100% 48%;
    color: inherit;
    padding: 0 0.06em;
}

.has-js .hl { background-size: 0% 48%; }

/* ============================================
   BODY GRID
   ============================================ */

.sheet-body {
    display: grid;
    grid-template-columns: var(--side-col) minmax(0, 1fr);
    gap: var(--body-gap);
    margin-top: var(--body-top);
    padding-top: var(--body-top);
    border-top: 1px solid var(--rule);
}

/* ============================================
   SIDE COLUMN
   ============================================ */

.side-block + .side-block { margin-top: var(--side-gap); }

.side-title, .block-title {
    padding-bottom: 0.6em;
    margin-bottom: var(--title-gap);
    border-bottom: 1px solid var(--rule);
    font-family: var(--font-mono);
    font-size: max(calc(0.64rem * var(--scale)), 8px);
    font-weight: 600;
    letter-spacing: 0.18em;
    text-transform: uppercase;
    color: var(--ink-soft);
}

.contact {
    display: flex;
    flex-direction: column;
    gap: 0.3em;
    font-size: calc(0.94rem * var(--scale));
    line-height: 1.5;
}

.contact a {
    text-decoration: none;
    border-bottom: 1px solid var(--link-rule, var(--rule));
    padding-bottom: 1px;
    transition: border-color 0.2s, background-color 0.2s;
}
.contact a:hover { border-color: var(--ink); background: var(--mark); }

.skill-label {
    font-family: var(--font-mono);
    font-size: max(calc(0.62rem * var(--scale)), 7.5px);
    letter-spacing: 0.1em;
    text-transform: uppercase;
    color: var(--ink-soft);
    margin: 1.1em 0 0.45em;
}
.side-block .skill-label:first-of-type { margin-top: 0; }

/* Chips on screen, comma lists on paper: the document tokens swap
   the display, the box and the separator. */
.chips {
    display: var(--chips-display, flex);
    flex-wrap: wrap;
    gap: var(--chip-gap, 0.35rem);
    font-size: calc(var(--chip-size, 0.68rem) * var(--scale));
    line-height: 1.5;
}

.chips li {
    display: var(--chip-display, block);
    padding: var(--chip-pad, 0.2rem 0.55rem);
    border: 1px solid var(--chip-border, var(--rule));
    border-radius: 3px;
    font-family: var(--chip-font, var(--font-mono));
    color: var(--ink);
}
.chips li:not(:last-child)::after { content: var(--chip-sep, none); }

/* Education: one line of qualification, one of place and date */
.edu-name {
    font-family: var(--font-display);
    font-weight: 600;
    font-size: calc(1rem * var(--scale));
    line-height: 1.3;
}
.edu-meta {
    margin-top: 0.15em;
    font-size: calc(0.9rem * var(--scale));
    line-height: 1.5;
    color: var(--ink-soft);
}

/* Speaking and writing: title, then venue and year */
.talks { display: grid; gap: 0.9em; font-size: calc(0.9rem * var(--scale)); line-height: 1.4; }

.talk-title {
    font-family: var(--font-display);
    font-weight: 600;
    font-size: calc(1rem * var(--scale));
    line-height: 1.3;
}
.talk-meta { margin-top: 0.15em; color: var(--ink-soft); }

/* ============================================
   MAIN COLUMN
   ============================================ */

.block + .block { margin-top: var(--block-gap); }

/* The spine is the chronology made visible: one rule running down
   the whole run of roles, in the gutter between the date column and
   the entry body. */
.spine { position: relative; }

.entry,
.work {
    position: relative;
    display: grid;
    grid-template-columns: var(--date-col) minmax(0, 1fr);
    gap: var(--gutter);
}

.entry { padding-bottom: var(--entry-gap); }
.entry:last-child { padding-bottom: 0; }

.spine .entry::before {
    content: "";
    position: absolute;
    left: calc(var(--date-col) + var(--gutter) / 2);
    top: 0.55rem;
    bottom: -0.45rem;
    width: 1px;
    background: var(--rule);
}
.spine .entry:last-child::before { display: none; }

.spine .entry::after {
    content: "";
    position: absolute;
    left: calc(var(--date-col) + var(--gutter) / 2 - 3px);
    top: calc(0.35rem + 2px);
    width: 7px; height: 7px;
    border-radius: 50%;
    background: var(--ink);
}

.entry-date,
.work-kind {
    font-family: var(--font-mono);
    font-size: max(calc(0.7rem * var(--scale)), 8px);
    line-height: 1.5;
    letter-spacing: 0.02em;
    color: var(--ink-soft);
    padding-top: 0.2em;
    text-align: right;
}

.entry-role,
.work-name {
    font-family: var(--font-display);
    font-weight: 600;
    font-size: calc(1.18rem * var(--scale));
    line-height: 1.25;
    letter-spacing: -0.01em;
}

.entry-org {
    margin-top: 0.15em;
    font-size: calc(0.95rem * var(--scale));
    color: var(--ink-soft);
}

.entry-place::before { content: " / "; color: var(--ink-soft); }

.entry-lede {
    margin-top: 0.45em;
    font-size: calc(1rem * var(--scale));
    line-height: var(--leading);
    max-width: 58ch;
}

/* Running text blocks set their own size, so the line boxes around
   their children shrink with the type rather than keeping a 16px strut */
.entry-body,
.work-body {
    font-size: calc(0.95rem * var(--scale));
    line-height: var(--leading);
}

/* Expandable detail */
.entry-more {
    display: var(--screen-display, inline-flex);
    align-items: center;
    gap: 0.45rem;
    margin-top: 0.7rem;
    padding: 0.2rem 0;
    border: 0;
    background: none;
    color: var(--ink);
    font-family: var(--font-mono);
    font-size: 0.66rem;
    font-weight: 500;
    letter-spacing: 0.1em;
    text-transform: uppercase;
    cursor: pointer;
}

.entry-more-icon {
    position: relative;
    width: 11px; height: 11px;
    flex: none;
}
.entry-more-icon::before,
.entry-more-icon::after {
    content: "";
    position: absolute;
    background: var(--ink);
    transition: transform 0.3s var(--ease-soft), opacity 0.3s;
}
.entry-more-icon::before { left: 0; top: 5px; width: 11px; height: 1px; }
.entry-more-icon::after { left: 5px; top: 0; width: 1px; height: 11px; }

.entry-more[aria-expanded="true"] .entry-more-icon::after { transform: rotate(90deg); opacity: 0; }

.entry-more:hover .entry-more-label { background: var(--mark); }

/* Closed by default once JS is here; the document tokens force every
   panel open, and the script clears its inline heights to let them. */
.entry-detail { overflow: hidden; }
.has-js .entry-detail { height: var(--detail-closed, 0); }
.has-js .entry-more[aria-expanded="true"] + .entry-detail { height: auto; }

.bullets {
    display: flex;
    flex-direction: column;
    gap: 0.4em;
    padding-top: 0.75em;
    max-width: 62ch;
}

.bullets li {
    position: relative;
    padding-left: 1em;
    font-size: calc(0.95rem * var(--scale));
    line-height: var(--leading);
    color: var(--ink);
}

.bullets li::before {
    content: "";
    position: absolute;
    left: 0; top: 0.72em;
    width: 0.4em; height: 1px;
    background: var(--ink-soft);
}

/* Selected work: the same two columns as the chronology, with the
   kind of project where the date would be, so the whole sheet reads
   in one grammar. */
.works { display: grid; gap: var(--entry-gap); }

.work-desc {
    margin-top: 0.3em;
    font-size: calc(0.98rem * var(--scale));
    line-height: var(--leading);
    max-width: 58ch;
}

.work-stat { color: var(--ink-soft); }

/* In the document a project's name runs into its description, so the
   list stays three lines long */
.work-name,
.work-desc { display: var(--run-in, block); }
.work-name { margin-right: 0.4em; }

/* ============================================
   SHEET FOOT + PAGE FOOT
   ============================================ */

.sheet-foot {
    margin-top: auto;
    padding-top: var(--block-gap);
}

.sheet-foot p {
    padding-top: 1em;
    border-top: 1px solid var(--rule);
    font-family: var(--font-mono);
    font-size: max(calc(0.64rem * var(--scale)), 8px);
    letter-spacing: 0.08em;
    text-transform: uppercase;
    color: var(--ink-soft);
}

.page-foot {
    padding: 2.25rem 0 2.75rem;
    text-align: center;
    font-family: var(--font-mono);
    font-size: 0.66rem;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    color: var(--on-desk);
}

/* ============================================
   DOCUMENT TOKENS
   Resume mode and print are the same document,
   so they share these values. Two groups:

   - Content: what the document shows. Applied in
     Resume mode at every width.
   - A4 geometry: the page itself. Applied in
     Resume mode wherever an A4 page fits on
     screen, and always in print.

   Print repeats both lists in its own block at
   the end of this section: change a value here,
   change it there.
   ============================================ */

body.mode-paper {
    /* content */
    --screen-display: none;      /* at-a-glance, availability, Detail buttons */
    --hl-image: none;            /* the highlighter is a screen affordance */
    --meta-col: 0px;             /* the head drops its at-a-glance column */
    --detail-closed: auto;       /* every role opens: a CV shows the detail */
    --link-rule: transparent;
    --chip-display: inline;
    --chip-pad: 0;
    --chip-border: transparent;
    --chip-font: var(--font-body);
    --chip-size: 0.92rem;
    --chip-sep: ", ";
    --chip-gap: 0;
    --chips-display: block;      /* so a list wraps word by word, like prose */

    --scale: 0.9;
    background: var(--paper-desk);
}

/* A4 is 794 x 1123 CSS pixels: the sheet takes exactly that size
   and the content is set to fit it, so what is on screen is the
   page the PDF will hold. */
@media screen and (min-width: 880px) {
    body.mode-paper {
        --scale: 0.72;
        --name-size: 2.75rem;
        --sheet-max: 794px;
        --sheet-min-h: 1123px;
        --sheet-pad: 42px 50px 34px;
        --head-gap: 0px;
        --side-col: 164px;
        --body-gap: 26px;
        --body-top: 18px;
        --block-gap: 18px;
        --side-gap: 18px;
        --title-gap: 9px;
        --entry-gap: 11px;
        --date-col: 3.9rem;
        --gutter: 1rem;
        --leading: 1.4;
        --head-display: grid;
        --summary-top: 0px;
        --run-in: inline;
        --role-part: block;
        --role-dot: none;
    }
}

body.mode-paper .sheet {
    box-shadow: 0 18px 46px -22px var(--paper-shadow);
    border-radius: 0;
}

body.mode-paper .page-foot { color: var(--on-paper-desk); }

/* While the layout morphs, each moving block clips its own content.
   Text is reflowing at in-between sizes inside in-between boxes, and
   a clean edge reads as a mask where spill would read as a collision. */
.is-morphing [data-flip-id]:not(.sheet) { overflow: hidden; }

/* ============================================
   RESPONSIVE
   Screen only: print has its own fixed page.
   ============================================ */

@media screen and (max-width: 900px) {
    .sheet-head { grid-template-columns: 1fr; gap: 1.75rem; }
    .head-meta {
        display: var(--screen-display, grid);
        grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
        gap: 1rem 1.5rem;
    }

    /* One column: contact first, then the history, then the rest of
       the side column. display: contents lets the side blocks join
       the body grid so they can be ordered around the main column. */
    .sheet-body { grid-template-columns: minmax(0, 1fr); gap: 2.5rem; }
    .side { display: contents; }
    .side-block + .side-block { margin-top: 0; }
    .side-block--contact { order: -1; }
    .side-block:not(.side-block--contact) { order: 1; }
}

@media screen and (max-width: 720px) {
    .entry, .work { grid-template-columns: 1fr; gap: 0.3rem; }
    .entry-date, .work-kind { text-align: left; padding-top: 0; }

    /* The spine only reads as chronology in two columns */
    .spine .entry::before,
    .spine .entry::after { display: none; }
}

@media screen and (max-width: 560px) {
    .bar { gap: 0.6rem; padding-block: 0.6rem; }
    /* The monogram carries the brand; the name is still read out */
    .bar-name {
        position: absolute;
        width: 1px; height: 1px;
        overflow: hidden;
        clip: rect(0 0 0 0);
    }
    .bar-actions .btn--ghost { display: none; }
    .mode-btn { padding-inline: 0.75rem; }
    .btn { padding-inline: 0.85rem; }
    .btn-long { display: none; }

    .available { font-size: 0.64rem; letter-spacing: 0; }
}

/* ============================================
   REDUCED MOTION
   Everything visible, nothing to wait for.
   ============================================ */

@media (prefers-reduced-motion: reduce) {
    body, .mode-btn, .btn, .modes::before { transition: none !important; }

    .has-js .hl { background-size: 100% 48%; }
    .has-js .word { transform: none !important; }
}

/* ============================================
   PRINT
   Always prints the A4 document, whichever mode
   is on screen: the same tokens as Resume mode,
   on white, with the page margin inside the sheet.
   ============================================ */

@media print {
    @page { size: A4; margin: 0; }

    /* body.mode-paper is listed so these win over Resume mode's own
       values when printing from that mode */
    body,
    body.mode-paper {
        /* content (keep in step with body.mode-paper) */
        --screen-display: none;
        --hl-image: none;
        --meta-col: 0px;
        --detail-closed: auto;
        --link-rule: transparent;
        --chip-display: inline;
        --chip-pad: 0;
        --chip-border: transparent;
        --chip-font: var(--font-body);
        --chip-size: 0.92rem;
        --chip-sep: ", ";
        --chip-gap: 0;
        --chips-display: block;

        /* A4 geometry (keep in step with the min-width: 880px block) */
        --scale: 0.72;
        --name-size: 2.75rem;
        --sheet-max: none;
        --sheet-min-h: 0px;
        --sheet-pad: 42px 50px 34px;
        --head-gap: 0px;
        --side-col: 164px;
        --body-gap: 26px;
        --body-top: 18px;
        --block-gap: 18px;
        --side-gap: 18px;
        --title-gap: 9px;
        --entry-gap: 11px;
        --date-col: 3.9rem;
        --gutter: 1rem;
        --leading: 1.4;
        --head-display: grid;
        --summary-top: 0px;
        --run-in: inline;
        --role-part: block;
        --role-dot: none;

        background: #fff !important;
    }

    .bar, .page-foot, .modes { display: none !important; }

    .desk { padding: 0 !important; }

    .sheet {
        background: #fff !important;
        box-shadow: none !important;
        border-radius: 0 !important;
    }

    /* Whatever the script left mid-animation, print the settled page */
    .word { opacity: 1 !important; transform: none !important; }
    .entry-detail { height: auto !important; }

    a { text-decoration: none; }
}

/* ============================================================
   ALTERNATE PALETTES
   ------------------------------------------------------------
   Four alternatives to the default indigo. These are yours to
   keep: they are not demo scaffolding, and nothing outside this
   block depends on them.

   To use one, set the attribute on the <html> tag in
   index.html:

       <html lang="en" data-palette="moss">

   ...or, once you have settled on one, copy its values into the
   :root block at the top of this file and delete the other
   three. Keep the print rule at the end of this block either
   way: see the note on it.

   Each block restates the same nineteen colour tokens as :root,
   including the two shadow tokens, so nothing is left resolving
   from the palette you switched away from. Everything else on
   the page is already drawing from those tokens.

   Each block also carries its own display face, so the five
   read as five identities rather than one design in five
   colours; the faces are loaded by the fonts <link> in
   index.html, and a browser only downloads the one in use.
   Keep the display face and drop the palette, or the other way
   round: the two tokens are independent. Only the name and the
   bar monogram read the weight token; the entry and role lines
   keep asking for 600 and 400, and a family loaded at one
   weight answers with that weight.

   Every palette was measured against the same contrast bar as
   the default: body type on the sheet, the small mono date and
   label type (the most at-risk type on the page), type sitting
   over the highlighter, and every piece of type in the sticky
   bar. All at 4.5:1 or better. The highlighter itself, the
   availability dot and the sheet's edge against the desk were
   checked at 3:1 or better, so the marks still read as marks.

   The page foot is the only type sitting on the desk itself, so
   each palette gives it a solid --on-desk and --on-paper-desk
   token rather than a translucent tint: both measure 4.5:1 or
   better on their ground in every palette.
   ============================================================ */

/* ---- Moss: deep green desk, lime highlighter ---- */
:root[data-palette="moss"] {
    --font-display: 'Fraunces', Georgia, 'Times New Roman', serif;
    --font-display-weight: 700;  /* Fraunces: a soft, warm serif */

    --desk: #22342C;
    --desk-deep: #182620;
    --desk-lift: #354A40;
    --bar-ink: #F1F0E6;
    --bar-dim: #B3C0B7;
    --bar-line: #63756B;
    --bar-rule: #2E4238;
    --bar-fill: #2C4038;
    --on-desk: #B3C0B7;

    --sheet: #F2F0E9;
    --ink: #1F2420;
    --ink-soft: #585F59;
    --rule: #D3D2C7;
    --mark: #C4E86B;
    --ok: #2E7D4F;
    --sheet-shadow: rgba(6, 18, 12, 0.7);

    --paper-desk: #D9D9CE;
    --on-paper-desk: #4C5250;
    --paper-shadow: rgba(20, 28, 24, 0.55);
}

/* ---- Claret: wine desk, amber highlighter ---- */
:root[data-palette="claret"] {
    --font-display: 'Playfair Display', Georgia, 'Times New Roman', serif;
    --font-display-weight: 700;  /* Playfair Display: a high-contrast editorial serif */

    --desk: #3A1F28;
    --desk-deep: #2A151C;
    --desk-lift: #523240;
    --bar-ink: #F7EFE8;
    --bar-dim: #CDB6BC;
    --bar-line: #836268;
    --bar-rule: #46262F;
    --bar-fill: #4A2B34;
    --on-desk: #CDB6BC;

    --sheet: #F7F1E9;
    --ink: #2A2225;
    --ink-soft: #615A5C;
    --rule: #DDD3C7;
    --mark: #FFBE6E;
    --ok: #2E7D4F;
    --sheet-shadow: rgba(28, 8, 14, 0.7);

    --paper-desk: #DFD6CE;
    --on-paper-desk: #55504F;
    --paper-shadow: rgba(32, 20, 24, 0.55);
}

/* ---- Charcoal: near-black desk, cyan highlighter ---- */
:root[data-palette="charcoal"] {
    --font-display: 'IBM Plex Mono', ui-monospace, Menlo, monospace;
    --font-display-weight: 700;  /* IBM Plex Mono: a mono, for an engineer's CV */

    --desk: #1C1D20;
    --desk-deep: #141517;
    --desk-lift: #33353A;
    --bar-ink: #F2F1EE;
    --bar-dim: #B4B5B8;
    --bar-line: #63656A;
    --bar-rule: #2A2C30;
    --bar-fill: #28292D;
    --on-desk: #B4B5B8;

    --sheet: #FAF8F4;
    --ink: #1D1F22;
    --ink-soft: #5A5D63;
    --rule: #DAD6CF;
    --mark: #8FE3F0;
    --ok: #2E7D4F;
    --sheet-shadow: rgba(0, 0, 0, 0.7);

    --paper-desk: #DCDAD6;
    --on-paper-desk: #4F5257;
    --paper-shadow: rgba(16, 16, 18, 0.55);
}

/* ---- Kraft: the inversion. A light kraft-paper desk instead
        of a dark one, with the sticky bar staying dark so the
        chrome still separates from the document. This is why
        the bar tokens are their own group: --on-desk goes dark
        for the page foot while --bar-ink stays light one
        element away. The desk is kept dark enough to clear 3:1
        against the sheet, or the sheet stops reading as a sheet
        and the whole conceit falls apart. ---- */
:root[data-palette="kraft"] {
    --font-display: 'Zilla Slab', Rockwell, Georgia, serif;
    --font-display-weight: 700;  /* Zilla Slab: a slab */

    --desk: #9B9179;
    --desk-deep: #2A2721;
    --desk-lift: #4A463C;
    --bar-ink: #F5F1E9;
    --bar-dim: #B7B1A5;
    --bar-line: #767063;
    --bar-rule: #3B3730;
    --bar-fill: #3A362E;
    --on-desk: #292720;

    --sheet: #FFFDF8;
    --ink: #22242A;
    --ink-soft: #5A5D64;
    --rule: #DCD7CC;
    --mark: #FF8FB8;
    --ok: #2E7D4F;
    --sheet-shadow: rgba(40, 34, 22, 0.55);

    --paper-desk: #E2DED4;
    --on-paper-desk: #4F5158;
    --paper-shadow: rgba(40, 34, 22, 0.5);

    /* this palette inverts the ground */
    color-scheme: light;
}

/* ---- Print stays ink on white, whatever palette is set ----
        A CV gets printed, and the printed page has to be ink on
        white however the screen is coloured. The print
        stylesheet earlier in this file already forces a white
        page and black body type, and it removes the
        highlighter, so the only palette-dependent things left
        in print are the sheet's own type and rule colours.
        This rule pins those back to the defaults.

        It must stay BELOW the palette blocks above.
        :root[data-palette] and :root[data-palette="moss"] have
        the same specificity, so source order is the only thing
        making this win. Move it up and an alternate palette
        will print in its own colours.

        None of the four palettes above inverts the sheet, so
        this changes nothing about them today. It is here so
        that it stays true: add a fifth palette with a dark
        sheet and print still comes out correct. Nothing about
        the desk is restated, since none of it prints. ---- */
@media print {
    :root[data-palette] {
        --sheet: #F4EFE4;
        --ink: #23262B;
        --ink-soft: #5C626B;
        --rule: #D8D0C1;
        --mark: #EDE04A;
        --paper-desk: #DBD8D1;
        --on-paper-desk: #4E5158;
    }
}

A single-page CV site that morphs between two layouts. The signature moment is the Web / Résumé toggle: GSAP Flip records the position of every structural block, one class switch reflows the page from a spacious web layout into an A4 page, and Flip animates the difference while the type scales down with it. Print uses the same document layout, so the page prints as a clean one-page CV from either mode and your site and your PDF never drift apart.

Art direction: a document lying on a desk. Deep indigo ground, warm cream sheet, graphite type, and one highlighter yellow used only to mark the lines that matter.

What's Included

resume-template/
├── index.html          # The complete site
├── README.md           # These notes
├── LICENSE.txt         # Your licence, keep it
└── assets/
    ├── style.css       # All styles, responsive + print + reduced motion
    └── script.js       # All behaviour, commented section by section

No build step. No images to manage. Open index.html and it runs.

Quick Start

  1. Open index.html and replace the placeholder person with your own: name, role line, summary, contact list, roles, work, education, and talks.
  2. Update the <title> and <meta name="description">, and remove <meta name="robots" content="noindex, nofollow"> when you go live.
  3. Change data-copy="nadia@osei.dev" on the bar button to your address.
  4. Change the palette at the top of style.css. The whole page reskins from that one token block; see Colour.
  5. Or pick one of the five shipped palettes instead of editing tokens by hand: set data-palette on the <html> tag (see Palettes below).
  6. Deploy the folder anywhere that serves static files.

Fonts load from Google Fonts and GSAP from a CDN. To go fully self-hosted, download both and update the tags in <head>.

Optional: minify before you go live

assets/script.js is the readable source and is what index.html loads. It is around 23KB, which is fine to ship as-is. If you would rather serve it minified, produce it yourself once your edits are done:

bunx terser assets/script.js -c -m -o assets/script.min.js

Then point the last <script> tag in index.html at assets/script.min.js. Keep script.js as the file you edit and re-run the command after each change, otherwise the two quietly drift apart.

Sections & Options

The mode toggle ([data-modes], the signature)

Two buttons, data-mode="web" and data-mode="paper". Clicking one:

  • captures Flip.getState() on every [data-flip-id] block
  • toggles .mode-paper on <body>, which swaps the layout tokens
  • runs Flip.from() with absolute: true and nested: true so blocks can move and resize without tearing, while --scale and the name's size tween alongside so the type shrinks with the page instead of snapping
  • keeps the row you were reading at the same height on screen, so switching from deep in the page does not throw you back to the top

Everything the two modes differ by is a custom property. The :root block sets the web values (--scale, --sheet-max, --side-col, --date-col, the gaps), and the DOCUMENT TOKENS section of style.css swaps them: one set for what the document shows (chips become comma lists, every role opens, the highlighters and at-a-glance block go), and one set for the A4 geometry. In Resume mode the sheet is exactly 794 by 1123 pixels, which is A4 at 96dpi, so what you see is the page the PDF holds. On screens too narrow for that page, Resume mode keeps the document content in a readable single column.

To change what Resume mode looks like, edit those tokens. You do not need to touch the JavaScript. To add another block to the morph, give it a unique data-flip-id.

Keeping it to one page. The placeholder content fills one A4 page with a little room to spare. If yours runs over, cut a bullet or two, or lower --scale in both document token blocks (Resume mode and print) a step at a time. A CV that needs a second page will still print correctly; it just flows on.

To ship only one layout, delete the .modes markup from the bar. The page keeps whichever mode the body class says, and printing still works.

Expandable roles ([data-expand])

Each role's Detail button toggles aria-expanded and animates the panel height, then hands the height back to CSS so Resume mode and print can force everything open. Set aria-expanded="true" in the markup to start a role open, as the current role does. A role without a Detail button (see Studio Fennec) simply has no .entry-detail; nothing else changes.

Highlighter marks (.hl)

Wrap any phrase in <mark class="hl">. The mark is a background gradient swept from zero width by ScrollTrigger the first time it scrolls in. It is deliberately removed in Resume mode and in print, because a marked-up page is a screen affordance and not a document one.

Use it sparingly. Two or three marks across a page read as emphasis; ten read as decoration. Change the colour with --mark.

The mark sits behind the text rather than over it, so the thing to check when you change it is --ink against --mark at 4.5:1 or better. Keep it pale and saturated: real highlighters are, which is what makes a mark read as a mark instead of as a coloured box.

The chronology spine

The connecting rule and dots down the experience list are drawn with ::before and ::after on .entry, positioned in the gutter using the --date-col and --gutter tokens. Change the date column width in one place and the spine follows. Selected work uses the same two columns, with the kind of project where a date would be. The spine hides below 720px, where the layout stacks and the rule would no longer encode order.

At a glance (.head-meta)

The four label/value pairs beside the name. A screen affordance: Resume mode and print drop it, because the history below already says it. Add or remove <div><dt>…</dt><dd>…</dd></div> pairs freely.

Copy email ([data-copy])

Copies the value of the data-copy attribute and confirms in place for 1.8 seconds. Falls back to displaying the address if the Clipboard API is unavailable. Hidden entirely without JavaScript, since the address is in the contact list anyway.

Side column

Contact, skills, languages, education, and speaking and writing. Below 900px the page goes to one column with contact first, then the history, then the rest of the side column. Delete any side-block section freely.

Colour

Every colour on the page resolves from one token block at the top of style.css. There are no stray hex or rgba() values anywhere else in the stylesheet, apart from the black and white in the print block, which are meant to be literal, so a reskin really is this block and nothing else.

The block splits into two groups on purpose. The desk is the chrome the document lies on; the sheet is the document. Keeping them apart is what lets the desk go light without dragging the sticky bar into unreadability with it.

:root {
    /* -- The desk: page ground and sticky-bar chrome -- */
    --desk: #1E2A4A;        /* page ground */
    --desk-deep: #16203A;   /* sticky bar, scrollbar track */
    --desk-lift: #33406A;   /* scrollbar thumb */
    --bar-ink: #F4EFE4;     /* primary type in the bar */
    --bar-dim: #B9BFD2;     /* secondary type in the bar */
    --bar-line: #6A7290;    /* ghost-button border */
    --bar-rule: #2C3654;    /* the hairline under the bar */
    --bar-fill: #2A3556;    /* the mode-toggle track */
    --on-desk: #B9BFD2;     /* the page foot, the only type on the desk itself */

    /* -- The sheet: the document -- */
    --sheet: #F4EFE4;       /* the CV sheet */
    --ink: #23262B;         /* body type */
    --ink-soft: #5C626B;    /* dates, labels, secondary type */
    --rule: #D8D0C1;        /* rules and dividers */
    --mark: #EDE04A;        /* the highlighter, the only accent */
    --ok: #2E7D4F;          /* the one state colour: the availability dot */
    --sheet-shadow: rgba(8, 14, 30, 0.7);

    /* -- Resume mode: the desk becomes a print preview -- */
    --paper-desk: #DBD8D1;
    --on-paper-desk: #4E5158;
    --paper-shadow: rgba(20, 24, 32, 0.55);
}

In practice you change three: --desk, --sheet and --mark. The desk and the highlighter carry the whole identity.

Contrast to check after any change, all at 4.5:1 or better: --ink on --sheet, --ink-soft on --sheet (the small mono date and label type is the most at-risk thing on the page), --ink over --mark where the highlighter sweeps, and --bar-ink plus --bar-dim on --desk-deep. Then --on-desk on --desk for the page foot, and --on-paper-desk on --paper-desk for the same foot in Resume mode. The five shipped palettes are all measured at that bar, so they double as worked examples.

The page foot is the only type sitting directly on the desk, so it uses solid --on-desk and --on-paper-desk tokens rather than a translucent tint of the sheet colour: in the default palette they measure 7.7:1 and 5.6:1.

Palettes

The template ships with five palettes, each paired with its own display face so the five read as five identities rather than one design in five colours. Indigo is the default; the other four are in the ALTERNATE PALETTES block at the end of assets/style.css.

Palette Desk Highlighter Display face
Indigo (default) deep indigo yellow Bricolage Grotesque 800, a contemporary grotesk
Moss deep green lime Fraunces 700, a soft warm serif
Claret wine amber Playfair Display 700, a high-contrast editorial serif
Charcoal near-black cyan IBM Plex Mono 700, a mono
Kraft light kraft paper, with the bar staying dark pink Zilla Slab 700, a slab

The face and the colours are independent tokens (--font-display and --font-display-weight sit in each block next to the colours), so keep the palette and swap the face, or the other way round. All five faces are in the Google Fonts <link> in index.html; a browser only downloads the one the page uses, but once you have settled, trim the link to the family you kept. The body face (Literata) and the mono face (Azeret Mono) are the same in every palette. Only the name and the bar monogram read the weight token; the entry titles and the role line keep their own weights, and a family loaded at one weight answers with that weight.

To use one, set the attribute on the <html> tag in index.html:

<html lang="en" data-palette="moss">

That is the whole change. Each palette block restates the same nineteen colour tokens as :root, including the two shadow tokens, so nothing is left resolving from the palette you switched away from. Everything on the page is already drawing from those tokens.

Once you have settled on one, the tidier end state is to copy its values into the :root block at the top of style.css and delete the other three. Keep the @media print rule at the end of that block either way; see Printing.

Kraft is the one that inverts the template's ground, and it is why the desk and bar tokens are separate groups: --on-desk goes dark for the page foot while --bar-ink stays light one element away. Its desk is kept dark enough to clear 3:1 against the sheet, because below that the sheet stops reading as a sheet lying on a desk and the whole conceit falls apart.

Contrast. Every palette was measured against the same bar as the default, all at 4.5:1 or better: body type on the sheet, the small mono date and label type, type sitting over the highlighter, and every piece of type in the sticky bar. The highlighter itself, the availability dot and the sheet's edge against the desk were checked at 3:1 or better, so the marks still read as marks. If you edit a palette yourself, re-check those pairs.

The picker on the demo is not in your download. The dock in the corner of the online demo is preview scaffolding that lives in a file the zip does not contain. Your copy has the palettes without it, which is the right way round: nobody's CV should carry a theme switcher.

Printing

@page is A4 with no browser margin: the sheet's own padding is the page margin, so print and Resume mode are the same layout from the same tokens. The print stylesheet does not depend on the on-screen mode, so Cmd/Ctrl+P produces the same one-page CV whether the visitor is looking at the web layout or the résumé layout, and it works without JavaScript too. The Download PDF button just calls window.print().

Print is also independent of the palette, deliberately. A CV gets printed, and a printed CV is ink on white whatever the screen is doing: the print stylesheet forces a white page and black body type, drops the highlighter, and hides the desk entirely. On top of that, one rule at the very end of the ALTERNATE PALETTES block pins the document's own tokens back to their defaults under @media print, so a CV set to Claret or Kraft still prints as ink on white.

That rule has to stay below the palette blocks. :root[data-palette] and :root[data-palette="moss"] have identical specificity, so source order is the only thing making it win; move it above them and an alternate palette will print in its own colours. If you strip the file back to a single palette, you can drop the rule with the rest (your one remaining palette is the default), but there is no cost to leaving it.

Smooth Scrolling

Lenis is wired in and optional. Remove the CDN tag, the initLenis helper at the top of script.js and its one call to drop it. It shares GSAP's ticker with ScrollTrigger, never runs under reduced motion, and can be turned off with data-smooth="off" on <html> or ?smooth=off in the URL.

Accessibility

  • The mode toggle is a real pressed-state control (aria-pressed) with a live region announcing the change.
  • Expandable roles use aria-expanded and aria-controls on real buttons.
  • Every interaction is reachable and operable by keyboard, with a visible focus ring.
  • prefers-reduced-motion gets the same layouts with no tweens: the mode switch applies instantly and the highlighters are pre-drawn. Nothing is hidden or unreachable.
  • Without JavaScript the page reads as a complete document, with every role's detail already open. Only the toggle and the two bar buttons, which need JS, are hidden.

Customisation Notes

  • Colour: one token block at the top of style.css, split into desk and sheet groups; see Colour. The desk and the highlighter carry the identity, so change --desk, --sheet and --mark first.
  • Type: --font-display (Bricolage Grotesque), --font-body (Literata), --font-mono (Azeret Mono). Update the Google Fonts link when you swap them.
  • Scale: --scale multiplies the sheet's type sizes, so you can tune the density of each mode from one value.
  • Sections are independent: the script skips any section it cannot find, so delete what you do not need.

Dependencies

  • GSAP 3.15.0 (core)
  • ScrollTrigger
  • Flip
  • Lenis 1.3.17 (optional)