diff --git a/fonts/OFL-Geist.txt b/fonts/OFL-Geist.txt new file mode 100644 index 0000000..61f6f4b --- /dev/null +++ b/fonts/OFL-Geist.txt @@ -0,0 +1,93 @@ +Copyright 2024 The Geist Project Authors (https://github.com/vercel/geist-font.git) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. \ No newline at end of file diff --git a/fonts/geist-variable.ttf b/fonts/geist-variable.ttf new file mode 100644 index 0000000..f63f0af Binary files /dev/null and b/fonts/geist-variable.ttf differ diff --git a/images/crack-the-quote-preview.webp b/images/crack-the-quote-preview.webp new file mode 100644 index 0000000..a0c518a Binary files /dev/null and b/images/crack-the-quote-preview.webp differ diff --git a/images/headshot.jpeg b/images/headshot.jpeg new file mode 100644 index 0000000..1c72842 Binary files /dev/null and b/images/headshot.jpeg differ diff --git a/images/wordle-solver-preview.webp b/images/wordle-solver-preview.webp new file mode 100644 index 0000000..826190b Binary files /dev/null and b/images/wordle-solver-preview.webp differ diff --git a/index.html b/index.html index 5a9d058..ee8e5d0 100644 --- a/index.html +++ b/index.html @@ -11,6 +11,13 @@ + projects
  • contact
  • +
    -

    // havox.org - v4

    -

    John Gatward

    -

    Software Engineer

    -

    - - - -

    -
    -
    -
    - -

    Havox & me

    +

    Havox & me

    +
    + +
    + John Gatward +

    + Havox started in 2017 as a place to dump whatever projects I had + written. Four redesigns and several dead domains later, it's still + going. Less a portfolio, more a scrapbook. +

    - -
    +
    -

    - Havox started in 2017 as a place to dump whatever projects I had - written. Four redesigns and several dead domains later, it's - still going. Less a portfolio, more a scrapbook. -

    -
    - -
    - day job +

    Day job

    Backend engineer at Sainsbury's Supply Chain & Logistics. It's good fun. @@ -87,7 +99,7 @@

    - dev +

    Always learning

    Fascinated with new languages, frameworks, and shiny tech I probably don’t need. Constantly learning just enough to build @@ -96,7 +108,7 @@

    - linux +

    Off the clock

    Long-time Linux user and distro-hopper. Self-hosted server running 24/7 for friends and family who didn't ask for it but @@ -107,7 +119,7 @@

    - +

    Version history

    @@ -177,13 +189,10 @@
    -
    -
    -

    - What I know + What I know

    @@ -233,7 +242,7 @@ React @@ -249,14 +258,11 @@ Wireguard
    -
    +
    React @@ -277,13 +283,10 @@
    -
    -
    -

    - Things I built + Things I built

    A timeline of my projects and an overview of my journey as a @@ -291,131 +294,216 @@ recreate.

    -
    - Featured projects -
    +

    + Featured projects +

    -
    - All projects -
    +

    + All projects +

    + +
    -
    diff --git a/package.json b/package.json index 4ae1e9b..69d7a50 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,8 @@ "description": "", "main": "404.js", "scripts": { - "format": "prettier --write ." + "format": "prettier --write .", + "test": "node --test tests/*.test.js" }, "repository": { "type": "git", diff --git a/script.js b/script.js index 3f83e24..6df7ed7 100644 --- a/script.js +++ b/script.js @@ -1,16 +1,17 @@ -const NAV_OFFSET = 120; const DEFAULT_VISIBLE_PROJECTS = 9; -const INTERACTIVE_SELECTOR = - 'a, button, input, textarea, select, summary, [role="button"]'; -initRevealOnScroll(); -initActiveNavLink(); -initProjectCardLinks(); initProjectFilters(); +initMobileNav(); +initActiveNavLink(); +initRevealOnScroll(); function initRevealOnScroll() { const revealTargets = document.querySelectorAll('.reveal'); - if (revealTargets.length === 0) { + if ( + revealTargets.length === 0 || + !('IntersectionObserver' in window) || + window.matchMedia('(prefers-reduced-motion: reduce)').matches + ) { return; } @@ -28,91 +29,79 @@ function initRevealOnScroll() { { threshold: 0.1, rootMargin: '0px 0px -40px 0px' } ); - revealTargets.forEach((target) => observer.observe(target)); + revealTargets.forEach((target) => { + observer.observe(target); + target.classList.add('reveal-pending'); + }); } function initActiveNavLink() { const sections = Array.from(document.querySelectorAll('section[id]')); - const navLinks = Array.from(document.querySelectorAll('.nav-links a')); - if (sections.length === 0 || navLinks.length === 0) { + const navLinks = Array.from( + document.querySelectorAll('.nav-links a, .mobile-nav-links a') + ); + if ( + sections.length === 0 || + navLinks.length === 0 || + !('IntersectionObserver' in window) + ) { return; } - const updateActiveNavLink = () => { - let currentSectionId = ''; - - sections.forEach((section) => { - if (window.scrollY >= section.offsetTop - NAV_OFFSET) { - currentSectionId = section.id; + const visibleSections = new Set(); + const updateActiveNavLink = (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + visibleSections.add(entry.target); + } else { + visibleSections.delete(entry.target); } }); - + const currentSection = sections + .filter((section) => visibleSections.has(section)) + .at(-1); + if (!currentSection) { + return; + } navLinks.forEach((link) => { - link.classList.toggle( - 'is-active', - link.getAttribute('href') === `#${currentSectionId}` - ); + const isActive = link.getAttribute('href') === `#${currentSection.id}`; + link.classList.toggle('is-active', isActive); + if (isActive) { + link.setAttribute('aria-current', 'location'); + } else { + link.removeAttribute('aria-current'); + } }); }; - - window.addEventListener('scroll', updateActiveNavLink, { passive: true }); - updateActiveNavLink(); + let observer; + const observeSections = () => { + observer?.disconnect(); + visibleSections.clear(); + observer = new IntersectionObserver(updateActiveNavLink, { + rootMargin: `-56px 0px -${Math.max(0, window.innerHeight - 160)}px 0px`, + }); + sections.forEach((section) => observer.observe(section)); + }; + observeSections(); + window.addEventListener('resize', observeSections); } -function initProjectCardLinks() { - const cardLinks = document.querySelectorAll('.card-link[data-href]'); - if (cardLinks.length === 0) { +function initMobileNav() { + const menu = document.querySelector('.nav-menu'); + if (!menu) { return; } - - cardLinks.forEach((card) => { - card.setAttribute('role', 'link'); - card.setAttribute('tabindex', '0'); - - card.addEventListener('click', (event) => { - if (shouldIgnoreCardActivation(card, event.target)) { - return; - } - - navigateToCard(card, event); - }); - - card.addEventListener('keydown', (event) => { - if (event.key !== 'Enter' && event.key !== ' ') { - return; - } - - if (shouldIgnoreCardActivation(card, event.target)) { - return; - } - - event.preventDefault(); - navigateToCard(card, event); + menu.querySelectorAll('a').forEach((link) => { + link.addEventListener('click', () => { + menu.open = false; }); }); -} - -function shouldIgnoreCardActivation(card, target) { - if (!(target instanceof Element)) { - return false; - } - - const interactiveAncestor = target.closest(INTERACTIVE_SELECTOR); - return interactiveAncestor !== null && interactiveAncestor !== card; -} - -function navigateToCard(card, event) { - const href = card.dataset.href; - if (!href) { - return; - } - - if (event.metaKey || event.ctrlKey || event.button === 1) { - window.open(href, '_blank', 'noopener'); - return; - } - - window.location.assign(href); + menu.addEventListener('keydown', (event) => { + if (event.key === 'Escape' && menu.open) { + menu.open = false; + menu.querySelector('summary').focus(); + } + }); } function initProjectFilters() { @@ -125,6 +114,11 @@ function initProjectFilters() { } const seeMoreButton = document.querySelector('#projects-see-more'); + const projectCount = document.querySelector('#project-count'); + const emptyState = document.querySelector('#projects-empty'); + const projectGrid = document.querySelector('#project-grid'); + const filters = document.querySelector('.project-filters'); + const resetButton = document.querySelector('#projects-reset'); const techByCard = new Map( projectCards.map((card) => [ card, @@ -136,36 +130,47 @@ function initProjectFilters() { let isExpanded = false; const applyFilter = () => { - let visibleCount = 0; - + const matchingCards = projectCards.filter( + (card) => + selectedFilter === 'all' || + techByCard.get(card).includes(selectedFilter) + ); + const shownCards = isExpanded + ? matchingCards + : matchingCards.slice(0, DEFAULT_VISIBLE_PROJECTS); + const shownSet = new Set(shownCards); projectCards.forEach((card) => { - const tech = techByCard.get(card) || []; - const matchesFilter = - selectedFilter === 'all' || tech.includes(selectedFilter); - - card.classList.toggle('is-hidden', !matchesFilter); - if (!matchesFilter) { - card.classList.remove('is-collapsed'); - return; - } - - const isCollapsed = - !isExpanded && visibleCount >= DEFAULT_VISIBLE_PROJECTS; - card.classList.toggle('is-collapsed', isCollapsed); - visibleCount += 1; + card.hidden = !shownSet.has(card); }); - if (!seeMoreButton) { - return; + filterChips.forEach((chip) => { + const isSelected = chip.dataset.filter === selectedFilter; + chip.classList.toggle('active', isSelected); + chip.setAttribute('aria-pressed', String(isSelected)); + }); + if (projectCount) { + const label = filterChips + .find((chip) => chip.dataset.filter === selectedFilter) + .textContent.trim(); + projectCount.hidden = false; + projectCount.textContent = + `Showing ${shownCards.length} of ${matchingCards.length} project${matchingCards.length === 1 ? '' : 's'}` + + (selectedFilter === 'all' ? '' : ` (${label})`); + } + if (emptyState) { + emptyState.hidden = matchingCards.length !== 0; + } + if (projectGrid) { + projectGrid.hidden = matchingCards.length === 0; + } + if (seeMoreButton) { + const remainingCount = matchingCards.length - shownCards.length; + seeMoreButton.hidden = matchingCards.length <= DEFAULT_VISIBLE_PROJECTS; + seeMoreButton.setAttribute('aria-expanded', String(isExpanded)); + seeMoreButton.textContent = isExpanded + ? 'Show fewer projects' + : `Show ${remainingCount} more project${remainingCount === 1 ? '' : 's'}`; } - - const canExpand = visibleCount > DEFAULT_VISIBLE_PROJECTS; - seeMoreButton.hidden = !canExpand; - seeMoreButton.setAttribute( - 'aria-expanded', - canExpand && isExpanded ? 'true' : 'false' - ); - seeMoreButton.textContent = isExpanded ? 'See less' : 'See more'; }; filterChips.forEach((chip) => { @@ -173,10 +178,6 @@ function initProjectFilters() { selectedFilter = chip.dataset.filter || 'all'; isExpanded = false; - filterChips.forEach((otherChip) => { - otherChip.classList.toggle('active', otherChip === chip); - }); - applyFilter(); }); }); @@ -185,8 +186,23 @@ function initProjectFilters() { seeMoreButton.addEventListener('click', () => { isExpanded = !isExpanded; applyFilter(); + if (!isExpanded) { + seeMoreButton.scrollIntoView({ block: 'nearest', behavior: 'instant' }); + } + }); + } + + if (resetButton) { + resetButton.addEventListener('click', () => { + selectedFilter = 'all'; + isExpanded = false; + applyFilter(); + filterChips.find((chip) => chip.dataset.filter === 'all').focus(); }); } applyFilter(); + if (filters) { + filters.hidden = false; + } } diff --git a/style.css b/style.css index f07e82d..1af6c75 100644 --- a/style.css +++ b/style.css @@ -1,5 +1,15 @@ +@font-face { + font-family: 'Geist'; + font-style: normal; + font-weight: 100 900; + font-display: swap; + src: url('fonts/geist-variable.ttf') format('truetype'); +} + /* ─── Catppuccin Mocha ─────────────────────────────────── */ :root { + --font-body: 'Geist', system-ui, sans-serif; + --font-mono: 'JetBrains Mono', monospace; --base: #1e1e2e; --mantle: #181825; --crust: #11111b; @@ -40,74 +50,32 @@ html { scroll-behavior: smooth; } +[hidden] { + display: none !important; +} + body { - background: - radial-gradient(rgba(205, 214, 244, 0.028) 0.5px, transparent 0.6px), - radial-gradient(rgba(17, 17, 27, 0.032) 0.5px, transparent 0.6px), - radial-gradient( - 1200px 800px at 12% 8%, - rgba(137, 180, 250, 0.07), - transparent 62% - ), - radial-gradient( - 1000px 720px at 88% 92%, - rgba(203, 166, 247, 0.07), - transparent 64% - ), - linear-gradient(180deg, #1e1e2e 0%, #1b1b2b 55%, #1a1a29 100%); - background-size: - 3px 3px, - 3px 3px, - auto, - auto, - auto; - background-position: - 0 0, - 1px 1px, - 0 0, - 0 0, - 0 0; + background: var(--base); color: var(--text); - font-family: 'JetBrains Mono', 'Apple Color Emoji', monospace; - font-size: 15px; + font-family: var(--font-body); + font-size: 16px; line-height: 1.7; overflow-x: hidden; position: relative; } -body::before, -body::after { - content: ''; - position: fixed; - width: 52rem; - height: 42rem; - border-radius: 58% 42% 55% 45% / 46% 54% 48% 52%; - pointer-events: none; - z-index: -1; - filter: blur(90px); - opacity: 0.16; -} - -body::before { - top: -18rem; - right: -16rem; - transform: rotate(14deg); - background: radial-gradient( - closest-side at 38% 42%, - rgba(203, 166, 247, 0.42), - transparent 72% - ); -} - -body::after { - bottom: -20rem; - left: -18rem; - transform: rotate(-12deg); - background: radial-gradient( - closest-side at 60% 58%, - rgba(137, 180, 250, 0.36), - transparent 72% - ); +.nav-links, +.mobile-nav-links, +.v-year, +.v-current-badge, +.tag, +.card-date, +.project-status, +.card-source, +.card-action, +.project-count, +footer { + font-family: var(--font-mono); } ::selection { @@ -176,6 +144,9 @@ nav { } .nav-logo { + display: inline-flex; + align-items: center; + min-height: 44px; font-family: 'Fraunces', serif; font-size: 1.3rem; color: var(--mauve); @@ -189,47 +160,68 @@ nav { list-style: none; } -.nav-links a { +.nav-links a, +.mobile-nav-links a { + display: flex; + align-items: center; + min-height: 44px; color: var(--subtext0); text-decoration: none; font-size: 0.78rem; letter-spacing: 0.08em; text-transform: uppercase; - transition: color 0.2s; + text-decoration: underline; + text-decoration-color: transparent; + text-underline-offset: 0.3em; + transition: + color 0.2s, + text-decoration-color 0.2s; position: relative; } -.nav-links a::after { - content: ''; - position: absolute; - bottom: -3px; - left: 0; - right: 0; - height: 1px; - background: var(--mauve); - transform: scaleX(0); - transform-origin: left; - transition: transform 0.2s; +.nav-links a:hover, +.nav-links a:focus-visible, +.mobile-nav-links a:hover, +.mobile-nav-links a:focus-visible { + color: var(--mauve); + text-decoration-color: currentColor; } -.nav-links a:hover { - color: var(--text); -} - -.nav-links a.is-active { +.nav-links a.is-active, +.mobile-nav-links a.is-active { color: var(--mauve); } -.nav-links a:hover::after { - transform: scaleX(1); +.nav-menu { + display: none; + position: relative; +} + +.nav-menu summary { + min-height: 44px; + padding: 0.5rem 0.75rem; + color: var(--text); + cursor: pointer; +} + +.mobile-nav-links { + position: absolute; + top: calc(100% + 6px); + right: 0; + width: 180px; + padding: 0.5rem 1rem; + background: var(--mantle); + border: 1px solid var(--surface1); + border-radius: 8px; + list-style: none; } /* ─── Sections ─────────────────────────────────────────── */ section { - min-height: 100vh; - padding: 7rem 3rem 5rem; + padding: clamp(4rem, 7vw, 6rem) 3rem; max-width: 1100px; margin: 0 auto; + scroll-margin-top: 56px; } section.full-width { @@ -238,56 +230,25 @@ section.full-width { padding-right: 0; } -/* ─── Section labels ───────────────────────────────────── */ -.section-label { - font-size: 0.7rem; - letter-spacing: 0.2em; - text-transform: uppercase; - color: var(--mauve); - margin-bottom: 0.5rem; - display: flex; - align-items: center; - gap: 0.75rem; -} - -.section-label::after { - content: ''; - display: block; - height: 1px; - width: 3rem; - background: var(--mauve); - opacity: 0.5; -} - /* ─── Hero ─────────────────────────────────────────────── */ #hero { - min-height: 100vh; - display: flex; - flex-direction: column; - justify-content: center; - padding-top: 5rem; - position: relative; + padding-top: 6rem; + padding-bottom: clamp(3rem, 6vw, 5rem); } -.hero-eyebrow { - font-size: 0.75rem; - letter-spacing: 0.25em; - text-transform: uppercase; - color: var(--green); - margin-bottom: 1.5rem; - opacity: 0; - animation: fadeUp 0.6s 0.2s forwards; +.hero-copy { + min-width: 0; + max-width: 750px; } .hero-name { font-family: 'Fraunces', serif; - font-size: clamp(3.5rem, 9vw, 7.5rem); + font-size: clamp(3rem, 6vw, 5rem); font-weight: 600; - line-height: 1; + line-height: 1.1; + padding-bottom: 0.1em; color: var(--text); margin-bottom: 0.5rem; - opacity: 0; - animation: fadeUp 0.7s 0.35s forwards; } .hero-name em { @@ -296,78 +257,53 @@ section.full-width { } .hero-subtitle { - font-family: 'Fraunces', serif; - font-size: clamp(1.2rem, 3vw, 2.2rem); - font-weight: 300; + font-size: clamp(1.2rem, 2vw, 1.5rem); + font-weight: 500; + letter-spacing: -0.025em; color: var(--subtext1); - margin-bottom: 2rem; - opacity: 0; - animation: fadeUp 0.7s 0.5s forwards; + margin-bottom: 1rem; } .hero-desc { max-width: 560px; color: var(--subtext0); - font-size: 0.9rem; - line-height: 1.8; - margin-bottom: 2.5rem; - opacity: 0; - animation: fadeUp 0.7s 0.65s forwards; -} - -.hero-by-line { - display: flex; - align-items: baseline; - gap: 0.6rem; - margin-bottom: 0.3rem; -} - -.hero-by { - color: var(--text); - font-size: 0.9rem; -} - -.hero-by-role { - font-family: 'Fraunces', serif; - font-style: italic; - font-size: 0.85rem; - color: var(--mauve); - opacity: 0.8; + font-size: 1rem; + line-height: 1.6; + margin-bottom: 1.5rem; } .hero-links { display: flex; - gap: 1.25rem; + gap: 0.75rem; flex-wrap: wrap; - opacity: 0; - animation: fadeUp 0.7s 0.8s forwards; } .btn { display: inline-flex; align-items: center; + justify-content: center; + min-height: 44px; gap: 0.5rem; padding: 0.6rem 1.4rem; border-radius: 4px; - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 0.78rem; letter-spacing: 0.05em; text-decoration: none; transition: all 0.2s; cursor: pointer; border: none; + white-space: nowrap; } .btn-primary { background: var(--mauve); color: var(--crust); - box-shadow: - 0 0 0 1px rgba(203, 166, 247, 0.25), - 0 10px 26px rgba(17, 17, 27, 0.4); } .btn-primary:hover { - background: var(--lavender); + background: var(--mauve); + filter: brightness(1.05); transform: translateY(-2px); } @@ -383,78 +319,63 @@ section.full-width { transform: translateY(-2px); } -/* Decorative grid bg */ -#hero::before { - content: ''; - position: absolute; - inset: 0; - background-image: - linear-gradient(var(--surface0) 1px, transparent 1px), - linear-gradient(90deg, var(--surface0) 1px, transparent 1px); - background-size: 60px 60px; - opacity: 0.18; - mask-image: radial-gradient(ellipse 80% 60% at 50% 50%, black, transparent); - pointer-events: none; +.btn:active, +.filter-chip:active, +.projects-more-btn:active { + transform: scale(0.98); } /* ─── About ────────────────────────────────────────────── */ -.about-section { - min-height: auto; - padding-bottom: 5rem; +.about-lead { + display: grid; + grid-template-columns: minmax(0, 1fr) 176px; + align-items: center; + gap: 3rem; + margin-top: 1.5rem; +} + +.about-portrait { + grid-column: 2; + grid-row: 1; + display: block; + width: 176px; + height: auto; + border-radius: 8px; +} + +.about-intro { + grid-column: 1; + grid-row: 1; + max-width: 60ch; + font-size: 1.1rem; + color: var(--subtext1); + line-height: 1.75; } .about-columns { display: grid; grid-template-columns: 1fr 1fr; - gap: 2rem; - margin-top: 2.5rem; + gap: 4rem; + margin-top: 3rem; align-items: start; } -.about-grid { +.about-details { display: flex; flex-direction: column; - border: 1px solid var(--surface0); - border-radius: 8px; - overflow: hidden; + gap: 1.75rem; } -.about-block { - background: var(--mantle); - padding: 1.25rem 1.5rem; - border-bottom: 1px solid var(--surface0); - transition: background 0.2s; - display: flex; - align-items: baseline; - gap: 1.25rem; -} - -.about-grid .about-block:first-child { - background: linear-gradient( - 90deg, - rgba(203, 166, 247, 0.07), - rgba(203, 166, 247, 0.03) - ); - border-left: 2px solid rgba(203, 166, 247, 0.65); - box-shadow: inset 0 0 0 1px rgba(203, 166, 247, 0.08); -} - -.about-block:last-child { - border-bottom: none; -} - -.about-block-tag { - flex-shrink: 0; - font-size: 0.65rem; - letter-spacing: 0.1em; - text-transform: uppercase; - width: 5rem; - text-align: center; +.about-block h3, +.about-subheading { + font-size: 1rem; + font-weight: 600; + margin-bottom: 0.4rem; } .about-block p { color: var(--subtext0); - font-size: 0.85rem; + font-size: 1rem; line-height: 1.75; } @@ -511,16 +432,17 @@ section.full-width { z-index: 1; } -.version-item:hover .v-dot { - border-color: var(--mauve); - background: var(--mauve); -} - .v-title { - font-size: 0.85rem; + font-size: 1rem; font-weight: 700; color: var(--text); margin-bottom: 0.15rem; + text-decoration: underline; + text-decoration-color: transparent; + text-underline-offset: 0.3em; + transition: + color 0.2s, + text-decoration-color 0.2s; } .v-year { @@ -531,7 +453,7 @@ section.full-width { } .v-desc { - font-size: 0.8rem; + font-size: 0.95rem; color: var(--subtext0); line-height: 1.6; } @@ -542,13 +464,10 @@ section.full-width { cursor: pointer; } -.version-item--link:hover .v-dot { - border-color: var(--mauve); - background: var(--mauve); -} - -.version-item--link:hover .v-title { +.version-item--link:hover .v-title, +.version-item--link:focus-visible .v-title { color: var(--mauve); + text-decoration-color: currentColor; } .v-link-arrow { @@ -561,7 +480,8 @@ section.full-width { display: inline-block; } -.version-item--link:hover .v-link-arrow { +.version-item--link:hover .v-link-arrow, +.version-item--link:focus-visible .v-link-arrow { color: var(--mauve); transform: translate(2px, -2px); } @@ -586,42 +506,25 @@ section.full-width { /* ─── Skills ───────────────────────────────────────────── */ #skills h2 { - font-family: 'Fraunces', serif; - font-size: 2.8rem; - font-weight: 600; - color: var(--text); margin-bottom: 3rem; } -#skills h2 em { - font-style: italic; - color: var(--mauve); -} - .skills-grid { display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 1px; - background: var(--surface0); - border: 1px solid var(--surface0); - border-radius: 8px; - overflow: hidden; -} - -.skill-card--wide { - grid-column: 1 / -1; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 3rem 4rem; } .skill-card { - background: var(--mantle); - padding: 2rem; - transition: background 0.2s; + display: grid; + grid-template-columns: 2rem minmax(0, 1fr); + gap: 0.75rem 1rem; + align-content: start; } .skill-card-icon { - font-size: 1.5rem; - margin-bottom: 1rem; display: block; + align-self: center; } .skill-card-icon-image { @@ -631,16 +534,23 @@ section.full-width { } .skill-card h3 { - font-size: 0.85rem; + align-self: center; + font-size: 1.1rem; font-weight: 700; color: var(--text); - margin-bottom: 0.5rem; - letter-spacing: 0.05em; - text-transform: uppercase; +} + +.skill-card p, +.skill-card .tag-row { + grid-column: 2; +} + +.skill-card .tag-row { + margin-top: 0.25rem; } .skill-card p { - font-size: 0.82rem; + font-size: 0.95rem; color: var(--subtext0); line-height: 1.7; } @@ -656,74 +566,26 @@ section.full-width { display: inline-block; padding: 0.15rem 0.6rem; border-radius: 3px; - font-size: 0.7rem; + font-size: 0.75rem; letter-spacing: 0.04em; font-weight: 700; -} - -.tag-blue { - background: rgba(137, 180, 250, 0.12); - color: var(--blue); -} - -.tag-green { - background: rgba(166, 227, 161, 0.12); - color: var(--green); -} - -.tag-peach { - background: rgba(250, 179, 135, 0.12); - color: var(--peach); -} - -.tag-teal { - background: rgba(148, 226, 213, 0.12); - color: var(--teal); -} - -.tag-mauve { - background: rgba(203, 166, 247, 0.12); - color: var(--mauve); -} - -.tag-yellow { - background: rgba(249, 226, 175, 0.12); - color: var(--yellow); -} - -.tag-sky { - background: rgba(137, 220, 235, 0.12); - color: var(--sky); -} - -.tag-red { - background: rgba(243, 139, 168, 0.12); - color: var(--red); -} - -.tag-pink { - background: rgba(245, 194, 231, 0.12); - color: var(--pink); + background: var(--surface0); + color: var(--subtext1); } /* ─── Tutorials / Projects shared ─────────────────────── */ .section-heading { - font-family: 'Fraunces', serif; - font-size: 2.8rem; + font-size: clamp(2rem, 4vw, 2.8rem); font-weight: 600; + line-height: 1.15; + letter-spacing: -0.035em; color: var(--text); margin-bottom: 0.75rem; } -.section-heading em { - font-style: italic; - color: var(--mauve); - text-shadow: 0 0 16px rgba(203, 166, 247, 0.25); -} - .section-intro { color: var(--subtext0); - font-size: 0.88rem; + font-size: 1rem; max-width: 560px; line-height: 1.8; margin-bottom: 3rem; @@ -731,15 +593,66 @@ section.full-width { .projects-subheading { margin: 0 0 0.9rem; - font-size: 0.7rem; - letter-spacing: 0.14em; - text-transform: uppercase; - color: var(--mauve); + font-size: 1.1rem; + font-weight: 600; + color: var(--subtext1); } .card-grid.card-grid--featured { - margin-bottom: 2rem; + margin-bottom: 3rem; grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 2rem; + background: transparent; + border: none; + border-radius: 0; + overflow: visible; +} + +.card-grid--featured .card.featured-card { + padding: 0; + gap: 0; + border-radius: 8px; + background: transparent; +} + +.project-preview { + display: block; + width: 100%; + height: auto; + aspect-ratio: 3 / 2; + object-fit: cover; + border: 1px solid var(--surface0); + border-radius: 8px; + transition: border-color 0.2s; +} + +.featured-card-content { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 1.25rem 0.25rem 0; + flex: 1; +} + +.featured-card .card-title { + font-size: 1.35rem; +} + +.featured-card .tag-row { + margin-top: 0; +} + +.project-meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem 1rem; + margin-top: 0.75rem; +} + +.featured-card:hover .project-preview, +.featured-card:has(.card-title:focus-visible) .project-preview { + border-color: var(--mauve); } #projects .section-intro { @@ -770,11 +683,12 @@ section.full-width { background: var(--mantle); color: var(--subtext0); border-radius: 999px; - padding: 0.32rem 0.8rem; - font-size: 0.68rem; + min-height: 44px; + padding: 0.5rem 0.9rem; + font-size: 0.75rem; letter-spacing: 0.04em; text-transform: uppercase; - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); cursor: pointer; transition: all 0.2s; } @@ -790,12 +704,19 @@ section.full-width { color: var(--mauve); } -.card.is-hidden { - display: none; +.project-count { + color: var(--subtext0); + font-size: 0.8rem; + margin-bottom: 1rem; } -.card.is-collapsed { - display: none; +.projects-empty { + padding: 2rem 0; +} + +.projects-empty p { + color: var(--subtext0); + margin: 0.5rem 0 1rem; } .projects-more-row { @@ -809,11 +730,12 @@ section.full-width { background: var(--mantle); color: var(--subtext0); border-radius: 999px; - padding: 0.42rem 1rem; - font-size: 0.72rem; + min-height: 44px; + padding: 0.5rem 1rem; + font-size: 0.8rem; letter-spacing: 0.06em; text-transform: uppercase; - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); cursor: pointer; transition: all 0.2s; } @@ -842,112 +764,91 @@ section.full-width { gap: 0.5rem; text-decoration: none; color: inherit; - transition: - background 0.2s, - transform 0.2s, - box-shadow 0.2s; position: relative; overflow: hidden; } -.card-link { - cursor: pointer; - box-shadow: inset 0 0 0 1px transparent; -} - -.card-link:focus-visible { - outline: none; - background: var(--surface0); - transform: translateY(-2px); - box-shadow: - inset 0 0 0 1px rgba(203, 166, 247, 0.28), - 0 14px 30px rgba(17, 17, 27, 0.28); -} - -.card::before { - content: ''; - position: absolute; - top: 0; - left: 0; - width: 3px; - height: 100%; - background: var(--mauve); - transform: scaleY(0); - transform-origin: bottom; - transition: transform 0.25s; -} - -.card:hover { - background: var(--surface0); - transform: translateY(-1px); -} - -.card-link:hover { - box-shadow: - inset 0 0 0 1px rgba(203, 166, 247, 0.18), - 0 12px 24px rgba(17, 17, 27, 0.22); - transform: translateY(-2px); -} - -.card:hover::before, -.card:focus-visible::before { - transform: scaleY(1); -} - .card-date { - font-size: 0.68rem; - color: var(--overlay1); + font-size: 0.75rem; + color: var(--subtext0); letter-spacing: 0.06em; } .project-status { display: inline-block; align-self: flex-start; - font-size: 0.64rem; - font-weight: 700; - letter-spacing: 0.05em; - text-transform: uppercase; - padding: 0.12rem 0.45rem; - border-radius: 999px; + font-size: 0.7rem; + font-weight: 400; } .project-status--active { - color: var(--green); - background: rgba(166, 227, 161, 0.12); - border: 1px solid rgba(166, 227, 161, 0.24); + color: var(--subtext0); } .card-title { - font-size: 0.92rem; + display: block; + min-height: 44px; + font-size: 1.1rem; font-weight: 700; color: var(--text); - transition: color 0.2s; + transition: + color 0.2s, + text-decoration-color 0.2s; text-decoration: underline; text-decoration-color: transparent; text-underline-offset: 0.18em; } +/* Stretch the native title link over the card, below secondary links. */ +.card-title::after { + content: ''; + position: absolute; + inset: 0; +} + +.card:has(.card-title:focus-visible) { + box-shadow: inset 0 0 0 2px var(--mauve); +} + +.card:has(.card-title:focus-visible) .card-title { + outline: none; +} + +.card-action { + display: block; + margin-top: 0.25rem; + font-size: 0.75rem; + font-weight: 400; + color: var(--mauve); +} + .card:hover .card-title, -.card:focus-visible .card-title { +.card:has(.card-title:focus-visible) .card-title { color: var(--mauve); text-decoration-color: currentColor; } .card-desc { - font-size: 0.8rem; + font-size: 0.95rem; color: var(--subtext0); line-height: 1.6; flex: 1; } .card-source { - font-size: 0.7rem; - color: var(--overlay1); + font-size: 0.75rem; + color: var(--subtext0); letter-spacing: 0.04em; } .card-source a { - color: var(--sky); + position: relative; + z-index: 1; + display: inline-flex; + align-items: center; + min-height: 44px; + padding: 0 0.25rem; + color: var(--mauve); text-decoration: underline; text-underline-offset: 2px; } @@ -957,7 +858,12 @@ section.full-width { } .card-desc a { - color: var(--sky); + position: relative; + z-index: 1; + display: inline-flex; + align-items: center; + min-height: 44px; + color: var(--mauve); text-decoration: underline; text-underline-offset: 2px; } @@ -968,65 +874,53 @@ section.full-width { /* ─── Contact ──────────────────────────────────────────── */ #contact { - min-height: auto; - padding-bottom: 8rem; + padding-bottom: 6rem; } .contact-inner { - border: 1px solid var(--surface0); - border-radius: 8px; - padding: 3.5rem; - background: var(--mantle); - display: grid; - grid-template-columns: 1fr 1fr; - gap: 4rem; - align-items: start; + max-width: 720px; } .contact-inner h2 { - font-family: 'Fraunces', serif; - font-size: 2.4rem; + font-size: clamp(2rem, 4vw, 2.4rem); font-weight: 600; + line-height: 1.15; + letter-spacing: -0.035em; color: var(--text); margin-bottom: 1rem; } -.contact-inner h2 em { - font-style: italic; - color: var(--mauve); -} - .contact-inner p { color: var(--subtext0); - font-size: 0.88rem; + font-size: 1rem; line-height: 1.8; margin-bottom: 1.5rem; } .contact-links { display: flex; - flex-direction: column; - gap: 0.75rem; + align-items: center; + flex-wrap: wrap; + gap: 1rem 1.5rem; + margin-top: 2rem; } .contact-link { - display: flex; + display: inline-flex; align-items: center; - gap: 1rem; - padding: 1rem 1.25rem; - border: 1px solid var(--surface0); - border-radius: 6px; - text-decoration: none; - color: var(--text); - background: var(--base); - transition: all 0.2s; - font-size: 0.85rem; + gap: 0.75rem; + min-height: 44px; } -.contact-link:hover { - border-color: var(--mauve); +.contact-link--secondary { + color: var(--subtext1); + text-decoration: underline; + text-underline-offset: 4px; + transition: color 0.2s; +} + +.contact-link--secondary:hover { color: var(--mauve); - transform: translateX(4px); } .contact-link-icon { @@ -1052,15 +946,6 @@ section.full-width { mask-image: url('icons/linkedin.svg'); } -.contact-link-label { - flex: 1; -} - -.contact-link-handle { - font-size: 0.72rem; - color: var(--overlay1); -} - /* ─── Footer ───────────────────────────────────────────── */ footer { border-top: 1px solid var(--surface0); @@ -1069,11 +954,11 @@ footer { justify-content: space-between; align-items: center; font-size: 0.72rem; - color: var(--overlay0); + color: var(--subtext0); } footer a { - color: var(--overlay1); + color: var(--mauve); text-decoration: none; } @@ -1093,27 +978,26 @@ footer a:hover { } } -.reveal { - opacity: 0; - transform: translateY(24px); - transition: - opacity 0.6s ease, - transform 0.6s ease; +@media (prefers-reduced-motion: no-preference) { + .hero-copy { + animation: fadeUp 0.6s both; + } + + .reveal-pending { + opacity: 0; + transform: translateY(16px); + transition: + opacity 0.4s ease, + transform 0.4s ease; + } } -.reveal.visible { +.reveal.visible, +.reveal:focus-within { opacity: 1; transform: none; } -/* ─── Divider ──────────────────────────────────────────── */ -.divider { - height: 1px; - background: var(--surface0); - max-width: 1100px; - margin: 0 auto; -} - /* ─── Responsive ───────────────────────────────────────── */ @media (max-width: 768px) { nav { @@ -1125,7 +1009,20 @@ footer a:hover { } section { - padding: 6rem 1.5rem 4rem; + padding: 4rem 1.5rem; + } + + #hero { + padding: 5rem 1.5rem 2.5rem; + } + + .hero-links { + gap: 0.75rem; + } + + .hero-links .btn { + padding: 0.6rem 0.9rem; + font-size: 0.75rem; } .card-grid.card-grid--featured { @@ -1140,48 +1037,33 @@ footer a:hover { grid-template-columns: 1fr; } - .about-block-tag { - width: 4rem; + .about-lead { + grid-template-columns: 1fr; + gap: 1.5rem; + } + + .about-portrait { + grid-column: auto; + grid-row: auto; + width: 128px; + } + + .about-intro { + grid-column: auto; + grid-row: auto; } .skills-grid { grid-template-columns: 1fr; } - .skill-card--wide { - grid-column: 1; - } - - .contact-inner { - grid-template-columns: 1fr; - gap: 2rem; - padding: 2rem; + .skill-card p, + .skill-card .tag-row { + grid-column: 1 / -1; } .hero-name { - font-size: clamp(2.8rem, 12vw, 5rem); - } - - .hero-by-line { - align-items: flex-start; - flex-wrap: wrap; - gap: 0.35rem; - margin-bottom: 0.45rem; - } - - .hero-by-line:last-child { - margin-bottom: 0; - } - - .hero-by { - font-size: 0.86rem; - line-height: 1.5; - } - - .hero-by-role { - font-size: 0.8rem; - line-height: 1.5; - opacity: 0.75; + font-size: clamp(2.5rem, 10vw, 3.5rem); } footer { @@ -1191,6 +1073,16 @@ footer a:hover { } } +@media (max-width: 540px) { + .nav-links { + display: none; + } + + .nav-menu { + display: block; + } +} + @media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; @@ -1199,9 +1091,22 @@ footer a:hover { *, *::before, *::after { - animation-duration: 0.01ms !important; - animation-iteration-count: 1 !important; + animation: none !important; scroll-behavior: auto !important; - transition-duration: 0.01ms !important; + transition: none !important; + } + + .reveal, + .hero-copy { + opacity: 1; + transform: none; + } + + .btn, + .filter-chip, + .projects-more-btn, + .contact-link, + .v-link-arrow { + transform: none !important; } } diff --git a/tests/script.test.js b/tests/script.test.js new file mode 100644 index 0000000..5ad8e85 --- /dev/null +++ b/tests/script.test.js @@ -0,0 +1,344 @@ +const assert = require('node:assert/strict'); +const { readFileSync, existsSync } = require('node:fs'); +const { resolve } = require('node:path'); +const { test } = require('node:test'); +const { runInNewContext } = require('node:vm'); + +const root = resolve(__dirname, '..'); +const script = readFileSync(resolve(root, 'script.js'), 'utf8'); +const html = readFileSync(resolve(root, 'index.html'), 'utf8'); + +function element(dataset = {}, textContent = '') { + const classes = new Set(); + const attributes = new Map(); + const listeners = new Map(); + return { + dataset, + textContent, + hidden: true, + focused: false, + classList: { + add: (name) => classes.add(name), + contains: (name) => classes.has(name), + toggle: (name, force) => { + if (force) classes.add(name); + else classes.delete(name); + }, + }, + setAttribute: (name, value) => attributes.set(name, value), + getAttribute: (name) => attributes.get(name), + removeAttribute: (name) => attributes.delete(name), + addEventListener: (name, listener) => listeners.set(name, listener), + click: () => listeners.get('click')(), + dispatch: (name, event) => listeners.get(name)(event), + focus() { + this.focused = true; + }, + scrollIntoView() { + this.scrolledIntoView = true; + }, + }; +} + +function fixture({ missingFilter = false, reducedMotion = false } = {}) { + const chips = Array.from( + html.matchAll( + /]*data-filter="([^"]+)"[^>]*>([\s\S]*?)<\/button>/g + ), + (match) => element({ filter: match[1] }, match[2].trim()) + ); + if (missingFilter) chips.push(element({ filter: 'missing' }, 'Missing')); + const cards = Array.from( + html.matchAll( + / element({ tech: match[1] }) + ); + const controls = new Map( + [ + '#projects-see-more', + '#project-count', + '#projects-empty', + '#project-grid', + '.project-filters', + '#projects-reset', + ].map((selector) => [selector, element()]) + ); + const reveal = element(); + const observers = []; + class IntersectionObserver { + constructor(callback, options) { + this.callback = callback; + this.options = options; + this.targets = []; + observers.push(this); + } + observe(target) { + this.targets.push(target); + } + unobserve(target) { + this.targets = this.targets.filter((item) => item !== target); + } + disconnect() { + this.targets = []; + this.disconnected = true; + } + } + const context = { + document: { + querySelectorAll: (selector) => { + if (selector === '.filter-chip') return chips; + if (selector === '#project-grid .project-card') return cards; + if (selector === '.reveal') return [reveal]; + return []; + }, + querySelector: (selector) => controls.get(selector) || null, + }, + window: { + IntersectionObserver, + matchMedia: () => ({ matches: reducedMotion }), + }, + IntersectionObserver, + }; + return { + chips, + cards, + controls, + reveal, + observers, + context, + start: () => runInNewContext(script, context), + visible: () => cards.filter((card) => !card.hidden), + }; +} + +test('projects start collapsed with an accurate count and remaining label', () => { + const f = fixture(); + f.start(); + assert.equal(f.visible().length, 9); + assert.equal( + f.controls.get('#project-count').textContent, + `Showing 9 of ${f.cards.length} projects` + ); + assert.equal( + f.controls.get('#projects-see-more').textContent, + `Show ${f.cards.length - 9} more projects` + ); + assert.equal(f.controls.get('.project-filters').hidden, false); + assert.equal(f.controls.get('#projects-empty').hidden, true); + assert.equal(f.chips[0].getAttribute('aria-pressed'), 'true'); +}); + +test('expanding and collapsing updates the count and keeps the control in view', () => { + const f = fixture(); + f.start(); + const button = f.controls.get('#projects-see-more'); + button.click(); + assert.equal(f.visible().length, f.cards.length); + assert.equal(button.getAttribute('aria-expanded'), 'true'); + assert.equal(button.textContent, 'Show fewer projects'); + button.click(); + assert.equal(f.visible().length, 9); + assert.equal(button.getAttribute('aria-expanded'), 'false'); + assert.equal(button.scrolledIntoView, true); +}); + +test('every technology filter selects matching cards and resets expansion', () => { + const f = fixture(); + f.start(); + for (const chip of f.chips) { + f.controls.get('#projects-see-more').click(); + chip.click(); + const expected = f.cards.filter( + (card) => + chip.dataset.filter === 'all' || + card.dataset.tech.split(/\s+/).includes(chip.dataset.filter) + ); + assert.deepEqual(f.visible(), expected.slice(0, 9)); + assert.equal(chip.getAttribute('aria-pressed'), 'true'); + assert.equal( + f.chips.filter((item) => item.getAttribute('aria-pressed') === 'true') + .length, + 1 + ); + assert.equal( + f.controls.get('#projects-see-more').hidden, + expected.length <= 9 + ); + assert.equal( + f.controls.get('#projects-see-more').getAttribute('aria-expanded'), + 'false' + ); + assert.equal( + f.controls.get('#project-count').textContent, + `Showing ${Math.min(9, expected.length)} of ${expected.length} project${expected.length === 1 ? '' : 's'}` + + (chip.dataset.filter === 'all' ? '' : ` (${chip.textContent})`) + ); + } +}); + +test('empty results show a reset which restores all projects and focus', () => { + const f = fixture({ missingFilter: true }); + f.start(); + f.chips.at(-1).click(); + assert.equal(f.visible().length, 0); + assert.equal(f.controls.get('#projects-empty').hidden, false); + assert.equal(f.controls.get('#project-grid').hidden, true); + assert.equal(f.controls.get('#projects-see-more').hidden, true); + f.controls.get('#projects-reset').click(); + assert.equal(f.visible().length, 9); + assert.equal(f.controls.get('#projects-empty').hidden, true); + assert.equal(f.controls.get('#project-grid').hidden, false); + assert.equal(f.chips[0].focused, true); +}); + +test('reveals only become pending when an observer can reveal them', () => { + const f = fixture(); + f.start(); + assert.equal(f.reveal.classList.contains('reveal-pending'), true); + const observer = f.observers[0]; + observer.callback([{ target: f.reveal, isIntersecting: true }], observer); + assert.equal(f.reveal.classList.contains('visible'), true); + assert.equal(observer.targets.length, 0); + + const reduced = fixture({ reducedMotion: true }); + reduced.start(); + assert.equal(reduced.reveal.classList.contains('reveal-pending'), false); + + const unsupported = fixture(); + delete unsupported.context.window.IntersectionObserver; + unsupported.start(); + assert.equal(unsupported.reveal.classList.contains('reveal-pending'), false); + assert.equal(unsupported.controls.get('.project-filters').hidden, false); +}); + +test('active navigation uses a viewport-sized observer and updates on resize', () => { + const f = fixture(); + const sections = ['hero', 'about', 'skills', 'projects', 'contact'].map( + (id) => ({ ...element(), id }) + ); + const links = sections.slice(1).map((section) => { + const link = element(); + link.setAttribute('href', `#${section.id}`); + return link; + }); + const query = f.context.document.querySelectorAll; + f.context.document.querySelectorAll = (selector) => { + if (selector === 'section[id]') return sections; + if (selector === '.nav-links a, .mobile-nav-links a') return links; + return query(selector); + }; + const listeners = new Map(); + f.context.window.innerHeight = 900; + f.context.window.addEventListener = (name, listener) => + listeners.set(name, listener); + f.start(); + const observer = f.observers[0]; + assert.equal(observer.options.rootMargin, '-56px 0px -740px 0px'); + observer.callback([{ target: sections[1], isIntersecting: true }]); + assert.equal(links[0].getAttribute('aria-current'), 'location'); + observer.callback([ + { target: sections[1], isIntersecting: false }, + { target: sections[2], isIntersecting: true }, + ]); + assert.equal(links[0].getAttribute('aria-current'), undefined); + assert.equal(links[1].getAttribute('aria-current'), 'location'); + f.context.window.innerHeight = 600; + listeners.get('resize')(); + assert.equal(observer.disconnected, true); + assert.equal(f.observers.at(-1).options.rootMargin, '-56px 0px -440px 0px'); + assert.deepEqual(f.observers.at(-1).targets, sections); +}); + +test('mobile navigation closes after selection and Escape restores menu focus', () => { + const f = fixture(); + const menu = element(); + const summary = element(); + const link = element(); + menu.querySelectorAll = () => [link]; + menu.querySelector = () => summary; + f.controls.set('.nav-menu', menu); + f.start(); + menu.open = true; + link.click(); + assert.equal(menu.open, false); + menu.open = true; + menu.dispatch('keydown', { key: 'Escape' }); + assert.equal(menu.open, false); + assert.equal(summary.focused, true); +}); + +test('all project destinations are native links with existing local files', () => { + assert.doesNotMatch(html, /data-href=|class="[^"]*card-link/); + const links = Array.from( + html.matchAll(/ match[1] + ); + assert.equal(links.length, 17); + for (const href of links) { + if (!href.startsWith('https://')) { + assert.ok(existsSync(resolve(root, href)), `Missing project: ${href}`); + } + } +}); + +test('the hero is text-only and featured projects have local screenshots', () => { + const hero = html.match(/
    ([\s\S]*?)<\/section>/)[1]; + assert.doesNotMatch(hero, //g), + (match) => match[0] + ); + assert.equal(previews.length, 2); + for (const preview of previews) { + const src = preview.match(/src="([^"]+)"/)[1]; + assert.ok(existsSync(resolve(root, src))); + assert.match(preview, /loading="lazy"/); + assert.match(preview, /width="1200"/); + assert.match(preview, /height="800"/); + } +}); + +test('LinkedIn is the first and primary contact action, with GitHub secondary', () => { + const contact = html.match(/
    ([\s\S]*?)<\/section>/)[1]; + const links = Array.from( + contact.matchAll(/ { + const about = html.match(/
    ]*>([\s\S]*?)<\/section>/)[1]; + assert.match(about, /class="about-intro"/); + assert.doesNotMatch(about, /about-grid|about-block-tag/); + const portrait = about.match(//)[0]; + assert.match(portrait, /alt="John Gatward"/); + assert.match(portrait, /width="800"[\s\S]*height="800"/); + assert.ok(existsSync(resolve(root, portrait.match(/src="([^"]+)"/)[1]))); + assert.match(about, /

    Version history<\/h3>/); + assert.equal( + (about.match(/href="archive\/v[123]\/index.html"/g) || []).length, + 3 + ); +}); + +test('featured projects put title and purpose ahead of supporting metadata', () => { + const featured = Array.from( + html.matchAll(/