diff --git a/README.md b/README.md
index bc229a0b..8286fffd 100644
--- a/README.md
+++ b/README.md
@@ -117,10 +117,7 @@ The full list lives in [Features](docs/FEATURES.md).
ID wallet, dark mode. Tilt it, flip it, verify it.
-
-
-
404. This page isn't on the programme, but the golden retriever is.
-
+
diff --git a/app/globals.css b/app/globals.css
index b31f5895..aa7ba88f 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -390,38 +390,27 @@
transition: transform 520ms cubic-bezier(0.34, 1.4, 0.64, 1), opacity 320ms ease-out;
}
-/* 404 golden retriever (components/feedback/NotFoundPet.tsx). Motion is driven by data-state;
- the reduced-motion rule above stills every loop. */
+/* Patch on the 404 page (components/feedback/NotFoundPet.tsx). Each direction is one sprite sheet of
+ the design system's eight-frame walk cycle (heading left uses the approved left-facing sheet; Patch
+ is never flipped). Frame N sits at N/7 of the sheet. Motion is driven by data-state, data-facing
+ and data-happy; the reduced-motion rule above stills every loop. */
.yy-pet { will-change: transform; }
-.yy-pet-body { transition: transform 160ms ease-out; }
-.yy-pet-leg { transform-box: fill-box; transform-origin: 50% 0; }
-.yy-pet-tail { transform-box: fill-box; transform-origin: 100% 100%; animation: yy-pet-wag 1.4s ease-in-out infinite; }
-.yy-pet-head { transform-box: fill-box; transform-origin: 30% 80%; }
-.yy-pet-pupils { transform: translate(var(--look-x, 0px), var(--look-y, 0px)); transition: transform 120ms ease-out; }
-.yy-pet-eyes { transform-box: fill-box; transform-origin: 50% 50%; animation: yy-pet-blink 5.2s infinite; }
-.yy-pet-sleepy-eyes { opacity: 0; }
-
-.yy-pet[data-state="walk"] .yy-pet-leg-a,
-.yy-pet[data-state="run"] .yy-pet-leg-a { animation: yy-pet-step 0.32s ease-in-out infinite alternate; }
-.yy-pet[data-state="walk"] .yy-pet-leg-b,
-.yy-pet[data-state="run"] .yy-pet-leg-b { animation: yy-pet-step 0.32s ease-in-out infinite alternate-reverse; }
-.yy-pet[data-state="run"] .yy-pet-leg { animation-duration: 0.18s; }
-.yy-pet[data-state="walk"] .yy-pet-torso,
-.yy-pet[data-state="run"] .yy-pet-torso { animation: yy-pet-bob 0.32s ease-in-out infinite alternate; }
-.yy-pet[data-state="run"] .yy-pet-torso { animation-duration: 0.18s; }
-.yy-pet[data-state="run"] .yy-pet-tail { animation-duration: 0.3s; }
-.yy-pet[data-state="sit"] .yy-pet-head { animation: yy-pet-tilt 4s ease-in-out infinite; }
-.yy-pet[data-happy] .yy-pet-tail { animation: yy-pet-wag 0.18s ease-in-out infinite; }
-.yy-pet[data-happy] .yy-pet-torso { animation: yy-pet-bob 0.22s ease-in-out 4 alternate; }
-
-.yy-pet[data-state="sleep"] .yy-pet-eyes { opacity: 0; animation: none; }
-.yy-pet[data-state="sleep"] .yy-pet-sleepy-eyes { opacity: 1; }
-.yy-pet[data-state="sleep"] .yy-pet-torso { animation: yy-pet-breathe 3s ease-in-out infinite; transform-box: fill-box; transform-origin: 50% 100%; }
-.yy-pet[data-state="sleep"] .yy-pet-tail { animation: none; }
+.yy-pet-sprite {
+ aspect-ratio: 256 / 176;
+ width: 100%;
+ background: url("/brand/patch/patch-walk-right.webp") 0 0 / 800% 100% no-repeat;
+}
+.yy-pet[data-facing="left"] .yy-pet-sprite { background-image: url("/brand/patch/patch-walk-left.webp"); }
+.yy-pet[data-state="walk"] .yy-pet-sprite { animation: yy-pet-walk 0.96s steps(8) infinite; }
+.yy-pet[data-state="run"] .yy-pet-sprite { animation: yy-pet-walk 0.48s steps(8) infinite; }
+/* Asleep or patted: frame 6, Patch's eyes closed happily as drawn on the walk sheet. */
+.yy-pet[data-state="sleep"] .yy-pet-sprite,
+.yy-pet[data-happy] .yy-pet-sprite { animation: none; background-position: calc(500% / 7) 0; }
+.yy-pet[data-happy] { animation: yy-pet-bounce 0.22s ease-in-out 4 alternate; }
.yy-pet-zzz {
position: absolute;
- right: 4px;
+ right: 10%;
top: -6px;
font-family: var(--font-display);
font-weight: 600;
@@ -430,6 +419,7 @@
opacity: 0;
pointer-events: none;
}
+.yy-pet[data-facing="left"] .yy-pet-zzz { right: auto; left: 10%; }
.yy-pet[data-state="sleep"] .yy-pet-zzz { animation: yy-pet-zzz 2.8s ease-out infinite; }
.yy-pet[data-state="sleep"] .yy-pet-zzz:nth-of-type(2) { animation-delay: 1.4s; font-size: 11px; }
@@ -449,7 +439,7 @@
/* Fading paw prints in the muted ink. */
.yy-pet-paw {
position: absolute;
- bottom: 10px;
+ bottom: 6px;
width: 9px;
height: 7px;
border-radius: 50% 50% 45% 45%;
@@ -459,23 +449,8 @@
animation: yy-pet-paw 1.8s ease-out forwards;
}
-/* The floppy ear swings as the dog trots and flaps when patted; the tongue
- comes out when it is happy. */
-.yy-pet-ear { transform-box: fill-box; transform-origin: 60% 8%; }
-.yy-pet[data-state="walk"] .yy-pet-ear,
-.yy-pet[data-state="run"] .yy-pet-ear { animation: yy-pet-flap 0.32s ease-in-out infinite alternate; }
-.yy-pet[data-happy] .yy-pet-ear { animation: yy-pet-flap 0.2s ease-in-out 6 alternate; }
-@keyframes yy-pet-flap { from { transform: rotate(-6deg); } to { transform: rotate(10deg); } }
-.yy-pet-tongue { transform-box: fill-box; transform-origin: 50% 0; transform: scaleY(0); transition: transform 160ms ease-out; }
-.yy-pet[data-happy] .yy-pet-tongue,
-.yy-pet[data-state="run"] .yy-pet-tongue { transform: scaleY(1); }
-
-@keyframes yy-pet-step { from { transform: rotate(-16deg); } to { transform: rotate(16deg); } }
-@keyframes yy-pet-bob { from { transform: translateY(0); } to { transform: translateY(-2.5px); } }
-@keyframes yy-pet-wag { 0%, 100% { transform: rotate(-10deg); } 50% { transform: rotate(16deg); } }
-@keyframes yy-pet-tilt { 0%, 70%, 100% { transform: rotate(0deg); } 80%, 90% { transform: rotate(-9deg); } }
-@keyframes yy-pet-blink { 0%, 94%, 100% { transform: scaleY(1); } 96% { transform: scaleY(0.1); } }
-@keyframes yy-pet-breathe { 0%, 100% { transform: scaleY(1); } 50% { transform: scaleY(0.95); } }
+@keyframes yy-pet-walk { from { background-position: 0 0; } to { background-position: calc(800% / 7) 0; } }
+@keyframes yy-pet-bounce { from { translate: 0 0; } to { translate: 0 -3px; } }
@keyframes yy-pet-zzz { 0% { opacity: 0; transform: translate(0, 0) scale(0.7); } 25% { opacity: 0.8; } 100% { opacity: 0; transform: translate(14px, -26px) scale(1.2); } }
@keyframes yy-pet-heart { 0% { opacity: 0; transform: translateY(0) scale(0.6); } 20% { opacity: 1; } 100% { opacity: 0; transform: translateY(-34px) scale(1.15); } }
@keyframes yy-pet-paw { 0% { opacity: 0.35; } 100% { opacity: 0; } }
diff --git a/components/feedback/NotFoundPet.tsx b/components/feedback/NotFoundPet.tsx
index 09c70af0..b95a22a8 100644
--- a/components/feedback/NotFoundPet.tsx
+++ b/components/feedback/NotFoundPet.tsx
@@ -5,24 +5,23 @@ import { useCallback, useEffect, useRef } from "react";
type PetState = "walk" | "run" | "sit" | "sleep";
const EDGE = 12;
-/** Golden coat: Warm Yellow and Workshop Orange mixed 70/30; shading 50/50. */
-const COAT = "color-mix(in srgb, var(--sw-yellow) 70%, var(--sw-orange))";
-const SHADE = "color-mix(in srgb, var(--sw-yellow) 50%, var(--sw-orange))";
const WANDER_AFTER_MS = 4_500;
const SLEEP_AFTER_MS = 15_000;
/**
- * A small decorative golden retriever that lives along the bottom of the 404
- * page. It runs toward the pointer (or a tap on touch screens), watches the
- * pointer, leaves fading paw prints, wanders and naps when ignored, and wags,
- * flops its ear, sticks its tongue out and sends up hearts when patted.
- * Purely decorative: hidden from assistive tech and click-through (it never
- * blocks page controls), and it sits still for reduced-motion users.
+ * Patch, the Space Works mascot, trotting along the bottom of the 404 page. Patch runs toward the
+ * pointer (or a tap on touch screens), leaves fading paw prints, wanders and naps when ignored, and
+ * closes its eyes happily and sends up hearts when patted.
+ *
+ * The artwork is the design system's walk cycle (SpaceWorks-Design `mascot/original/walk/` and the
+ * approved left-facing `walk-left/`), eight frames per direction in one sprite sheet each under
+ * `public/brand/patch/`. Patch is never flipped or redrawn here: heading left uses the left-facing
+ * sheet. Purely decorative: hidden from assistive tech and click-through (it never blocks page
+ * controls), and it sits still for reduced-motion users.
*/
export function NotFoundPet() {
const layerRef = useRef(null);
const petRef = useRef(null);
- const bodyRef = useRef(null);
const pat = useCallback(() => {
const pet = petRef.current;
@@ -31,7 +30,7 @@ export function NotFoundPet() {
for (let i = 0; i < 3; i++) {
const heart = document.createElement("span");
heart.className = "yy-pet-heart";
- heart.style.left = `${24 + i * 12}px`;
+ heart.style.left = `${30 + i * 14}%`;
heart.style.animationDelay = `${i * 120}ms`;
pet.appendChild(heart);
window.setTimeout(() => heart.remove(), 1400);
@@ -44,22 +43,19 @@ export function NotFoundPet() {
useEffect(() => {
const layer = layerRef.current;
const pet = petRef.current;
- const body = bodyRef.current;
- if (!layer || !pet || !body) return;
+ if (!layer || !pet) return;
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
- const petWidth = () => pet.offsetWidth || 64;
+ const petWidth = () => pet.offsetWidth || 88;
const maxX = () => Math.max(EDGE, window.innerWidth - petWidth() - EDGE);
let x = reduceMotion ? maxX() - 8 : Math.min(maxX(), window.innerWidth * 0.18);
let target = x;
- let facing = 1;
let state: PetState = "sit";
let lastInput = performance.now();
let nextWander = 0;
let hop = 0; // 0..1 progress of a hop arc
let hopping = false;
let stepDistance = 0;
- let pointer = { x: window.innerWidth / 2, y: window.innerHeight / 2 };
let frame = 0;
const setState = (next: PetState) => {
@@ -67,26 +63,20 @@ export function NotFoundPet() {
state = next;
pet.dataset.state = next;
};
+ const face = (direction: "left" | "right") => {
+ if (pet.dataset.facing !== direction) pet.dataset.facing = direction;
+ };
const render = () => {
const lift = hopping ? Math.sin(hop * Math.PI) * 46 : 0;
pet.style.transform = `translate3d(${x}px, ${-lift}px, 0)`;
- body.style.transform = `scaleX(${facing})`;
- // Eyes look toward the pointer (mirrored when facing left).
- const headX = x + petWidth() * (facing > 0 ? 0.72 : 0.28);
- const headY = window.innerHeight - 52 - lift;
- const dx = pointer.x - headX;
- const dy = pointer.y - headY;
- const length = Math.hypot(dx, dy) || 1;
- pet.style.setProperty("--look-x", `${((dx / length) * 1.6 * facing).toFixed(2)}px`);
- pet.style.setProperty("--look-y", `${((dy / length) * 1.4).toFixed(2)}px`);
};
const dropPaw = () => {
const paw = document.createElement("span");
paw.className = "yy-pet-paw";
paw.style.left = `${x + petWidth() / 2 + (Math.random() * 10 - 5)}px`;
- paw.style.transform = `rotate(${facing > 0 ? 90 : -90}deg)`;
+ paw.style.transform = `rotate(${pet.dataset.facing === "left" ? -90 : 90}deg)`;
layer.appendChild(paw);
window.setTimeout(() => paw.remove(), 1800);
};
@@ -102,12 +92,10 @@ export function NotFoundPet() {
};
const onPointerMove = (event: PointerEvent) => {
- pointer = { x: event.clientX, y: event.clientY };
if (event.pointerType === "mouse" || event.buttons > 0) goTo(event.clientX);
else lastInput = performance.now();
};
const onPointerDown = (event: PointerEvent) => {
- pointer = { x: event.clientX, y: event.clientY };
// The layer is click-through, so detect a pat by position instead of a click handler.
const bounds = pet.getBoundingClientRect();
if (event.clientX >= bounds.left && event.clientX <= bounds.right && event.clientY >= bounds.top - 8 && event.clientY <= bounds.bottom) {
@@ -125,6 +113,7 @@ export function NotFoundPet() {
};
pet.dataset.state = state;
+ face(reduceMotion ? "left" : "right");
render();
window.addEventListener("resize", onResize);
if (reduceMotion) return () => window.removeEventListener("resize", onResize);
@@ -151,7 +140,7 @@ export function NotFoundPet() {
const speed = Math.min(Math.abs(dx) * 0.06, fast ? 7.5 : 3.2) * dt;
const move = Math.sign(dx) * Math.max(0.6, speed);
x += Math.abs(move) > Math.abs(dx) ? dx : move;
- facing = dx > 0 ? 1 : -1;
+ face(dx > 0 ? "right" : "left");
setState(fast ? "run" : "walk");
stepDistance += Math.abs(move);
if (stepDistance > 34) {
@@ -184,77 +173,10 @@ export function NotFoundPet() {
return (
-
+
zz
-
- {/* A golden retriever drawn in brand colours: the coat is Warm Yellow and
- Workshop Orange mixed 70/30 (50/50 in the shading), Warm Cream for the
- snout and highlights, Deep Ink for lines, eyes and nose, and a Maker
- Teal collar with a Citron tag. The tongue (Workshop Orange) shows when
- the dog is happy. */}
-
-
+
);
diff --git a/docs/readme/screens/not-found.webp b/docs/readme/screens/not-found.webp
deleted file mode 100644
index 61eb328b..00000000
Binary files a/docs/readme/screens/not-found.webp and /dev/null differ
diff --git a/public/brand/patch/patch-walk-left.webp b/public/brand/patch/patch-walk-left.webp
new file mode 100644
index 00000000..c10962a9
Binary files /dev/null and b/public/brand/patch/patch-walk-left.webp differ
diff --git a/public/brand/patch/patch-walk-right.webp b/public/brand/patch/patch-walk-right.webp
new file mode 100644
index 00000000..30835b37
Binary files /dev/null and b/public/brand/patch/patch-walk-right.webp differ