Skip to content
Merged
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
5 changes: 1 addition & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,10 +117,7 @@ The full list lives in [Features](docs/FEATURES.md).
<img src="docs/readme/screens/member-id-wallet-dark.webp" alt="A member's ID card in the account wallet, in dark mode" />
<p><strong>ID wallet, dark mode.</strong> Tilt it, flip it, verify it.</p>
</td>
<td width="50%" valign="top">
<img src="docs/readme/screens/not-found.webp" alt="The 404 page: a torn ticket and a golden retriever along the bottom" />
<p><strong>404.</strong> This page isn't on the programme, but the golden retriever is.</p>
</td>
<td width="50%" valign="top"></td>
</tr>
</table>

Expand Down
67 changes: 21 additions & 46 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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; }

Expand All @@ -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%;
Expand All @@ -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; } }
Expand Down
118 changes: 20 additions & 98 deletions components/feedback/NotFoundPet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLDivElement>(null);
const petRef = useRef<HTMLDivElement>(null);
const bodyRef = useRef<HTMLDivElement>(null);

const pat = useCallback(() => {
const pet = petRef.current;
Expand All @@ -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);
Expand All @@ -44,49 +43,40 @@ 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) => {
if (state === next) return;
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);
};
Expand All @@ -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) {
Expand All @@ -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);
Expand All @@ -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) {
Expand Down Expand Up @@ -184,77 +173,10 @@ export function NotFoundPet() {

return (
<div ref={layerRef} aria-hidden className="pointer-events-none fixed inset-0 z-30 overflow-hidden">
<div ref={petRef} className="yy-pet absolute bottom-2 left-0 w-14 select-none sm:bottom-3 sm:w-[72px]">
<div ref={petRef} className="yy-pet absolute bottom-1 left-0 w-[88px] select-none sm:bottom-2 sm:w-[104px]">
<span className="yy-pet-zzz">z</span>
<span className="yy-pet-zzz">z</span>
<div ref={bodyRef} className="yy-pet-body">
{/* 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. */}
<svg viewBox="0 0 80 64" className="block w-full overflow-visible" strokeLinejoin="round" strokeLinecap="round">
<ellipse cx="40" cy="61" rx="26" ry="3" className="fill-ink opacity-10" />
{/* Feathery tail, hinged at the rump */}
<g className="yy-pet-tail">
<path
className="stroke-sw-ink"
style={{ fill: SHADE }}
d="M20 32 C12 31 6 26 3 17 C6 20 9 21 11 21 C8 18 7 14 8 10 C12 17 17 23 23 27 Z"
strokeWidth="1.6"
/>
<path className="fill-none stroke-sw-cream opacity-70" d="M8 15 C10 20 14 24 19 27" strokeWidth="1.2" />
</g>
{/* Legs with cream paws */}
{[
{ x: 18, side: "a" },
{ x: 28, side: "b" },
{ x: 41, side: "b" },
{ x: 51, side: "a" },
].map((leg) => (
<g key={leg.x} className={`yy-pet-leg yy-pet-leg-${leg.side}`}>
<rect x={leg.x} y="42" width="7" height="15" rx="3.5" className="stroke-sw-ink" style={{ fill: COAT }} strokeWidth="1.6" />
<ellipse cx={leg.x + 3.5} cy="56.5" rx="4" ry="2" className="fill-sw-cream stroke-sw-ink" strokeWidth="1.4" />
</g>
))}
<g className="yy-pet-torso">
<rect x="13" y="25" width="46" height="22" rx="11" className="stroke-sw-ink" style={{ fill: COAT }} strokeWidth="2" />
{/* Shaded back and a fluffy cream chest */}
<path d="M20 27 C30 24 44 24 54 27" className="fill-none opacity-80" style={{ stroke: SHADE }} strokeWidth="3" />
<ellipse cx="34" cy="42" rx="13" ry="3.2" className="fill-sw-cream opacity-60" />
<g className="yy-pet-head">
{/* Collar and tag */}
<path d="M51 31 C54 37 60 39 66 37" className="fill-none stroke-sw-teal" strokeWidth="3.4" />
<circle cx="60" cy="40.5" r="2.4" className="fill-sw-citron stroke-sw-ink" strokeWidth="1.2" />
<circle cx="61" cy="22" r="13" className="stroke-sw-ink" style={{ fill: COAT }} strokeWidth="2" />
{/* Snout, nose, mouth and tongue */}
<ellipse cx="71" cy="27" rx="8" ry="6" className="fill-sw-cream stroke-sw-ink" strokeWidth="1.8" />
<ellipse cx="77" cy="24.6" rx="2.6" ry="2.1" className="fill-sw-ink" />
<path className="yy-pet-tongue fill-sw-orange stroke-sw-ink" d="M70 30.5 C70 36 75 36 75 30.5 Z" strokeWidth="1.2" />
<path d="M68 29.5 Q71.5 32.5 76 29" className="fill-none stroke-sw-ink" strokeWidth="1.4" />
{/* Floppy ear */}
<path
className="yy-pet-ear stroke-sw-ink"
style={{ fill: SHADE }}
d="M53 12 C46 13 44 23 47 31 C50 33 54 30 56 24 C57 19 56 14 53 12 Z"
strokeWidth="1.8"
/>
<g className="yy-pet-eyes">
<g className="yy-pet-pupils">
<circle cx="60.5" cy="19" r="2.5" className="fill-sw-ink" />
<circle cx="67.5" cy="18.5" r="2.3" className="fill-sw-ink" />
<circle cx="61.3" cy="18.2" r="0.8" className="fill-sw-cream" />
<circle cx="68.2" cy="17.7" r="0.75" className="fill-sw-cream" />
</g>
</g>
<g className="yy-pet-sleepy-eyes">
<path d="M58 19.5 q2.5 2.2 5 0" className="fill-none stroke-sw-ink" strokeWidth="1.6" />
<path d="M65.2 19 q2.4 2.1 4.8 0" className="fill-none stroke-sw-ink" strokeWidth="1.6" />
</g>
</g>
</g>
</svg>
</div>
<div className="yy-pet-sprite" />
</div>
</div>
);
Expand Down
Binary file removed docs/readme/screens/not-found.webp
Binary file not shown.
Binary file added public/brand/patch/patch-walk-left.webp
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 public/brand/patch/patch-walk-right.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading