Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ jobs:
- name: Install dependencies
run: npm ci

- name: Validate coin-faces (≥2 headshots required)
run: ./scripts/validate-coin-faces.sh

- name: Build
run: hugo --minify

Expand Down
33 changes: 29 additions & 4 deletions layouts/partials/nav.html
Original file line number Diff line number Diff line change
@@ -1,18 +1,43 @@
{{ $frontImg := .Site.Params.profileImage | relURL }}
{{ $frontWebp := $frontImg | replaceRE `\.(png|jpg|jpeg)$` ".webp" }}
{{/* Discover coin-face images from the coin-faces folder */}}
{{ $coinFaces := slice }}
{{ range readDir "static/img/coin-faces" }}
{{ if not .IsDir }}
{{ $name := .Name }}
{{ $lower := $name | lower }}
{{ if or (strings.HasSuffix $lower ".png") (strings.HasSuffix $lower ".jpg") (strings.HasSuffix $lower ".jpeg") }}
{{ $coinFaces = $coinFaces | append (printf "/img/coin-faces/%s" $name) }}
{{ end }}
{{ end }}
{{ end }}
{{ $backImg := "" }}
{{ if gt (len $coinFaces) 0 }}
{{ $backImg = index $coinFaces 0 }}
{{ end }}
{{ $backWebp := $backImg | replaceRE `\.(png|jpg|jpeg)$` ".webp" }}
Comment on lines +14 to +18

Copilot AI Mar 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When static/img/coin-faces/ is empty, $backImg stays "" and $backWebp is also empty. The generated inline image-set(url('')...) can resolve to the current document URL and trigger an unintended request / broken background. Consider conditionally emitting the background-image only when a back image exists (or providing a safe placeholder).

Copilot uses AI. Check for mistakes.
{{ $isJpeg := findRE `(?i)\.(jpg|jpeg)$` $backImg }}
<nav class="navbar navbar-expand-lg navbar-dark bg-primary fixed-top" id="sideNav">
<a class="navbar-brand js-scroll-trigger" href="{{ "#about" | relURL }}">
<div class="d-block d-lg-none mobile-brand">
<div id="mobileCoin" class="mobile-brand__coin">
<div id="mobileCoin" class="mobile-brand__coin" data-coin-images='{{ $coinFaces | jsonify }}'>
<div class="coin-side coin-front" style="background-image: image-set(url('{{ $frontWebp }}') type('image/webp'), url('{{ $frontImg }}') type('image/png'))"></div>
<div class="coin-side coin-back" style="background-image: image-set(url('/img/me-photo.webp') type('image/webp'), url('/img/me-photo.png') type('image/png'))"></div>
{{ if $isJpeg }}
<div class="coin-side coin-back" style="background-image: image-set(url('{{ $backWebp }}') type('image/webp'), url('{{ $backImg }}') type('image/jpeg'))"></div>
{{ else }}
<div class="coin-side coin-back" style="background-image: image-set(url('{{ $backWebp }}') type('image/webp'), url('{{ $backImg }}') type('image/png'))"></div>

Copilot AI Mar 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The back-face inline style always uses type('image/png'), but $backImg can be .jpg/.jpeg (since those are included in $coinFaces). This makes the declared MIME type incorrect and can cause browsers to ignore that source in image-set(). Consider deriving the correct type from the extension (or omit the type(...) for the fallback URL).

Copilot uses AI. Check for mistakes.
{{ end }}
</div>
<span class="mobile-brand__name">{{ .Site.Params.firstName }} {{ .Site.Params.lastName }}</span>
</div>
<div class="d-none d-lg-block">
<div id="profileCoin" class="mx-auto mb-2">
<div id="profileCoin" class="mx-auto mb-2" data-coin-images='{{ $coinFaces | jsonify }}'>
<div class="coin-side coin-front" style="background-image: image-set(url('{{ $frontWebp }}') type('image/webp'), url('{{ $frontImg }}') type('image/png'))"></div>
<div class="coin-side coin-back" style="background-image: image-set(url('/img/me-photo.webp') type('image/webp'), url('/img/me-photo.png') type('image/png'))"></div>
{{ if $isJpeg }}
<div class="coin-side coin-back" style="background-image: image-set(url('{{ $backWebp }}') type('image/webp'), url('{{ $backImg }}') type('image/jpeg'))"></div>
{{ else }}
<div class="coin-side coin-back" style="background-image: image-set(url('{{ $backWebp }}') type('image/webp'), url('{{ $backImg }}') type('image/png'))"></div>

Copilot AI Mar 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The back-face inline style always uses type('image/png'), but $backImg can be .jpg/.jpeg (since those are included in $coinFaces). This makes the declared MIME type incorrect and can cause browsers to ignore that source in image-set(). Consider deriving the correct type from the extension (or omit the type(...) for the fallback URL).

