From d176af72525ec0c6cd136c350bd7dbc62d886385 Mon Sep 17 00:00:00 2001 From: Jon Froehlich Date: Wed, 24 Jun 2026 15:19:23 -0700 Subject: [PATCH 1/3] Footer logo easter egg: hover/tap to morph the logo apart & back (#1397) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Progressively enhances the static footer logo into an interactive driven by makeabilitylab/js (pinned @0.6.0). Desktop: cursor X across the logo maps to a linear "reverse explosion" — left = exploded, right = assembled — easing back to assembled on mouse-leave. Touch: a tap plays a one-shot explode -> reassemble (avoids fighting page scroll). The logo renders all-white (triangles, M/L outlines, and the MAKEABILITY LAB wordmark) to match the white PNG it replaces on the blue footer. Honors prefers-reduced-motion and missing-canvas: in those cases the static is left untouched. The injected canvas carries role="img" + aria-label so its accessible name matches the logo. Co-Authored-By: Claude Opus 4.8 (1M context) --- website/static/website/css/footer.css | 15 + .../static/website/js/makelab-footer-logo.js | 281 ++++++++++++++++++ website/templates/website/base.html | 10 +- 3 files changed, 305 insertions(+), 1 deletion(-) create mode 100644 website/static/website/js/makelab-footer-logo.js diff --git a/website/static/website/css/footer.css b/website/static/website/css/footer.css index eda9dc33..8cd27722 100644 --- a/website/static/website/css/footer.css +++ b/website/static/website/css/footer.css @@ -160,6 +160,21 @@ margin-right: auto; } +/* Make the logo link a block so the injected easter-egg (#1397) + inherits a definite width from the column instead of collapsing to the + inline content width. */ +.makelab-footer-logo-link { + display: block; +} + +/* The canvas reuses .makelab-footer-logo for responsive sizing/centering; + it just needs to render as a block and advertise its interactivity. */ +.makelab-footer-logo-canvas { + display: block; + width: 100%; + cursor: pointer; +} + /* ============================================================================ SOCIAL LINKS - Desktop diff --git a/website/static/website/js/makelab-footer-logo.js b/website/static/website/js/makelab-footer-logo.js new file mode 100644 index 00000000..7d0f6c8d --- /dev/null +++ b/website/static/website/js/makelab-footer-logo.js @@ -0,0 +1,281 @@ +/** + * Footer Logo Easter Egg (#1397) + * + * Progressive enhancement: replaces the static footer Makeability Lab logo + * with an interactive driven by the makeabilitylab/js library + * (the same lib that powers the landing-page logo and the 404 leaf-fall). + * + * Interaction: + * - Desktop (hover-capable, fine pointer): moving the cursor across the logo + * maps horizontal position to the morph amount along a linear "reverse + * explosion" path — left edge = fully exploded, right edge = fully + * assembled. On mouse-leave the logo eases back to assembled. + * - Touch (no hover / coarse pointer): a single tap plays a one-shot + * explode -> reassemble animation. (Finger-tracking horizontally would + * fight page scroll near the footer, so we use a tap instead.) The tap is + * consumed by the easter egg, so on touch the footer logo does NOT also + * navigate home — the navbar logo and footer links still do. + * + * Accessibility: + * - Honors prefers-reduced-motion: when set (or when canvas is unsupported), + * we leave the static in place — no canvas, no animation. + * - The injected canvas carries role="img" + aria-label so its accessible + * name matches the logo it replaces. + * + * The library is pinned to a released tag (@0.6.0), not @main, so the easter + * egg can't break when the library's bleeding edge changes. + * + * CDN cache can be purged at https://www.jsdelivr.com/tools/purge + * + * @author Jon Froehlich (with Claude Code) + */ + +import { + MakeabilityLabLogo, + MakeabilityLabLogoMorpher, + linearPath, +} from 'https://cdn.jsdelivr.net/gh/makeabilitylab/js@0.6.0/dist/makelab.logo.js'; + +// ============================================================================= +// Configuration +// ============================================================================= + +const DPR = window.devicePixelRatio || 1; +// Logical triangle size before the logo is scaled to fit the canvas width. +const TRIANGLE_SIZE = 70; +// Keep the assembled logo within this fraction of the canvas width so the +// exploded triangles still have room to scatter without clipping. +const LOGO_WIDTH_FRACTION = 0.9; +// Extra vertical breathing room (logical px) added above/below the logo. +const VERTICAL_PADDING = 6; +// The logo renders white-on-dark to match the footer's white wordmark PNG. +const LOGO_COLOR = 'white'; +// Faint white for the exploded (scattered) triangles before they assemble. +const START_FILL_COLOR = 'rgba(255, 255, 255, 0.35)'; +// Fraction of the canvas width treated as dead margin on each side, so the +// fully-exploded / fully-assembled states are reachable before the very edge. +const EDGE_MARGIN_FRACTION = 0.08; +// One-shot tap animation timings (ms). +const TAP_EXPLODE_MS = 450; +const TAP_ASSEMBLE_MS = 750; +// Ease-back-to-assembled duration when the cursor leaves (ms). +const LEAVE_MS = 500; + +// ============================================================================= +// Easing +// ============================================================================= + +const easeOutCubic = (t) => 1 - Math.pow(1 - t, 3); +const easeInOutCubic = (t) => + t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; +const clamp = (v, lo, hi) => Math.min(Math.max(v, lo), hi); + +// ============================================================================= +// Reduced motion (#1294) +// ============================================================================= + +function prefersReducedMotion() { + if (window.MakeLab && window.MakeLab.prefersReducedMotion) { + return window.MakeLab.prefersReducedMotion(); + } + return !!( + window.matchMedia && + window.matchMedia('(prefers-reduced-motion: reduce)').matches + ); +} + +// ============================================================================= +// State +// ============================================================================= + +let canvas = null; +let ctx = null; +let link = null; +let morpher = null; +let logicalWidth = 0; +let logicalHeight = 0; +let lastCssWidth = -1; +// 1 = fully assembled (the resting state); 0 = fully exploded. +let currentLerp = 1; +let rafId = null; + +// ============================================================================= +// Rendering +// ============================================================================= + +/** + * Applies the white-on-dark color scheme. The M/L outlines and the wordmark + * label are drawn from dedicated properties (not setColors), and the morpher + * keeps two logo instances (the hidden target + the animated one), so we whiten + * every channel on both to get a clean all-white logo on the blue footer. + */ +function applyColors() { + for (const logo of [morpher.makeLabLogo, morpher.makeLabLogoAnimated]) { + logo.setColors(LOGO_COLOR, LOGO_COLOR); + logo.mOutlineColor = LOGO_COLOR; + logo.lOutlineColor = LOGO_COLOR; + logo.setLTriangleStrokeColor(LOGO_COLOR); + logo.labelColor = LOGO_COLOR; + } +} + +function render() { + if (!morpher) return; + // Clear to transparent so the footer's dark background shows through. + ctx.clearRect(0, 0, logicalWidth, logicalHeight); + morpher.update(currentLerp); + morpher.draw(ctx); +} + +/** + * (Re)sizes the canvas to its CSS box and lays out the logo within it. + * Guarded so the ResizeObserver (which also fires when we set the height) + * only does real work when the canvas *width* actually changes. + * @param {boolean} force - relayout even if the width is unchanged. + */ +function layout(force) { + const cssWidth = Math.round( + canvas.clientWidth || canvas.getBoundingClientRect().width || 160 + ); + if (!force && cssWidth === lastCssWidth) return; + lastCssWidth = cssWidth; + + // Scale the assembled logo to fit the canvas width (capped at natural size). + const naturalLogoWidth = MakeabilityLabLogo.numCols * TRIANGLE_SIZE; + const targetLogoWidth = Math.min(naturalLogoWidth, cssWidth * LOGO_WIDTH_FRACTION); + morpher.setLogoSize(targetLogoWidth); + + // makeLabLogo.height already accounts for the wordmark label below the logo. + const cssHeight = Math.ceil(morpher.makeLabLogo.height + VERTICAL_PADDING * 2); + logicalWidth = cssWidth; + logicalHeight = cssHeight; + + canvas.style.height = cssHeight + 'px'; + canvas.width = cssWidth * DPR; + canvas.height = cssHeight * DPR; + ctx.setTransform(DPR, 0, 0, DPR, 0, 0); + + morpher.centerLogo(logicalWidth, logicalHeight); + morpher.reset(logicalWidth, logicalHeight); + applyColors(); // reset() can repopulate triangle colors; reassert ours. + render(); +} + +// ============================================================================= +// Animation helpers +// ============================================================================= + +function cancelTween() { + if (rafId) { + cancelAnimationFrame(rafId); + rafId = null; + } +} + +/** + * Tweens currentLerp to `target` over `duration` ms using `easing`, then calls + * `onDone`. Uses the rAF timestamp for timing (no Date.now()). + */ +function tweenTo(target, duration, easing, onDone) { + cancelTween(); + const startLerp = currentLerp; + const delta = target - startLerp; + let startTs = null; + function step(ts) { + if (startTs === null) startTs = ts; + const t = duration > 0 ? Math.min((ts - startTs) / duration, 1) : 1; + currentLerp = startLerp + delta * easing(t); + render(); + if (t < 1) { + rafId = requestAnimationFrame(step); + } else { + rafId = null; + if (onDone) onDone(); + } + } + rafId = requestAnimationFrame(step); +} + +// ============================================================================= +// Interaction handlers +// ============================================================================= + +// Desktop: track the cursor's X position across the logo. +function onPointerEnter() { + // Re-scatter so each hover gets a fresh explosion pattern. + if (morpher) morpher.reset(logicalWidth, logicalHeight); +} + +function onPointerMove(e) { + cancelTween(); + const rect = canvas.getBoundingClientRect(); + const margin = rect.width * EDGE_MARGIN_FRACTION; + const usable = rect.width - 2 * margin; + const x = e.clientX - rect.left; + // Left edge -> 0 (exploded); right edge -> 1 (assembled). + currentLerp = usable > 0 ? clamp((x - margin) / usable, 0, 1) : 1; + render(); +} + +function onPointerLeave() { + tweenTo(1, LEAVE_MS, easeOutCubic); +} + +// Touch: tap plays a one-shot explode -> reassemble. +function onTap(e) { + // The easter egg owns this tap; don't also navigate home. + e.preventDefault(); + if (rafId) return; // ignore taps mid-animation + morpher.reset(logicalWidth, logicalHeight); + tweenTo(0, TAP_EXPLODE_MS, easeOutCubic, () => + tweenTo(1, TAP_ASSEMBLE_MS, easeInOutCubic) + ); +} + +// ============================================================================= +// Setup +// ============================================================================= + +function enhance(img) { + link = img.closest('a'); + if (!link) return; + + canvas = document.createElement('canvas'); + // Reuse the img's sizing class so the canvas inherits the same responsive + // max-width (incl. the #1395 mobile shrink) and centering. + canvas.className = 'makelab-footer-logo makelab-footer-logo-canvas'; + canvas.setAttribute('role', 'img'); + canvas.setAttribute('aria-label', 'Makeability Lab'); + ctx = canvas.getContext('2d'); + if (!ctx) return; // no 2D context -> keep the static img + + img.insertAdjacentElement('afterend', canvas); + img.style.display = 'none'; + + morpher = new MakeabilityLabLogoMorpher(0, 0, TRIANGLE_SIZE, START_FILL_COLOR); + morpher.setPath(linearPath()); // explicit linear "reverse explosion" trajectory + applyColors(); + layout(true); + + const canHover = window.matchMedia('(hover: hover) and (pointer: fine)').matches; + if (canHover) { + // Hover drives the morph; a normal click still navigates home. + canvas.addEventListener('pointerenter', onPointerEnter); + canvas.addEventListener('pointermove', onPointerMove); + canvas.addEventListener('pointerleave', onPointerLeave); + } else { + // Touch: tap plays the animation (and suppresses navigation). + link.addEventListener('click', onTap); + } + + new ResizeObserver(() => layout(false)).observe(canvas); +} + +const footerImg = document.getElementById('makelab-footer-logo-img'); +if ( + footerImg && + !prefersReducedMotion() && + !!document.createElement('canvas').getContext +) { + enhance(footerImg); +} diff --git a/website/templates/website/base.html b/website/templates/website/base.html index f5645fef..3a52e895 100644 --- a/website/templates/website/base.html +++ b/website/templates/website/base.html @@ -140,6 +140,7 @@ + {% block external_scripts %}{% endblock %} @@ -392,9 +393,16 @@ From a24299ff643681670ad855b0d2637e296475dda3 Mon Sep 17 00:00:00 2001 From: Jon Froehlich Date: Wed, 24 Jun 2026 17:44:12 -0700 Subject: [PATCH 2/3] Footer logo egg: fix no-op explosion, track footer row, restore black lines (#1397) Three fixes after live testing: - Explosion never fired. reset() snapshots the logo's *current* positions as the scatter origin, so calling it from the assembled resting state (which onPointerEnter/onTap both did) collapsed the morph to nothing. The single reset() in layout() is sufficient; update() interpolates against it. Removed the hover/tap resets (and the now-empty pointerenter handler). - Drive the morph from the cursor's X anywhere across the footer's first row (.makelab-footer-row), not just over the small logo, so the whole blue band is interactive. Falls back to the logo if the row isn't found. - Restore the canonical look at the assembled end state: white triangle fills with black facet/outline lines (was a flat white silhouette). The wordmark label stays white to keep AA contrast on the blue (#1565A7) background. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../static/website/js/makelab-footer-logo.js | 60 ++++++++++++------- 1 file changed, 37 insertions(+), 23 deletions(-) diff --git a/website/static/website/js/makelab-footer-logo.js b/website/static/website/js/makelab-footer-logo.js index 7d0f6c8d..8b76d9ab 100644 --- a/website/static/website/js/makelab-footer-logo.js +++ b/website/static/website/js/makelab-footer-logo.js @@ -48,8 +48,13 @@ const TRIANGLE_SIZE = 70; const LOGO_WIDTH_FRACTION = 0.9; // Extra vertical breathing room (logical px) added above/below the logo. const VERTICAL_PADDING = 6; -// The logo renders white-on-dark to match the footer's white wordmark PNG. -const LOGO_COLOR = 'white'; +// The assembled logo reads as the canonical Makeability Lab mark on the blue +// footer: white triangle fills with black facet/outline lines. The wordmark +// label stays white so it keeps AA contrast on the blue background (black text +// on #1565A7 is only ~3.4:1). +const LOGO_FILL_COLOR = 'white'; +const LOGO_LINE_COLOR = 'black'; +const LOGO_LABEL_COLOR = 'white'; // Faint white for the exploded (scattered) triangles before they assemble. const START_FILL_COLOR = 'rgba(255, 255, 255, 0.35)'; // Fraction of the canvas width treated as dead margin on each side, so the @@ -91,6 +96,10 @@ function prefersReducedMotion() { let canvas = null; let ctx = null; let link = null; +// The element whose width the cursor X is mapped across to drive the morph. +// We use the whole footer's first row so the logo responds to the cursor +// anywhere in the blue band, not just directly over the small logo. +let hoverRegion = null; let morpher = null; let logicalWidth = 0; let logicalHeight = 0; @@ -104,18 +113,18 @@ let rafId = null; // ============================================================================= /** - * Applies the white-on-dark color scheme. The M/L outlines and the wordmark - * label are drawn from dedicated properties (not setColors), and the morpher - * keeps two logo instances (the hidden target + the animated one), so we whiten - * every channel on both to get a clean all-white logo on the blue footer. + * Applies the color scheme to both internal logos (the hidden target used for + * the outline/label overlay, and the animated morphing one). The M/L outlines + * and the wordmark label are drawn from dedicated properties, not setColors, so + * each is set explicitly: white fills, black facet/outline lines, white label. */ function applyColors() { for (const logo of [morpher.makeLabLogo, morpher.makeLabLogoAnimated]) { - logo.setColors(LOGO_COLOR, LOGO_COLOR); - logo.mOutlineColor = LOGO_COLOR; - logo.lOutlineColor = LOGO_COLOR; - logo.setLTriangleStrokeColor(LOGO_COLOR); - logo.labelColor = LOGO_COLOR; + logo.setColors(LOGO_FILL_COLOR, LOGO_LINE_COLOR); // fill, facet strokes + logo.mOutlineColor = LOGO_LINE_COLOR; + logo.lOutlineColor = LOGO_LINE_COLOR; + logo.setLTriangleStrokeColor(LOGO_LINE_COLOR); + logo.labelColor = LOGO_LABEL_COLOR; } } @@ -201,14 +210,16 @@ function tweenTo(target, duration, easing, onDone) { // ============================================================================= // Desktop: track the cursor's X position across the logo. -function onPointerEnter() { - // Re-scatter so each hover gets a fresh explosion pattern. - if (morpher) morpher.reset(logicalWidth, logicalHeight); -} - +// NOTE: we deliberately do NOT call morpher.reset() on hover/tap. reset() +// snapshots the logo's *current* positions as the scatter origin, so resetting +// from the assembled resting state collapses the explosion to nothing. The +// single reset() in layout() establishes the scatter once; update() then +// interpolates against it, so every hover/tap explodes correctly. function onPointerMove(e) { cancelTween(); - const rect = canvas.getBoundingClientRect(); + // Map the cursor's X across the whole footer row (hoverRegion), not just the + // logo, so sweeping anywhere in the blue band drives the morph. + const rect = hoverRegion.getBoundingClientRect(); const margin = rect.width * EDGE_MARGIN_FRACTION; const usable = rect.width - 2 * margin; const x = e.clientX - rect.left; @@ -226,7 +237,6 @@ function onTap(e) { // The easter egg owns this tap; don't also navigate home. e.preventDefault(); if (rafId) return; // ignore taps mid-animation - morpher.reset(logicalWidth, logicalHeight); tweenTo(0, TAP_EXPLODE_MS, easeOutCubic, () => tweenTo(1, TAP_ASSEMBLE_MS, easeInOutCubic) ); @@ -257,14 +267,18 @@ function enhance(img) { applyColors(); layout(true); + // Drive the morph from the cursor anywhere in the footer's first row; fall + // back to the logo itself if that row can't be found. + hoverRegion = link.closest('.makelab-footer-row') || canvas; + const canHover = window.matchMedia('(hover: hover) and (pointer: fine)').matches; if (canHover) { - // Hover drives the morph; a normal click still navigates home. - canvas.addEventListener('pointerenter', onPointerEnter); - canvas.addEventListener('pointermove', onPointerMove); - canvas.addEventListener('pointerleave', onPointerLeave); + // Hover anywhere in the row drives the morph; a normal click on the logo + // still navigates home. + hoverRegion.addEventListener('pointermove', onPointerMove); + hoverRegion.addEventListener('pointerleave', onPointerLeave); } else { - // Touch: tap plays the animation (and suppresses navigation). + // Touch: tap the logo plays the animation (and suppresses navigation). link.addEventListener('click', onTap); } From 3cccce3fb3686a1602d6a6c7d61f69ee9b9a2b56 Mon Sep 17 00:00:00 2001 From: Jon Froehlich Date: Wed, 24 Jun 2026 17:55:37 -0700 Subject: [PATCH 3/3] Footer logo egg: keep the L's interior clean (no black facet lines) (#1397) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Explicitly disable areLTriangleStrokesVisible on both internal logos so the triangles inside the L never get facet strokes — the L stays a clean white shape while the M keeps its black facet lines. Set after setColors(), which would otherwise re-enable every triangle's stroke. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../static/website/js/makelab-footer-logo.js | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/website/static/website/js/makelab-footer-logo.js b/website/static/website/js/makelab-footer-logo.js index 8b76d9ab..19132d64 100644 --- a/website/static/website/js/makelab-footer-logo.js +++ b/website/static/website/js/makelab-footer-logo.js @@ -113,17 +113,24 @@ let rafId = null; // ============================================================================= /** - * Applies the color scheme to both internal logos (the hidden target used for - * the outline/label overlay, and the animated morphing one). The M/L outlines - * and the wordmark label are drawn from dedicated properties, not setColors, so - * each is set explicitly: white fills, black facet/outline lines, white label. + * Applies the color scheme to both internal logos (the hidden target whose + * triangle colors the morpher snapshots as the assembled end state, and the + * animated one). White fills, black facet/outline lines, white label. The L's + * inner facet strokes are made transparent so the L reads as a clean white + * shape. NOTE: the morpher snapshots stroke *color* into its frozen draw + * triangles but not stroke *visibility*, so we recolor (not hide) the L + * strokes; and applyColors must run before the first reset() so the snapshot + * captures these colors. */ function applyColors() { for (const logo of [morpher.makeLabLogo, morpher.makeLabLogoAnimated]) { logo.setColors(LOGO_FILL_COLOR, LOGO_LINE_COLOR); // fill, facet strokes logo.mOutlineColor = LOGO_LINE_COLOR; logo.lOutlineColor = LOGO_LINE_COLOR; - logo.setLTriangleStrokeColor(LOGO_LINE_COLOR); + // Never turn on the facet strokes of the triangles *inside* the L — keep + // the L a clean white shape (only the M shows black facet lines). Set after + // setColors(), which would otherwise re-enable every triangle's stroke. + logo.areLTriangleStrokesVisible = false; logo.labelColor = LOGO_LABEL_COLOR; } }