update design
This commit is contained in:
10 files changed
+1266
-835
No files matched your search
@@ -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 |
+312
-240
@@ -11,6 +11,13 @@
|
|||||||
<meta name="theme-color" content="#1e1e2e" />
|
<meta name="theme-color" content="#1e1e2e" />
|
||||||
<link rel="icon" href="icons/favicon.ico" sizes="any" />
|
<link rel="icon" href="icons/favicon.ico" sizes="any" />
|
||||||
<link rel="apple-touch-icon" href="icons/apple-touch-icon.png" />
|
<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 rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link
|
<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"
|
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="#projects">projects</a></li>
|
||||||
<li><a href="#contact">contact</a></li>
|
<li><a href="#contact">contact</a></li>
|
||||||
</ul>
|
</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>
|
</nav>
|
||||||
|
|
||||||
<main id="main-content" tabindex="-1">
|
<main id="main-content" tabindex="-1">
|
||||||
<!-- ─── Hero ─────────────────────────────────────────────────── -->
|
<!-- ─── Hero ─────────────────────────────────────────────────── -->
|
||||||
<section id="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>
|
<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">
|
<p class="hero-desc">
|
||||||
<span class="hero-by-line"
|
I build services, interactive experiments, and things my family
|
||||||
><span class="hero-by">Backend engineer</span
|
actually uses.
|
||||||
><span class="hero-by-role">by trade</span></span
|
</p>
|
||||||
>
|
<div class="hero-links">
|
||||||
<span class="hero-by-line"
|
<a href="#projects" class="btn btn-primary">View projects</a>
|
||||||
><span class="hero-by">Developer enthusiast</span
|
<a href="#contact" class="btn btn-ghost">Contact me</a>
|
||||||
><span class="hero-by-role">by curiosity</span></span
|
</div>
|
||||||
>
|
|
||||||
<span class="hero-by-line"
|
|
||||||
><span class="hero-by">Unofficial family cloud engineer</span
|
|
||||||
><span class="hero-by-role">by necessity</span></span
|
|
||||||
>
|
|
||||||
</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>
|
</section>
|
||||||
|
|
||||||
<div class="divider"></div>
|
|
||||||
|
|
||||||
<!-- ─── About ─────────────────────────────────────────────────── -->
|
<!-- ─── About ─────────────────────────────────────────────────── -->
|
||||||
<section id="about" class="about-section">
|
<section id="about" class="about-section">
|
||||||
<div class="reveal">
|
<div class="reveal">
|
||||||
<p class="section-label">about</p>
|
<h2 class="section-heading">Havox & me</h2>
|
||||||
<h2 class="section-heading">Havox & <em>me</em></h2>
|
</div>
|
||||||
|
|
||||||
|
<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.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="about-columns">
|
<div class="about-columns">
|
||||||
<!-- Bio blocks -->
|
<div class="about-details reveal" style="transition-delay: 0.05s">
|
||||||
<div class="about-grid reveal" style="transition-delay: 0.05s">
|
|
||||||
<div class="about-block">
|
<div class="about-block">
|
||||||
<p>
|
<h3>Day job</h3>
|
||||||
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.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="about-block">
|
|
||||||
<span class="about-block-tag tag tag-blue">day job</span>
|
|
||||||
<p>
|
<p>
|
||||||
Backend engineer at <strong>Sainsbury's</strong> Supply Chain
|
Backend engineer at <strong>Sainsbury's</strong> Supply Chain
|
||||||
& Logistics. It's good fun.
|
& Logistics. It's good fun.
|
||||||
@@ -87,7 +99,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="about-block">
|
<div class="about-block">
|
||||||
<span class="about-block-tag tag tag-teal">dev</span>
|
<h3>Always learning</h3>
|
||||||
<p>
|
<p>
|
||||||
Fascinated with new languages, frameworks, and shiny tech I
|
Fascinated with new languages, frameworks, and shiny tech I
|
||||||
probably don’t need. Constantly learning just enough to build
|
probably don’t need. Constantly learning just enough to build
|
||||||
@@ -96,7 +108,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="about-block">
|
<div class="about-block">
|
||||||
<span class="about-block-tag tag tag-yellow">linux</span>
|
<h3>Off the clock</h3>
|
||||||
<p>
|
<p>
|
||||||
Long-time Linux user and distro-hopper. Self-hosted server
|
Long-time Linux user and distro-hopper. Self-hosted server
|
||||||
running 24/7 for friends and family who didn't ask for it but
|
running 24/7 for friends and family who didn't ask for it but
|
||||||
@@ -107,7 +119,7 @@
|
|||||||
|
|
||||||
<!-- Version timeline -->
|
<!-- Version timeline -->
|
||||||
<div class="reveal" style="transition-delay: 0.15s">
|
<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-timeline">
|
||||||
<div class="version-item">
|
<div class="version-item">
|
||||||
<div class="v-dot v-dot--current"></div>
|
<div class="v-dot v-dot--current"></div>
|
||||||
@@ -177,13 +189,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="divider"></div>
|
|
||||||
|
|
||||||
<!-- ─── Skills ─────────────────────────────────────────────────── -->
|
<!-- ─── Skills ─────────────────────────────────────────────────── -->
|
||||||
<section id="skills">
|
<section id="skills">
|
||||||
<p class="section-label reveal">skills</p>
|
|
||||||
<h2 class="section-heading reveal" style="transition-delay: 0.05s">
|
<h2 class="section-heading reveal" style="transition-delay: 0.05s">
|
||||||
What I <em>know</em>
|
What I know
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div class="skills-grid">
|
<div class="skills-grid">
|
||||||
@@ -233,7 +242,7 @@
|
|||||||
<span class="skill-card-icon">
|
<span class="skill-card-icon">
|
||||||
<img
|
<img
|
||||||
src="icons/docker-logo.svg"
|
src="icons/docker-logo.svg"
|
||||||
alt="React"
|
alt="Docker"
|
||||||
class="skill-card-icon-image"
|
class="skill-card-icon-image"
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
@@ -249,14 +258,11 @@
|
|||||||
<span class="tag tag-peach">Wireguard</span>
|
<span class="tag tag-peach">Wireguard</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div class="skill-card reveal" style="transition-delay: 0.24s">
|
||||||
class="skill-card skill-card--wide reveal"
|
|
||||||
style="transition-delay: 0.24s"
|
|
||||||
>
|
|
||||||
<span class="skill-card-icon">
|
<span class="skill-card-icon">
|
||||||
<img
|
<img
|
||||||
src="icons/haskell-logo.svg"
|
src="icons/haskell-logo.svg"
|
||||||
alt="React"
|
alt="Haskell"
|
||||||
class="skill-card-icon-image"
|
class="skill-card-icon-image"
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
@@ -277,13 +283,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="divider"></div>
|
|
||||||
|
|
||||||
<!-- ─── Projects ──────────────────────────────────────────────── -->
|
<!-- ─── Projects ──────────────────────────────────────────────── -->
|
||||||
<section id="projects">
|
<section id="projects">
|
||||||
<p class="section-label reveal">projects</p>
|
|
||||||
<h2 class="section-heading reveal" style="transition-delay: 0.05s">
|
<h2 class="section-heading reveal" style="transition-delay: 0.05s">
|
||||||
Things I <em>built</em>
|
Things I built
|
||||||
</h2>
|
</h2>
|
||||||
<p class="section-intro reveal" style="transition-delay: 0.1s">
|
<p class="section-intro reveal" style="transition-delay: 0.1s">
|
||||||
A timeline of my projects and an overview of my journey as a
|
A timeline of my projects and an overview of my journey as a
|
||||||
@@ -291,131 +294,216 @@
|
|||||||
recreate.
|
recreate.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="projects-subheading reveal" style="transition-delay: 0.12s">
|
<h3 class="projects-subheading reveal" style="transition-delay: 0.12s">
|
||||||
<span>Featured projects</span>
|
Featured projects
|
||||||
</div>
|
</h3>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="card-grid card-grid--featured reveal"
|
class="card-grid card-grid--featured reveal"
|
||||||
style="transition-delay: 0.14s"
|
style="transition-delay: 0.14s"
|
||||||
>
|
>
|
||||||
<article
|
<article class="card featured-card">
|
||||||
class="card card-link"
|
<img
|
||||||
data-href="https://wordlesolver.umbra.mom"
|
class="project-preview"
|
||||||
>
|
src="images/wordle-solver-preview.webp"
|
||||||
<span class="card-date">9 Feb 2025</span>
|
alt="Wordle Solver showing a colour-coded guess and suggested next words"
|
||||||
<span class="project-status project-status--active"
|
width="1200"
|
||||||
>Active development</span
|
height="800"
|
||||||
>
|
loading="lazy"
|
||||||
<span class="card-title">Wordle Solver</span>
|
decoding="async"
|
||||||
<span class="card-desc"
|
/>
|
||||||
>Built after one too many missed 3-guess games. Now it plays
|
<div class="featured-card-content">
|
||||||
marginally better than me. Uses information theory to recommend
|
<a class="card-title" href="https://wordlesolver.umbra.mom">
|
||||||
the next best guess.</span
|
Wordle Solver
|
||||||
>
|
<span class="card-action">Open solver (external)</span>
|
||||||
<span class="card-source">
|
</a>
|
||||||
Source code:
|
<span class="card-desc"
|
||||||
<a
|
>Built after one too many missed 3-guess games. Now it plays
|
||||||
href="https://github.com/jayo60013/wordle-solver-api"
|
marginally better than me. Uses information theory to recommend
|
||||||
target="_blank"
|
the next best guess.</span
|
||||||
rel="noopener noreferrer"
|
|
||||||
>API</a
|
|
||||||
>
|
>
|
||||||
·
|
<div class="project-meta">
|
||||||
<a
|
<span class="card-date">9 Feb 2025</span>
|
||||||
href="https://github.com/jayo60013/wordle-solver-frontend"
|
<span class="project-status project-status--active"
|
||||||
target="_blank"
|
>Active development</span
|
||||||
rel="noopener noreferrer"
|
>
|
||||||
>Frontend</a
|
</div>
|
||||||
>
|
<span class="tag-row">
|
||||||
</span>
|
<span class="tag tag-yellow">Rust</span>
|
||||||
<span class="tag-row">
|
<span class="tag tag-teal">React</span>
|
||||||
<span class="tag tag-yellow">Rust</span>
|
<span class="tag tag-peach">TypeScript</span>
|
||||||
<span class="tag tag-teal">React</span>
|
</span>
|
||||||
<span class="tag tag-peach">TypeScript</span>
|
<span class="card-source">
|
||||||
</span>
|
Source code:
|
||||||
|
<a
|
||||||
|
href="https://github.com/jayo60013/wordle-solver-api"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>API</a
|
||||||
|
>
|
||||||
|
·
|
||||||
|
<a
|
||||||
|
href="https://github.com/jayo60013/wordle-solver-frontend"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>Frontend</a
|
||||||
|
>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
<article
|
<article class="card featured-card">
|
||||||
class="card card-link"
|
<img
|
||||||
data-href="https://crackthequote.umbra.mom"
|
class="project-preview"
|
||||||
>
|
src="images/crack-the-quote-preview.webp"
|
||||||
<span class="card-date">13 Apr 2024</span>
|
alt="Crack the Quote showing the daily cipher puzzle and letter controls"
|
||||||
<span class="project-status project-status--active"
|
width="1200"
|
||||||
>Active development</span
|
height="800"
|
||||||
>
|
loading="lazy"
|
||||||
<span class="card-title">Crack the Quote</span>
|
decoding="async"
|
||||||
<span class="card-desc"
|
/>
|
||||||
>A substitution-cipher puzzle game with a daily challenge. Built
|
<div class="featured-card-content">
|
||||||
for me and my family to play after getting bored with NYT
|
<a class="card-title" href="https://crackthequote.umbra.mom">
|
||||||
games.</span
|
Crack the Quote
|
||||||
>
|
<span class="card-action">Play game (external)</span>
|
||||||
<span class="card-source">
|
</a>
|
||||||
Source code:
|
<span class="card-desc"
|
||||||
<a
|
>A substitution-cipher puzzle game with a daily challenge. Built
|
||||||
href="https://github.com/jayo60013/crack-the-quote-api"
|
for me and my family to play after getting bored with NYT
|
||||||
target="_blank"
|
games.</span
|
||||||
rel="noopener noreferrer"
|
|
||||||
>API</a
|
|
||||||
>
|
>
|
||||||
</span>
|
<div class="project-meta">
|
||||||
<span class="tag-row">
|
<span class="card-date">13 Apr 2024</span>
|
||||||
<span class="tag tag-yellow">Rust</span>
|
<span class="project-status project-status--active"
|
||||||
<span class="tag tag-teal">React</span>
|
>Active development</span
|
||||||
<span class="tag tag-green">JavaScript</span>
|
>
|
||||||
</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
|
||||||
|
href="https://github.com/jayo60013/crack-the-quote-api"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>API</a
|
||||||
|
>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="projects-subheading reveal" style="transition-delay: 0.16s">
|
<h3 class="projects-subheading reveal" style="transition-delay: 0.16s">
|
||||||
<span>All projects</span>
|
All projects
|
||||||
</div>
|
</h3>
|
||||||
|
|
||||||
<fieldset
|
<fieldset
|
||||||
class="project-filters reveal"
|
class="project-filters reveal"
|
||||||
style="transition-delay: 0.18s"
|
style="transition-delay: 0.18s"
|
||||||
aria-label="Filter projects by tech stack"
|
aria-label="Filter projects by tech stack"
|
||||||
|
hidden
|
||||||
>
|
>
|
||||||
<button type="button" class="filter-chip active" data-filter="all">
|
<button
|
||||||
|
type="button"
|
||||||
|
class="filter-chip active"
|
||||||
|
data-filter="all"
|
||||||
|
aria-pressed="true"
|
||||||
|
>
|
||||||
All
|
All
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="filter-chip" data-filter="javascript">
|
<button
|
||||||
|
type="button"
|
||||||
|
class="filter-chip"
|
||||||
|
data-filter="javascript"
|
||||||
|
aria-pressed="false"
|
||||||
|
>
|
||||||
JavaScript
|
JavaScript
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="filter-chip" data-filter="python">
|
<button
|
||||||
|
type="button"
|
||||||
|
class="filter-chip"
|
||||||
|
data-filter="python"
|
||||||
|
aria-pressed="false"
|
||||||
|
>
|
||||||
Python
|
Python
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="filter-chip" data-filter="rust">
|
<button
|
||||||
|
type="button"
|
||||||
|
class="filter-chip"
|
||||||
|
data-filter="rust"
|
||||||
|
aria-pressed="false"
|
||||||
|
>
|
||||||
Rust
|
Rust
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="filter-chip" data-filter="react">
|
<button
|
||||||
|
type="button"
|
||||||
|
class="filter-chip"
|
||||||
|
data-filter="react"
|
||||||
|
aria-pressed="false"
|
||||||
|
>
|
||||||
React
|
React
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="filter-chip" data-filter="webassembly">
|
<button
|
||||||
|
type="button"
|
||||||
|
class="filter-chip"
|
||||||
|
data-filter="webassembly"
|
||||||
|
aria-pressed="false"
|
||||||
|
>
|
||||||
WebAssembly
|
WebAssembly
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="filter-chip" data-filter="p5">
|
<button
|
||||||
|
type="button"
|
||||||
|
class="filter-chip"
|
||||||
|
data-filter="p5"
|
||||||
|
aria-pressed="false"
|
||||||
|
>
|
||||||
p5.js
|
p5.js
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="filter-chip" data-filter="zig">
|
<button
|
||||||
|
type="button"
|
||||||
|
class="filter-chip"
|
||||||
|
data-filter="zig"
|
||||||
|
aria-pressed="false"
|
||||||
|
>
|
||||||
Zig
|
Zig
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="filter-chip" data-filter="cpp">
|
<button
|
||||||
|
type="button"
|
||||||
|
class="filter-chip"
|
||||||
|
data-filter="cpp"
|
||||||
|
aria-pressed="false"
|
||||||
|
>
|
||||||
C / C++
|
C / C++
|
||||||
</button>
|
</button>
|
||||||
</fieldset>
|
</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
|
<div
|
||||||
id="project-grid"
|
id="project-grid"
|
||||||
class="card-grid reveal"
|
class="card-grid reveal"
|
||||||
style="transition-delay: 0.2s"
|
style="transition-delay: 0.2s"
|
||||||
>
|
>
|
||||||
<article
|
<article class="card project-card" data-tech="python">
|
||||||
class="card project-card card-link"
|
|
||||||
data-tech="python"
|
|
||||||
data-href="https://pubquiz.umbra.mom"
|
|
||||||
>
|
|
||||||
<span class="card-date">03 Apr 2025</span>
|
<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"
|
<span class="card-desc"
|
||||||
>A Python dashboard to track our local pub quiz
|
>A Python dashboard to track our local pub quiz
|
||||||
performances.</span
|
performances.</span
|
||||||
@@ -424,13 +512,12 @@
|
|||||||
<span class="tag tag-red">Python</span>
|
<span class="tag tag-red">Python</span>
|
||||||
</span>
|
</span>
|
||||||
</article>
|
</article>
|
||||||
<article
|
<article class="card project-card" data-tech="cpp webassembly">
|
||||||
class="card project-card card-link"
|
|
||||||
data-tech="cpp webassembly"
|
|
||||||
data-href="projects/thin_ice/thin_ice.html"
|
|
||||||
>
|
|
||||||
<span class="card-date">14 Nov 2024</span>
|
<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"
|
<span class="card-desc"
|
||||||
>A small browser tile game inspired by one I played growing
|
>A small browser tile game inspired by one I played growing
|
||||||
up.</span
|
up.</span
|
||||||
@@ -441,13 +528,12 @@
|
|||||||
<span class="tag tag-peach">Raylib</span>
|
<span class="tag tag-peach">Raylib</span>
|
||||||
</span>
|
</span>
|
||||||
</article>
|
</article>
|
||||||
<article
|
<article class="card project-card" data-tech="zig webassembly">
|
||||||
class="card project-card card-link"
|
|
||||||
data-tech="zig webassembly"
|
|
||||||
data-href="projects/tsp/index.html"
|
|
||||||
>
|
|
||||||
<span class="card-date">20 Mar 2024</span>
|
<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"
|
<span class="card-desc"
|
||||||
>A nearest-neighbor + 2-opt visualiser. Wanted to revisit one of
|
>A nearest-neighbor + 2-opt visualiser. Wanted to revisit one of
|
||||||
my first projects with an optimised tech stack and
|
my first projects with an optimised tech stack and
|
||||||
@@ -459,13 +545,12 @@
|
|||||||
<span class="tag tag-teal">WebAssembly</span>
|
<span class="tag tag-teal">WebAssembly</span>
|
||||||
</span>
|
</span>
|
||||||
</article>
|
</article>
|
||||||
<article
|
<article class="card project-card" data-tech="rust">
|
||||||
class="card project-card card-link"
|
|
||||||
data-tech="rust"
|
|
||||||
data-href="projects/flocking/index.html"
|
|
||||||
>
|
|
||||||
<span class="card-date">31 Jan 2024</span>
|
<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"
|
<span class="card-desc"
|
||||||
>Rewrote the project from Go to Rust, compiling it to WebAssembly.
|
>Rewrote the project from Go to Rust, compiling it to WebAssembly.
|
||||||
Was fun being able to use DOM controls to affect the
|
Was fun being able to use DOM controls to affect the
|
||||||
@@ -476,16 +561,15 @@
|
|||||||
<span class="tag tag-peach">Raylib</span>
|
<span class="tag tag-peach">Raylib</span>
|
||||||
</span>
|
</span>
|
||||||
</article>
|
</article>
|
||||||
<article
|
<article class="card project-card" data-tech="javascript">
|
||||||
class="card project-card card-link"
|
|
||||||
data-tech="javascript"
|
|
||||||
data-href="https://samstoreymusic.com"
|
|
||||||
>
|
|
||||||
<span class="card-date">1 Nov 2023</span>
|
<span class="card-date">1 Nov 2023</span>
|
||||||
<span class="project-status project-status--active"
|
<span class="project-status project-status--active"
|
||||||
>Active development</span
|
>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"
|
<span class="card-desc"
|
||||||
>A website design and build for a friend working in music. Saving
|
>A website design and build for a friend working in music. Saving
|
||||||
him the hosting fees :)</span
|
him the hosting fees :)</span
|
||||||
@@ -495,13 +579,12 @@
|
|||||||
<span class="tag tag-green">JavaScript</span>
|
<span class="tag tag-green">JavaScript</span>
|
||||||
</span>
|
</span>
|
||||||
</article>
|
</article>
|
||||||
<article
|
<article class="card project-card" data-tech="javascript react">
|
||||||
class="card project-card card-link"
|
|
||||||
data-tech="javascript react"
|
|
||||||
data-href="projects/cellular_automata/index.html"
|
|
||||||
>
|
|
||||||
<span class="card-date">21 Jul 2023</span>
|
<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"
|
<span class="card-desc"
|
||||||
>After building Conway's Game of Life in p5.js as one of my first
|
>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
|
projects, I expanded it into a generic automata simulator.</span
|
||||||
@@ -511,13 +594,12 @@
|
|||||||
<span class="tag tag-green">JavaScript</span>
|
<span class="tag tag-green">JavaScript</span>
|
||||||
</span>
|
</span>
|
||||||
</article>
|
</article>
|
||||||
<article
|
<article class="card project-card" data-tech="cpp webassembly">
|
||||||
class="card project-card card-link"
|
|
||||||
data-tech="cpp webassembly"
|
|
||||||
data-href="projects/percolation/index.html"
|
|
||||||
>
|
|
||||||
<span class="card-date">5 Feb 2022</span>
|
<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"
|
<span class="card-desc"
|
||||||
>Watched a brilliant video by
|
>Watched a brilliant video by
|
||||||
<a
|
<a
|
||||||
@@ -534,13 +616,12 @@
|
|||||||
<span class="tag tag-teal">WebAssembly</span>
|
<span class="tag tag-teal">WebAssembly</span>
|
||||||
</span>
|
</span>
|
||||||
</article>
|
</article>
|
||||||
<article
|
<article class="card project-card" data-tech="javascript p5">
|
||||||
class="card project-card card-link"
|
|
||||||
data-tech="javascript p5"
|
|
||||||
data-href="projects/cubic_bezier_curve/index.html"
|
|
||||||
>
|
|
||||||
<span class="card-date">1 Oct 2021</span>
|
<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"
|
<span class="card-desc"
|
||||||
>An interactive animation for how cubic Bézier curves are
|
>An interactive animation for how cubic Bézier curves are
|
||||||
constructed. Inspired by this beautiful video by
|
constructed. Inspired by this beautiful video by
|
||||||
@@ -556,13 +637,12 @@
|
|||||||
<span class="tag tag-red">p5.js</span>
|
<span class="tag tag-red">p5.js</span>
|
||||||
</span>
|
</span>
|
||||||
</article>
|
</article>
|
||||||
<article
|
<article class="card project-card" data-tech="javascript p5">
|
||||||
class="card project-card card-link"
|
|
||||||
data-tech="javascript p5"
|
|
||||||
data-href="projects/marching_squares.html"
|
|
||||||
>
|
|
||||||
<span class="card-date">17 Sep 2021</span>
|
<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"
|
<span class="card-desc"
|
||||||
>I saw the 3D version of this algorithm in a game dev log and was
|
>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
|
inspired to create this 2D land-and-sea map generator.</span
|
||||||
@@ -572,13 +652,12 @@
|
|||||||
<span class="tag tag-red">p5.js</span>
|
<span class="tag tag-red">p5.js</span>
|
||||||
</span>
|
</span>
|
||||||
</article>
|
</article>
|
||||||
<article
|
<article class="card project-card" data-tech="javascript p5">
|
||||||
class="card project-card card-link"
|
|
||||||
data-tech="javascript p5"
|
|
||||||
data-href="projects/warping_lines.html"
|
|
||||||
>
|
|
||||||
<span class="card-date">2 Apr 2020</span>
|
<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"
|
<span class="card-desc"
|
||||||
>JavaScript visualisation of the Müller-Lyer optical
|
>JavaScript visualisation of the Müller-Lyer optical
|
||||||
illusion.</span
|
illusion.</span
|
||||||
@@ -588,13 +667,12 @@
|
|||||||
<span class="tag tag-red">p5.js</span>
|
<span class="tag tag-red">p5.js</span>
|
||||||
</span>
|
</span>
|
||||||
</article>
|
</article>
|
||||||
<article
|
<article class="card project-card" data-tech="javascript p5">
|
||||||
class="card project-card card-link"
|
|
||||||
data-tech="javascript p5"
|
|
||||||
data-href="projects/fourier_series.html"
|
|
||||||
>
|
|
||||||
<span class="card-date">27 Feb 2019</span>
|
<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"
|
<span class="card-desc"
|
||||||
>Builds a square wave from sine components to show Fourier series
|
>Builds a square wave from sine components to show Fourier series
|
||||||
in motion.</span
|
in motion.</span
|
||||||
@@ -604,13 +682,12 @@
|
|||||||
<span class="tag tag-red">p5.js</span>
|
<span class="tag tag-red">p5.js</span>
|
||||||
</span>
|
</span>
|
||||||
</article>
|
</article>
|
||||||
<article
|
<article class="card project-card" data-tech="javascript p5">
|
||||||
class="card project-card card-link"
|
|
||||||
data-tech="javascript p5"
|
|
||||||
data-href="projects/ellipse_construction.html"
|
|
||||||
>
|
|
||||||
<span class="card-date">23 Feb 2019</span>
|
<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"
|
<span class="card-desc"
|
||||||
>A geometric construction demo inspired by
|
>A geometric construction demo inspired by
|
||||||
<a
|
<a
|
||||||
@@ -626,13 +703,12 @@
|
|||||||
<span class="tag tag-red">p5.js</span>
|
<span class="tag tag-red">p5.js</span>
|
||||||
</span>
|
</span>
|
||||||
</article>
|
</article>
|
||||||
<article
|
<article class="card project-card" data-tech="javascript p5">
|
||||||
class="card project-card card-link"
|
|
||||||
data-tech="javascript p5"
|
|
||||||
data-href="projects/pi_approximation.html"
|
|
||||||
>
|
|
||||||
<span class="card-date">18 Mar 2018</span>
|
<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"
|
<span class="card-desc"
|
||||||
>A Monte Carlo approximation of pi using random sampling.</span
|
>A Monte Carlo approximation of pi using random sampling.</span
|
||||||
>
|
>
|
||||||
@@ -641,13 +717,12 @@
|
|||||||
<span class="tag tag-red">p5.js</span>
|
<span class="tag tag-red">p5.js</span>
|
||||||
</span>
|
</span>
|
||||||
</article>
|
</article>
|
||||||
<article
|
<article class="card project-card" data-tech="javascript p5">
|
||||||
class="card project-card card-link"
|
|
||||||
data-tech="javascript p5"
|
|
||||||
data-href="projects/oscillations_in_3d.html"
|
|
||||||
>
|
|
||||||
<span class="card-date">17 Dec 2017</span>
|
<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"
|
<span class="card-desc"
|
||||||
>A JavaScript recreation of a Bees and Bombs animation.</span
|
>A JavaScript recreation of a Bees and Bombs animation.</span
|
||||||
>
|
>
|
||||||
@@ -656,13 +731,12 @@
|
|||||||
<span class="tag tag-red">p5.js</span>
|
<span class="tag tag-red">p5.js</span>
|
||||||
</span>
|
</span>
|
||||||
</article>
|
</article>
|
||||||
<article
|
<article class="card project-card" data-tech="javascript p5">
|
||||||
class="card project-card card-link"
|
|
||||||
data-tech="javascript p5"
|
|
||||||
data-href="projects/maze_generation/index.html"
|
|
||||||
>
|
|
||||||
<span class="card-date">13 Nov 2017</span>
|
<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"
|
<span class="card-desc"
|
||||||
>Discovered the
|
>Discovered the
|
||||||
<a
|
<a
|
||||||
@@ -686,20 +760,19 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="projects-more-btn"
|
class="projects-more-btn"
|
||||||
aria-expanded="false"
|
aria-expanded="false"
|
||||||
|
aria-controls="project-grid"
|
||||||
|
hidden
|
||||||
>
|
>
|
||||||
See more
|
Show more projects
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="divider"></div>
|
|
||||||
|
|
||||||
<!-- ─── Contact ───────────────────────────────────────────────── -->
|
<!-- ─── Contact ───────────────────────────────────────────────── -->
|
||||||
<section id="contact">
|
<section id="contact">
|
||||||
<p class="section-label reveal">contact</p>
|
|
||||||
<div class="contact-inner reveal" style="transition-delay: 0.1s">
|
<div class="contact-inner reveal" style="transition-delay: 0.1s">
|
||||||
<div>
|
<div>
|
||||||
<h2>Let's <em>talk</em></h2>
|
<h2>Let's talk</h2>
|
||||||
<p>
|
<p>
|
||||||
I am currently employed and open to the right software engineering
|
I am currently employed and open to the right software engineering
|
||||||
opportunity, particularly backend or full-stack roles with
|
opportunity, particularly backend or full-stack roles with
|
||||||
@@ -708,31 +781,30 @@
|
|||||||
<p>For professional enquiries, connect with me on LinkedIn.</p>
|
<p>For professional enquiries, connect with me on LinkedIn.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="contact-links">
|
<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
|
<a
|
||||||
href="https://www.linkedin.com/in/jaygatward"
|
href="https://www.linkedin.com/in/jaygatward"
|
||||||
class="contact-link"
|
class="btn btn-primary contact-link"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="contact-link-icon contact-link-icon--linkedin"
|
class="contact-link-icon contact-link-icon--linkedin"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
></span>
|
></span>
|
||||||
<span class="contact-link-label">LinkedIn</span>
|
<span>Connect on LinkedIn</span>
|
||||||
<span class="contact-link-handle">jaygatward</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>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+2
-1
@@ -4,7 +4,8 @@
|
|||||||
"description": "",
|
"description": "",
|
||||||
"main": "404.js",
|
"main": "404.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"format": "prettier --write ."
|
"format": "prettier --write .",
|
||||||
|
"test": "node --test tests/*.test.js"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
const NAV_OFFSET = 120;
|
|
||||||
const DEFAULT_VISIBLE_PROJECTS = 9;
|
const DEFAULT_VISIBLE_PROJECTS = 9;
|
||||||
const INTERACTIVE_SELECTOR =
|
|
||||||
'a, button, input, textarea, select, summary, [role="button"]';
|
|
||||||
|
|
||||||
initRevealOnScroll();
|
|
||||||
initActiveNavLink();
|
|
||||||
initProjectCardLinks();
|
|
||||||
initProjectFilters();
|
initProjectFilters();
|
||||||
|
initMobileNav();
|
||||||
|
initActiveNavLink();
|
||||||
|
initRevealOnScroll();
|
||||||
|
|
||||||
function initRevealOnScroll() {
|
function initRevealOnScroll() {
|
||||||
const revealTargets = document.querySelectorAll('.reveal');
|
const revealTargets = document.querySelectorAll('.reveal');
|
||||||
if (revealTargets.length === 0) {
|
if (
|
||||||
|
revealTargets.length === 0 ||
|
||||||
|
!('IntersectionObserver' in window) ||
|
||||||
|
window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,91 +29,79 @@ function initRevealOnScroll() {
|
|||||||
{ threshold: 0.1, rootMargin: '0px 0px -40px 0px' }
|
{ 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() {
|
function initActiveNavLink() {
|
||||||
const sections = Array.from(document.querySelectorAll('section[id]'));
|
const sections = Array.from(document.querySelectorAll('section[id]'));
|
||||||
const navLinks = Array.from(document.querySelectorAll('.nav-links a'));
|
const navLinks = Array.from(
|
||||||
if (sections.length === 0 || navLinks.length === 0) {
|
document.querySelectorAll('.nav-links a, .mobile-nav-links a')
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
sections.length === 0 ||
|
||||||
|
navLinks.length === 0 ||
|
||||||
|
!('IntersectionObserver' in window)
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateActiveNavLink = () => {
|
const visibleSections = new Set();
|
||||||
let currentSectionId = '';
|
const updateActiveNavLink = (entries) => {
|
||||||
|
entries.forEach((entry) => {
|
||||||
sections.forEach((section) => {
|
if (entry.isIntersecting) {
|
||||||
if (window.scrollY >= section.offsetTop - NAV_OFFSET) {
|
visibleSections.add(entry.target);
|
||||||
currentSectionId = section.id;
|
} else {
|
||||||
|
visibleSections.delete(entry.target);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
const currentSection = sections
|
||||||
|
.filter((section) => visibleSections.has(section))
|
||||||
|
.at(-1);
|
||||||
|
if (!currentSection) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
navLinks.forEach((link) => {
|
navLinks.forEach((link) => {
|
||||||
link.classList.toggle(
|
const isActive = link.getAttribute('href') === `#${currentSection.id}`;
|
||||||
'is-active',
|
link.classList.toggle('is-active', isActive);
|
||||||
link.getAttribute('href') === `#${currentSectionId}`
|
if (isActive) {
|
||||||
);
|
link.setAttribute('aria-current', 'location');
|
||||||
|
} else {
|
||||||
|
link.removeAttribute('aria-current');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
let observer;
|
||||||
window.addEventListener('scroll', updateActiveNavLink, { passive: true });
|
const observeSections = () => {
|
||||||
updateActiveNavLink();
|
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() {
|
function initMobileNav() {
|
||||||
const cardLinks = document.querySelectorAll('.card-link[data-href]');
|
const menu = document.querySelector('.nav-menu');
|
||||||
if (cardLinks.length === 0) {
|
if (!menu) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
menu.querySelectorAll('a').forEach((link) => {
|
||||||
cardLinks.forEach((card) => {
|
link.addEventListener('click', () => {
|
||||||
card.setAttribute('role', 'link');
|
menu.open = false;
|
||||||
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.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === 'Escape' && menu.open) {
|
||||||
function shouldIgnoreCardActivation(card, target) {
|
menu.open = false;
|
||||||
if (!(target instanceof Element)) {
|
menu.querySelector('summary').focus();
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function initProjectFilters() {
|
function initProjectFilters() {
|
||||||
@@ -125,6 +114,11 @@ function initProjectFilters() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const seeMoreButton = document.querySelector('#projects-see-more');
|
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(
|
const techByCard = new Map(
|
||||||
projectCards.map((card) => [
|
projectCards.map((card) => [
|
||||||
card,
|
card,
|
||||||
@@ -136,36 +130,47 @@ function initProjectFilters() {
|
|||||||
let isExpanded = false;
|
let isExpanded = false;
|
||||||
|
|
||||||
const applyFilter = () => {
|
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) => {
|
projectCards.forEach((card) => {
|
||||||
const tech = techByCard.get(card) || [];
|
card.hidden = !shownSet.has(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;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!seeMoreButton) {
|
filterChips.forEach((chip) => {
|
||||||
return;
|
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) => {
|
filterChips.forEach((chip) => {
|
||||||
@@ -173,10 +178,6 @@ function initProjectFilters() {
|
|||||||
selectedFilter = chip.dataset.filter || 'all';
|
selectedFilter = chip.dataset.filter || 'all';
|
||||||
isExpanded = false;
|
isExpanded = false;
|
||||||
|
|
||||||
filterChips.forEach((otherChip) => {
|
|
||||||
otherChip.classList.toggle('active', otherChip === chip);
|
|
||||||
});
|
|
||||||
|
|
||||||
applyFilter();
|
applyFilter();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -185,8 +186,23 @@ function initProjectFilters() {
|
|||||||
seeMoreButton.addEventListener('click', () => {
|
seeMoreButton.addEventListener('click', () => {
|
||||||
isExpanded = !isExpanded;
|
isExpanded = !isExpanded;
|
||||||
applyFilter();
|
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();
|
applyFilter();
|
||||||
|
if (filters) {
|
||||||
|
filters.hidden = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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"/);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user