Copilot uses AI. Check for mistakes.
{{ end }}
</div>
</div>
</a>
Expand Down
28 changes: 28 additions & 0 deletions scripts/validate-coin-faces.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
#!/usr/bin/env bash
# ───────────────────────────────────────────────────────────
# Validate coin-faces: ensure at least 2 headshot images
# exist so the coin flip never shows the same photo twice.
#
# Usage:
# ./scripts/validate-coin-faces.sh # exits 1 on failure
# git hook (pre-push / pre-commit) # link or call this script
# CI step # runs before hugo build
# ───────────────────────────────────────────────────────────

set -euo pipefail

COIN_DIR="static/img/coin-faces"
MIN_REQUIRED=2

# Count only source images (png/jpg/jpeg) — not webp companions.
count=$(find "$COIN_DIR" -maxdepth 1 -type f \( -iname '*.png' -o -iname '*.jpg' -o -iname '*.jpeg' \) | wc -l)
count=$((count + 0)) # trim whitespace from wc

if (( count < MIN_REQUIRED )); then
echo "❌ coin-faces validation failed"
echo " Found $count image(s) in $COIN_DIR (need at least $MIN_REQUIRED)."
echo " Add more headshot .png/.jpg files so the coin never repeats."
exit 1
fi

echo "✅ coin-faces OK — $count image(s) found (≥ $MIN_REQUIRED required)"
Binary file added static/img/coin-faces/me-alt.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added static/img/coin-faces/me-alt.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
File renamed without changes
File renamed without changes
52 changes: 52 additions & 0 deletions static/js/coin-flip.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,55 @@ const coin = document.getElementById("profileCoin");
const mobileCoin = document.getElementById("mobileCoin");
const frontFace = coin?.querySelector(".coin-front");
const backFace = coin?.querySelector(".coin-back");
const mobileBackFace = mobileCoin?.querySelector(".coin-back");

let flipping = false;
let showingReal = true; // front = real, back = generative

// Coin face image rotation — images discovered at build time by Hugo
const coinImages = JSON.parse(coin?.dataset?.coinImages || '[]');
let currentBackImage = coinImages.length > 0 ? coinImages[0] : null;

Comment on lines +13 to +16

Copilot AI Mar 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR adds new behavior (parsing data-coin-images, randomizing the back face on load, and swapping it after flips), but the existing Puppeteer tests don’t appear to assert any of it. Consider adding an e2e/visual test that validates the data-coin-images JSON and that the .coin-back background-image changes after flipping back to the front (and remains stable for the single-image case).

Copilot uses AI. Check for mistakes.
if (coinImages.length < 2) {
console.warn('[coin-flip] fewer than 2 coin-face images — back-to-back repeats are unavoidable.');
}

function randomRange(min, max) {
return Math.random() * (max - min) + min;
}

/**
* Pick a random image from the pool, different from the current one if possible.
*/
function pickRandomImage(excludePath) {
if (coinImages.length === 0) return null;
if (coinImages.length === 1) return coinImages[0];
let pick;
do {
pick = coinImages[Math.floor(Math.random() * coinImages.length)];
} while (pick === excludePath);
return pick;
}

/**
* Update a coin face's background-image with WebP + fallback via image-set().
*/
function setFaceImage(face, imgPath) {
if (!face || !imgPath) return;
const webpPath = imgPath.replace(/\.(png|jpg|jpeg)$/, '.webp');

Copilot AI Mar 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

setFaceImage() derives webpPath via a case-sensitive \.(png|jpg|jpeg)$ replace. Because the Hugo template includes files by checking a lowercased name, filenames like ME.PNG would be accepted but the .webp path would not be generated correctly here. Consider making the replace regex case-insensitive (and/or normalizing the emitted paths) so mixed-case extensions work reliably.

Suggested change
const webpPath = imgPath.replace(/\.(png|jpg|jpeg)$/, '.webp');
const webpPath = imgPath.replace(/\.(png|jpg|jpeg)$/i, '.webp');

Copilot uses AI. Check for mistakes.
const mimeType = /\.jpe?g$/i.test(imgPath) ? 'image/jpeg' : 'image/png';
face.style.backgroundImage =
`image-set(url('${webpPath}') type('image/webp'), url('${imgPath}') type('${mimeType}'))`;
}

// Randomise the initial back face on every page load
if (coinImages.length > 0) {
const initialImg = pickRandomImage(null);
currentBackImage = initialImg;
setFaceImage(backFace, initialImg);
setFaceImage(mobileBackFace, initialImg);
}

function flipCoin() {
if (!coin || flipping) return;
flipping = true;
Expand All @@ -33,6 +74,17 @@ function flipCoin() {
const lockDuration = (window.FIELD && window.FIELD.prefersReducedMotion()) ? 0 : 600;
setTimeout(() => {
flipping = false;

// When the back face is hidden again, swap it to a new random image
// so the next reveal shows a fresh picture.
if (coinImages.length > 1 && showingReal) {
const newImg = pickRandomImage(currentBackImage);
if (newImg) {
currentBackImage = newImg;
setFaceImage(backFace, newImg);
setFaceImage(mobileBackFace, newImg);
}
}
}, lockDuration);
}

Expand Down