update design

This commit is contained in:
John Gatward committed 2026-10-08 11:59:24 +01:00
1 parent a8fde951a6
commit 874ea1f8bf
10 files changed
+1221 -790

No files matched your search

+93
View File
@@ -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.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

+270 -198
View File
@@ -11,6 +11,13 @@
<meta name="theme-color" content="#1e1e2e" />
<link rel="icon" href="icons/favicon.ico" sizes="any" />
<link rel="apple-touch-icon" href="icons/apple-touch-icon.png" />
<link
rel="preload"
href="fonts/geist-variable.ttf"
as="font"
type="font/ttf"
crossorigin
/>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link
href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,400;0,700;1,400&family=Fraunces:ital,opsz,wght@0,9..144,300;0,9..144,600;1,9..144,300&display=swap"
@@ -30,56 +37,61 @@
<li><a href="#projects">projects</a></li>
<li><a href="#contact">contact</a></li>
</ul>
<details class="nav-menu">
<summary>Menu</summary>
<ul class="mobile-nav-links">
<li><a href="#about">about</a></li>
<li><a href="#skills">skills</a></li>
<li><a href="#projects">projects</a></li>
<li><a href="#contact">contact</a></li>
</ul>
</details>
</nav>
<main id="main-content" tabindex="-1">
<!-- ─── Hero ─────────────────────────────────────────────────── -->
<section id="hero">
<p class="hero-eyebrow">// havox.org - v4</p>
<div class="hero-copy">
<h1 class="hero-name">John <em>Gatward</em></h1>
<p class="hero-subtitle">Software Engineer</p>
<p class="hero-subtitle">Backend engineer. Curious builder.</p>
<p class="hero-desc">
<span class="hero-by-line"
><span class="hero-by">Backend engineer</span
><span class="hero-by-role">by trade</span></span
>
<span class="hero-by-line"
><span class="hero-by">Developer enthusiast</span
><span class="hero-by-role">by curiosity</span></span
>
<span class="hero-by-line"
><span class="hero-by">Unofficial family cloud engineer</span
><span class="hero-by-role">by necessity</span></span
>
I build services, interactive experiments, and things my family
actually uses.
</p>
<div class="hero-links">
<a href="#projects" class="btn btn-primary">View projects</a>
<a href="#contact" class="btn btn-ghost">Contact me</a>
</div>
</div>
</section>
<div class="divider"></div>
<!-- ─── About ─────────────────────────────────────────────────── -->
<section id="about" class="about-section">
<div class="reveal">
<p class="section-label">about</p>
<h2 class="section-heading">Havox & <em>me</em></h2>
<h2 class="section-heading">Havox &amp; me</h2>
</div>
<div class="about-columns">
<!-- Bio blocks -->
<div class="about-grid reveal" style="transition-delay: 0.05s">
<div class="about-block">
<p>
<div class="about-lead reveal">
<img
class="about-portrait"
src="images/headshot.jpeg"
alt="John Gatward"
width="800"
height="800"
loading="lazy"
decoding="async"
/>
<p class="about-intro">
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.
written. Four redesigns and several dead domains later, it's still
going. Less a portfolio, more a scrapbook.
</p>
</div>
<div class="about-columns">
<div class="about-details reveal" style="transition-delay: 0.05s">
<div class="about-block">
<span class="about-block-tag tag tag-blue">day job</span>
<h3>Day job</h3>
<p>
Backend engineer at <strong>Sainsbury's</strong> Supply Chain
&amp; Logistics. It's good fun.
@@ -87,7 +99,7 @@
</div>
<div class="about-block">
<span class="about-block-tag tag tag-teal">dev</span>
<h3>Always learning</h3>
<p>
Fascinated with new languages, frameworks, and shiny tech I
probably don’t need. Constantly learning just enough to build
@@ -96,7 +108,7 @@
</div>
<div class="about-block">
<span class="about-block-tag tag tag-yellow">linux</span>
<h3>Off the clock</h3>
<p>
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 timeline -->
<div class="reveal" style="transition-delay: 0.15s">
<p class="section-label">version history</p>
<h3 class="about-subheading">Version history</h3>
<div class="version-timeline">
<div class="version-item">
<div class="v-dot v-dot--current"></div>
@@ -177,13 +189,10 @@
</div>
</section>
<div class="divider"></div>
<!-- ─── Skills ─────────────────────────────────────────────────── -->
<section id="skills">
<p class="section-label reveal">skills</p>
<h2 class="section-heading reveal" style="transition-delay: 0.05s">
What I <em>know</em>
What I know
</h2>
<div class="skills-grid">
@@ -233,7 +242,7 @@
<span class="skill-card-icon">
<img
src="icons/docker-logo.svg"
alt="React"
alt="Docker"
class="skill-card-icon-image"
/>
</span>
@@ -249,14 +258,11 @@
<span class="tag tag-peach">Wireguard</span>
</div>
</div>
<div
class="skill-card skill-card--wide reveal"
style="transition-delay: 0.24s"
>
<div class="skill-card reveal" style="transition-delay: 0.24s">
<span class="skill-card-icon">
<img
src="icons/haskell-logo.svg"
alt="React"
alt="Haskell"
class="skill-card-icon-image"
/>
</span>
@@ -277,13 +283,10 @@
</div>
</section>
<div class="divider"></div>
<!-- ─── Projects ──────────────────────────────────────────────── -->
<section id="projects">
<p class="section-label reveal">projects</p>
<h2 class="section-heading reveal" style="transition-delay: 0.05s">
Things I <em>built</em>
Things I built
</h2>
<p class="section-intro reveal" style="transition-delay: 0.1s">
A timeline of my projects and an overview of my journey as a
@@ -291,28 +294,45 @@
recreate.
</p>
<div class="projects-subheading reveal" style="transition-delay: 0.12s">
<span>Featured projects</span>
</div>
<h3 class="projects-subheading reveal" style="transition-delay: 0.12s">
Featured projects
</h3>
<div
class="card-grid card-grid--featured reveal"
style="transition-delay: 0.14s"
>
<article
class="card card-link"
data-href="https://wordlesolver.umbra.mom"
>
<span class="card-date">9 Feb 2025</span>
<span class="project-status project-status--active"
>Active development</span
>
<span class="card-title">Wordle Solver</span>
<article class="card featured-card">
<img
class="project-preview"
src="images/wordle-solver-preview.webp"
alt="Wordle Solver showing a colour-coded guess and suggested next words"
width="1200"
height="800"
loading="lazy"
decoding="async"
/>
<div class="featured-card-content">
<a class="card-title" href="https://wordlesolver.umbra.mom">
Wordle Solver
<span class="card-action">Open solver (external)</span>
</a>
<span class="card-desc"
>Built after one too many missed 3-guess games. Now it plays
marginally better than me. Uses information theory to recommend
the next best guess.</span
>
<div class="project-meta">
<span class="card-date">9 Feb 2025</span>
<span class="project-status project-status--active"
>Active development</span
>
</div>
<span class="tag-row">
<span class="tag tag-yellow">Rust</span>
<span class="tag tag-teal">React</span>
<span class="tag tag-peach">TypeScript</span>
</span>
<span class="card-source">
Source code:
<a
@@ -329,26 +349,39 @@
>Frontend</a
>
</span>
<span class="tag-row">
<span class="tag tag-yellow">Rust</span>
<span class="tag tag-teal">React</span>
<span class="tag tag-peach">TypeScript</span>
</span>
</div>
</article>
<article
class="card card-link"
data-href="https://crackthequote.umbra.mom"
>
<span class="card-date">13 Apr 2024</span>
<span class="project-status project-status--active"
>Active development</span
>
<span class="card-title">Crack the Quote</span>
<article class="card featured-card">
<img
class="project-preview"
src="images/crack-the-quote-preview.webp"
alt="Crack the Quote showing the daily cipher puzzle and letter controls"
width="1200"
height="800"
loading="lazy"
decoding="async"
/>
<div class="featured-card-content">
<a class="card-title" href="https://crackthequote.umbra.mom">
Crack the Quote
<span class="card-action">Play game (external)</span>
</a>
<span class="card-desc"
>A substitution-cipher puzzle game with a daily challenge. Built
for me and my family to play after getting bored with NYT
games.</span
>
<div class="project-meta">
<span class="card-date">13 Apr 2024</span>
<span class="project-status project-status--active"
>Active development</span
>
</div>
<span class="tag-row">
<span class="tag tag-yellow">Rust</span>
<span class="tag tag-teal">React</span>
<span class="tag tag-green">JavaScript</span>
</span>
<span class="card-source">
Source code:
<a
@@ -358,64 +391,119 @@
>API</a
>
</span>
<span class="tag-row">
<span class="tag tag-yellow">Rust</span>
<span class="tag tag-teal">React</span>
<span class="tag tag-green">JavaScript</span>
</span>
</div>
</article>
</div>
<div class="projects-subheading reveal" style="transition-delay: 0.16s">
<span>All projects</span>
</div>
<h3 class="projects-subheading reveal" style="transition-delay: 0.16s">
All projects
</h3>
<fieldset
class="project-filters reveal"
style="transition-delay: 0.18s"
aria-label="Filter projects by tech stack"
hidden
>
<button
type="button"
class="filter-chip active"
data-filter="all"
aria-pressed="true"
>
<button type="button" class="filter-chip active" data-filter="all">
All
</button>
<button type="button" class="filter-chip" data-filter="javascript">
<button
type="button"
class="filter-chip"
data-filter="javascript"
aria-pressed="false"
>
JavaScript
</button>
<button type="button" class="filter-chip" data-filter="python">
<button
type="button"
class="filter-chip"
data-filter="python"
aria-pressed="false"
>
Python
</button>
<button type="button" class="filter-chip" data-filter="rust">
<button
type="button"
class="filter-chip"
data-filter="rust"
aria-pressed="false"
>
Rust
</button>
<button type="button" class="filter-chip" data-filter="react">
<button
type="button"
class="filter-chip"
data-filter="react"
aria-pressed="false"
>
React
</button>
<button type="button" class="filter-chip" data-filter="webassembly">
<button
type="button"
class="filter-chip"
data-filter="webassembly"
aria-pressed="false"
>
WebAssembly
</button>
<button type="button" class="filter-chip" data-filter="p5">
<button
type="button"
class="filter-chip"
data-filter="p5"
aria-pressed="false"
>
p5.js
</button>
<button type="button" class="filter-chip" data-filter="zig">
<button
type="button"
class="filter-chip"
data-filter="zig"
aria-pressed="false"
>
Zig
</button>
<button type="button" class="filter-chip" data-filter="cpp">
<button
type="button"
class="filter-chip"
data-filter="cpp"
aria-pressed="false"
>
C / C++
</button>
</fieldset>
<p
id="project-count"
class="project-count"
role="status"
aria-atomic="true"
hidden
></p>
<div id="projects-empty" class="projects-empty" hidden>
<h3>No projects match this filter</h3>
<p>Choose another technology or browse all projects.</p>
<button id="projects-reset" class="btn btn-ghost" type="button">
Show all projects
</button>
</div>
<div
id="project-grid"
class="card-grid reveal"
style="transition-delay: 0.2s"
>
<article
class="card project-card card-link"
data-tech="python"
data-href="https://pubquiz.umbra.mom"
>
<article class="card project-card" data-tech="python">
<span class="card-date">03 Apr 2025</span>
<span class="card-title">Pub Quiz Dashboard</span>
<a class="card-title" href="https://pubquiz.umbra.mom">
Pub Quiz Dashboard
<span class="card-action">Open dashboard (external)</span>
</a>
<span class="card-desc"
>A Python dashboard to track our local pub quiz
performances.</span
@@ -424,13 +512,12 @@
<span class="tag tag-red">Python</span>
</span>
</article>
<article
class="card project-card card-link"
data-tech="cpp webassembly"
data-href="projects/thin_ice/thin_ice.html"
>
<article class="card project-card" data-tech="cpp webassembly">
<span class="card-date">14 Nov 2024</span>
<span class="card-title">Thin Ice</span>
<a class="card-title" href="projects/thin_ice/thin_ice.html">
Thin Ice
<span class="card-action">Play game</span>
</a>
<span class="card-desc"
>A small browser tile game inspired by one I played growing
up.</span
@@ -441,13 +528,12 @@
<span class="tag tag-peach">Raylib</span>
</span>
</article>
<article
class="card project-card card-link"
data-tech="zig webassembly"
data-href="projects/tsp/index.html"
>
<article class="card project-card" data-tech="zig webassembly">
<span class="card-date">20 Mar 2024</span>
<span class="card-title">Travelling Salesman Problem</span>
<a class="card-title" href="projects/tsp/index.html">
Travelling Salesman Problem
<span class="card-action">Open visualiser</span>
</a>
<span class="card-desc"
>A nearest-neighbor + 2-opt visualiser. Wanted to revisit one of
my first projects with an optimised tech stack and
@@ -459,13 +545,12 @@
<span class="tag tag-teal">WebAssembly</span>
</span>
</article>
<article
class="card project-card card-link"
data-tech="rust"
data-href="projects/flocking/index.html"
>
<article class="card project-card" data-tech="rust">
<span class="card-date">31 Jan 2024</span>
<span class="card-title">Flocking</span>
<a class="card-title" href="projects/flocking/index.html">
Flocking
<span class="card-action">Open visualiser</span>
</a>
<span class="card-desc"
>Rewrote the project from Go to Rust, compiling it to WebAssembly.
Was fun being able to use DOM controls to affect the
@@ -476,16 +561,15 @@
<span class="tag tag-peach">Raylib</span>
</span>
</article>
<article
class="card project-card card-link"
data-tech="javascript"
data-href="https://samstoreymusic.com"
>
<article class="card project-card" data-tech="javascript">
<span class="card-date">1 Nov 2023</span>
<span class="project-status project-status--active"
>Active development</span
>
<span class="card-title">samstoreymusic.com</span>
<a class="card-title" href="https://samstoreymusic.com">
samstoreymusic.com
<span class="card-action">Visit website (external)</span>
</a>
<span class="card-desc"
>A website design and build for a friend working in music. Saving
him the hosting fees :)</span
@@ -495,13 +579,12 @@
<span class="tag tag-green">JavaScript</span>
</span>
</article>
<article
class="card project-card card-link"
data-tech="javascript react"
data-href="projects/cellular_automata/index.html"
>
<article class="card project-card" data-tech="javascript react">
<span class="card-date">21 Jul 2023</span>
<span class="card-title">Game of Life</span>
<a class="card-title" href="projects/cellular_automata/index.html">
Game of Life
<span class="card-action">Open simulator</span>
</a>
<span class="card-desc"
>After building Conway's Game of Life in p5.js as one of my first
projects, I expanded it into a generic automata simulator.</span
@@ -511,13 +594,12 @@
<span class="tag tag-green">JavaScript</span>
</span>
</article>
<article
class="card project-card card-link"
data-tech="cpp webassembly"
data-href="projects/percolation/index.html"
>
<article class="card project-card" data-tech="cpp webassembly">
<span class="card-date">5 Feb 2022</span>
<span class="card-title">Percolation</span>
<a class="card-title" href="projects/percolation/index.html">
Percolation
<span class="card-action">Open visualiser</span>
</a>
<span class="card-desc"
>Watched a brilliant video by
<a
@@ -534,13 +616,12 @@
<span class="tag tag-teal">WebAssembly</span>
</span>
</article>
<article
class="card project-card card-link"
data-tech="javascript p5"
data-href="projects/cubic_bezier_curve/index.html"
>
<article class="card project-card" data-tech="javascript p5">
<span class="card-date">1 Oct 2021</span>
<span class="card-title">Drawing Bézier curves</span>
<a class="card-title" href="projects/cubic_bezier_curve/index.html">
Drawing Bézier curves
<span class="card-action">Open visualiser</span>
</a>
<span class="card-desc"
>An interactive animation for how cubic Bézier curves are
constructed. Inspired by this beautiful video by
@@ -556,13 +637,12 @@
<span class="tag tag-red">p5.js</span>
</span>
</article>
<article
class="card project-card card-link"
data-tech="javascript p5"
data-href="projects/marching_squares.html"
>
<article class="card project-card" data-tech="javascript p5">
<span class="card-date">17 Sep 2021</span>
<span class="card-title">2D Marching Squares</span>
<a class="card-title" href="projects/marching_squares.html">
2D Marching Squares
<span class="card-action">Open generator</span>
</a>
<span class="card-desc"
>I saw the 3D version of this algorithm in a game dev log and was
inspired to create this 2D land-and-sea map generator.</span
@@ -572,13 +652,12 @@
<span class="tag tag-red">p5.js</span>
</span>
</article>
<article
class="card project-card card-link"
data-tech="javascript p5"
data-href="projects/warping_lines.html"
>
<article class="card project-card" data-tech="javascript p5">
<span class="card-date">2 Apr 2020</span>
<span class="card-title">Müller-Lyer illusion</span>
<a class="card-title" href="projects/warping_lines.html">
Müller-Lyer illusion
<span class="card-action">Open visualiser</span>
</a>
<span class="card-desc"
>JavaScript visualisation of the Müller-Lyer optical
illusion.</span
@@ -588,13 +667,12 @@
<span class="tag tag-red">p5.js</span>
</span>
</article>
<article
class="card project-card card-link"
data-tech="javascript p5"
data-href="projects/fourier_series.html"
>
<article class="card project-card" data-tech="javascript p5">
<span class="card-date">27 Feb 2019</span>
<span class="card-title">Fourier series</span>
<a class="card-title" href="projects/fourier_series.html">
Fourier series
<span class="card-action">Open visualiser</span>
</a>
<span class="card-desc"
>Builds a square wave from sine components to show Fourier series
in motion.</span
@@ -604,13 +682,12 @@
<span class="tag tag-red">p5.js</span>
</span>
</article>
<article
class="card project-card card-link"
data-tech="javascript p5"
data-href="projects/ellipse_construction.html"
>
<article class="card project-card" data-tech="javascript p5">
<span class="card-date">23 Feb 2019</span>
<span class="card-title">Constructing an ellipse</span>
<a class="card-title" href="projects/ellipse_construction.html">
Constructing an ellipse
<span class="card-action">Open visualiser</span>
</a>
<span class="card-desc"
>A geometric construction demo inspired by
<a
@@ -626,13 +703,12 @@
<span class="tag tag-red">p5.js</span>
</span>
</article>
<article
class="card project-card card-link"
data-tech="javascript p5"
data-href="projects/pi_approximation.html"
>
<article class="card project-card" data-tech="javascript p5">
<span class="card-date">18 Mar 2018</span>
<span class="card-title">Calculating Pi</span>
<a class="card-title" href="projects/pi_approximation.html">
Calculating Pi
<span class="card-action">Open visualiser</span>
</a>
<span class="card-desc"
>A Monte Carlo approximation of pi using random sampling.</span
>
@@ -641,13 +717,12 @@
<span class="tag tag-red">p5.js</span>
</span>
</article>
<article
class="card project-card card-link"
data-tech="javascript p5"
data-href="projects/oscillations_in_3d.html"
>
<article class="card project-card" data-tech="javascript p5">
<span class="card-date">17 Dec 2017</span>
<span class="card-title">Oscillations in 3D</span>
<a class="card-title" href="projects/oscillations_in_3d.html">
Oscillations in 3D
<span class="card-action">Open visualiser</span>
</a>
<span class="card-desc"
>A JavaScript recreation of a Bees and Bombs animation.</span
>
@@ -656,13 +731,12 @@
<span class="tag tag-red">p5.js</span>
</span>
</article>
<article
class="card project-card card-link"
data-tech="javascript p5"
data-href="projects/maze_generation/index.html"
>
<article class="card project-card" data-tech="javascript p5">
<span class="card-date">13 Nov 2017</span>
<span class="card-title">Maze generator</span>
<a class="card-title" href="projects/maze_generation/index.html">
Maze generator
<span class="card-action">Open generator</span>
</a>
<span class="card-desc"
>Discovered the
<a
@@ -686,20 +760,19 @@
type="button"
class="projects-more-btn"
aria-expanded="false"
aria-controls="project-grid"
hidden
>
See more
Show more projects
</button>
</div>
</section>
<div class="divider"></div>
<!-- ─── Contact ───────────────────────────────────────────────── -->
<section id="contact">
<p class="section-label reveal">contact</p>
<div class="contact-inner reveal" style="transition-delay: 0.1s">
<div>
<h2>Let's <em>talk</em></h2>
<h2>Let's talk</h2>
<p>
I am currently employed and open to the right software engineering
opportunity, particularly backend or full-stack roles with
@@ -708,31 +781,30 @@
<p>For professional enquiries, connect with me on LinkedIn.</p>
</div>
<div class="contact-links">
<a
href="https://github.com/jayo60013"
class="contact-link"
target="_blank"
rel="noopener"
>
<span
class="contact-link-icon contact-link-icon--github"
aria-hidden="true"
></span>
<span class="contact-link-label">GitHub</span>
<span class="contact-link-handle">jayo60013</span>
</a>
<a
href="https://www.linkedin.com/in/jaygatward"
class="contact-link"
class="btn btn-primary contact-link"
target="_blank"
rel="noopener"
rel="noopener noreferrer"
>
<span
class="contact-link-icon contact-link-icon--linkedin"
aria-hidden="true"
></span>
<span class="contact-link-label">LinkedIn</span>
<span class="contact-link-handle">jaygatward</span>
<span>Connect on LinkedIn</span>
</a>
<a
href="https://github.com/jayo60013"
class="contact-link contact-link--secondary"
aria-label="Explore my code on GitHub"
target="_blank"
rel="noopener noreferrer"
>
<span
class="contact-link-icon contact-link-icon--github"
aria-hidden="true"
></span>
<span>Explore my code</span>
</a>
</div>
</div>
+2 -1
View File
@@ -4,7 +4,8 @@
"description": "",
"main": "404.js",
"scripts": {
"format": "prettier --write ."
"format": "prettier --write .",
"test": "node --test tests/*.test.js"
},
"repository": {
"type": "git",
+119 -103
View File
@@ -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;
menu.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && menu.open) {
menu.open = false;
menu.querySelector('summary').focus();
}
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);
});
}
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;
}
}
+393 -488
View File
File diff suppressed because it is too large. Load diff
+344
View File
@@ -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(
/<button\b[^>]*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(
/<article\s+class="card project-card"\s+data-tech="([^"]+)"/g
),
(match) => 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(/<a class="card-title" href="([^"]+)"/g),
(match) => 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(/<section id="hero">([\s\S]*?)<\/section>/)[1];
assert.doesNotMatch(hero, /<img|<figure|flocking/i);
const previews = Array.from(
html.matchAll(/<img\s+class="project-preview"[\s\S]*?\/>/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(/<section id="contact">([\s\S]*?)<\/section>/)[1];
const links = Array.from(
contact.matchAll(/<a\s+href="([^"]+)"\s+class="([^"]+)"/g)
);
assert.equal(links.length, 2);
assert.equal(links[0][1], 'https://www.linkedin.com/in/jaygatward');
assert.match(links[0][2], /btn-primary/);
assert.equal(links[1][1], 'https://github.com/jayo60013');
assert.doesNotMatch(links[1][2], /btn-primary/);
assert.match(contact, /Connect on LinkedIn/);
assert.match(contact, /Explore my code/);
});
test('About has a genuine local portrait and an unboxed opening paragraph', () => {
const about = html.match(/<section id="about"[^>]*>([\s\S]*?)<\/section>/)[1];
assert.match(about, /class="about-intro"/);
assert.doesNotMatch(about, /about-grid|about-block-tag/);
const portrait = about.match(/<img\s+class="about-portrait"[\s\S]*?\/>/)[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, /<h3 class="about-subheading">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(/<article class="card featured-card">([\s\S]*?)<\/article>/g),
(match) => match[1]
);
assert.equal(featured.length, 2);
for (const card of featured) {
assert.ok(card.indexOf('project-preview') < card.indexOf('card-title'));
assert.ok(card.indexOf('card-title') < card.indexOf('card-desc'));
assert.ok(card.indexOf('card-desc') < card.indexOf('project-meta'));
assert.ok(card.indexOf('project-meta') < card.indexOf('tag-row'));
}
assert.doesNotMatch(html, /class="section-label|class="divider"/);
});